"""Original compact hatchback. Blender 5.2, clean scene, CC0 scan-derived Cycles tile bakes.
Run: blender -b --python-exit-code 1 --python build_euro-hatchback.py
Blender coordinates: X left, -Y front, Z up. glTF: X left, Y up, Z front.
"""
import bpy, math, os, json, urllib.request, struct, time
from mathutils import Vector
from math import sin, cos, pi, sqrt
import numpy as np
ROOT=os.path.dirname(os.path.abspath(__file__))
os.makedirs(os.path.join(ROOT,'textures'),exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
sc=bpy.context.scene;sc.unit_settings.system='METRIC';sc.render.engine='CYCLES'
sc.cycles.samples=24;sc.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX';prefs.get_devices()
for d in prefs.devices:d.use=d.type!='CPU'
sc.cycles.device='GPU'
sc.render.image_settings.file_format='PNG';sc.render.film_transparent=False
sc.world=bpy.data.worlds.new('Daylight');sc.world.use_nodes=True
sc.world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.72,.82,1)
sc.world.node_tree.nodes['Background'].inputs[1].default_value=.45
sc.view_settings.view_transform='AgX'
print('FINAL_SOLID_SHELL_BUILD',flush=True)
M={}; parts={}; wheelgroups={}; lampobjs=[]
PH=['metal_plate','rubber_tiles','fabric_leather_01']
def download(id,mp):
 p=os.path.join(ROOT,'textures',f'{id}_{mp}_1k.jpg')
 if not os.path.exists(p):
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{mp}_1k.jpg'
  req=urllib.request.Request(url,headers={'User-Agent':'ForgeAsset/1.0'})
  with urllib.request.urlopen(req,timeout=60) as r:open(p,'wb').write(r.read())
 return bpy.data.images.load(p,check_existing=True)
def image_node(nt,img):
 n=nt.nodes.new('ShaderNodeTexImage');n.image=img;return n
# All eight game materials get a Cycles-baked tile set. Small physically scaled tiles
# retain scan microstructure without allocating a separate atlas per component.
def baked_material(name,scan,color,rough,metal,size=512):
 print('BAKE',name,flush=True)
 m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;nt.nodes.clear()
 out=nt.nodes.new('ShaderNodeOutputMaterial');bs=nt.nodes.new('ShaderNodeBsdfPrincipled');nt.links.new(bs.outputs[0],out.inputs['Surface'])
 tex=nt.nodes.new('ShaderNodeTexCoord');noise=nt.nodes.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=185;noise.inputs['Roughness'].default_value=.7;nt.links.new(tex.outputs['UV'],noise.inputs['Vector'])
 ramp=nt.nodes.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].color=tuple(v*.91 for v in color)+(1,);ramp.color_ramp.elements[1].color=tuple(color)+(1,)
 if scan:
  diff=image_node(nt,download(scan,'diff'));normal=image_node(nt,download(scan,'nor_gl'));arm=image_node(nt,download(scan,'arm'))
  normal.image.colorspace_settings.name='Non-Color';arm.image.colorspace_settings.name='Non-Color'
  bw=nt.nodes.new('ShaderNodeRGBToBW');nt.links.new(diff.outputs['Color'],bw.inputs[0]);nt.links.new(bw.outputs[0],ramp.inputs[0])
 else:nt.links.new(noise.outputs['Fac'],ramp.inputs[0])
 nt.links.new(ramp.outputs[0],bs.inputs['Base Color'])
 bs.inputs['Roughness'].default_value=rough;bs.inputs['Metallic'].default_value=metal
 bump=nt.nodes.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.09 if name=='Paint' else .2;bump.inputs['Distance'].default_value=.00012 if name=='Paint' else .0008
 nt.links.new(noise.outputs['Fac'],bump.inputs['Height']);nt.links.new(bump.outputs[0],bs.inputs['Normal'])
 if scan:
  nm=nt.nodes.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.025 if name=='Paint' else .18
  nt.links.new(normal.outputs['Color'],nm.inputs[1]);nt.links.new(nm.outputs[0],bump.inputs['Normal'])
 bpy.ops.mesh.primitive_plane_add(size=1);plane=bpy.context.object;plane.name='BakeTile';plane.data.materials.append(m)
 sc.cycles.samples=4;sc.render.bake.margin=8
 target=nt.nodes.new('ShaderNodeTexImage')
 def bake(label,res,kind,source=None):
  im=bpy.data.images.new(name+'_'+label,res,res,alpha=False)
  if label!='Base':im.colorspace_settings.name='Non-Color'
  target.image=im;nt.nodes.active=target
  if source:
   em=nt.nodes.new('ShaderNodeEmission');nt.links.new(source,em.inputs[0]);nt.links.new(em.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT');nt.nodes.remove(em);nt.links.new(bs.outputs[0],out.inputs[0])
  else:bpy.ops.object.bake(type=kind)
  im.filepath_raw=os.path.join(ROOT,'textures',name+'_'+label+'.png');im.file_format='PNG';im.save();return im
 if name=='Lamps':
  sep=nt.nodes.new('ShaderNodeSeparateXYZ');nt.links.new(tex.outputs['UV'],sep.inputs[0]);cr=nt.nodes.new('ShaderNodeValToRGB');cr.color_ramp.interpolation='CONSTANT'
  cr.color_ramp.elements[0].position=0;cr.color_ramp.elements[0].color=(1,.83,.57,1)
  cr.color_ramp.elements[1].position=.333;cr.color_ramp.elements[1].color=(.65,.006,.008,1)
  e=cr.color_ramp.elements.new(.667);e.color=(1,.25,.008,1);nt.links.new(sep.outputs['X'],cr.inputs[0]);colorout=cr.outputs[0]
 else:colorout=ramp.outputs[0]
 base=bake('Base',size,'EMIT',colorout)
 norm=bake('Normal',size,'NORMAL')
 comb=nt.nodes.new('ShaderNodeCombineXYZ');comb.inputs[0].default_value=1
 # Scanned roughness lightly modulates the intended finish; microscopic grime remains restrained.
 if scan:
  separate=nt.nodes.new('ShaderNodeSeparateColor');nt.links.new(arm.outputs[0],separate.inputs[0]);mul=nt.nodes.new('ShaderNodeMath');mul.operation='MULTIPLY_ADD';mul.inputs[1].default_value=.09;mul.inputs[2].default_value=max(.025,rough-.04);nt.links.new(separate.outputs[1],mul.inputs[0]);nt.links.new(mul.outputs[0],comb.inputs[1])
 else:comb.inputs[1].default_value=rough
 comb.inputs[2].default_value=metal;orm=bake('ORM',min(256,size),'EMIT',comb.outputs[0])
 bpy.data.objects.remove(plane,do_unlink=True)
 nt.nodes.clear();out=nt.nodes.new('ShaderNodeOutputMaterial');bs=nt.nodes.new('ShaderNodeBsdfPrincipled');nt.links.new(bs.outputs[0],out.inputs[0])
 bn=image_node(nt,base);nt.links.new(bn.outputs[0],bs.inputs['Base Color']);nn=image_node(nt,norm);nm=nt.nodes.new('ShaderNodeNormalMap');nt.links.new(nn.outputs[0],nm.inputs['Color']);nt.links.new(nm.outputs[0],bs.inputs['Normal'])
 on=image_node(nt,orm);sep=nt.nodes.new('ShaderNodeSeparateColor');nt.links.new(on.outputs[0],sep.inputs[0]);nt.links.new(sep.outputs[1],bs.inputs['Roughness']);nt.links.new(sep.outputs[2],bs.inputs['Metallic'])
 if name=='Paint':bs.inputs['Coat Weight'].default_value=1;bs.inputs['Coat Roughness'].default_value=.05
 if name=='Glass':
  bs.inputs['Alpha'].default_value=.30;bs.inputs['Roughness'].default_value=.075;m.surface_render_method='BLENDED';m.diffuse_color=(.14,.21,.24,.3)
 if name=='Lamps':nt.links.new(bn.outputs[0],bs.inputs['Emission Color']);bs.inputs['Emission Strength'].default_value=.7
 M[name]=m;return m
baked_material('Paint','metal_plate',(.96,.96,.96),.25,.55,1024)
baked_material('Rubber','rubber_tiles',(.027,.029,.032),.67,0)
baked_material('Alloy','metal_plate',(.52,.55,.58),.24,.95)
baked_material('BrakeSteel','metal_plate',(.12,.13,.145),.48,.8,256)
baked_material('Trim','rubber_tiles',(.018,.022,.026),.37,0)
baked_material('Interior','fabric_leather_01',(.055,.062,.07),.76,0)
baked_material('Glass',None,(.075,.11,.14),.075,.05,128)
baked_material('Lamps',None,(1,1,1),.19,.15,128)
sc.cycles.samples=24
# Mesh utilities. All components are eventually merged into the specified rigid nodes.
def finish(o,mat='Paint',group='Body',smooth=True):
 o.data.materials.append(M[mat]);parts.setdefault(group,[]).append(o)
 for p in o.data.polygons:p.use_smooth=smooth
 return o
def mesh(name,v,f,mat='Paint',group='Body'):
 me=bpy.data.meshes.new(name);me.from_pydata(v,[],f);me.update();o=bpy.data.objects.new(name,me);sc.collection.objects.link(o);finish(o,mat,group)
 return o
def bevel(o,w=.01,n=3):
 mod=o.modifiers.new('Manufactured edge radii','BEVEL');mod.width=w;mod.segments=n
 return o
def cube(name,loc,scale,mat='Paint',radius=.015,group='Body'):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.name=name;o.scale=scale;bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);finish(o,mat,group)
 if radius:bevel(o,radius,3)
 return o
def uvball(name,loc,scale,mat='Paint',group='Body',seg=32,rings=16):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=seg,ring_count=rings,radius=1,location=loc);o=bpy.context.object;o.name=name;o.scale=scale;bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);return finish(o,mat,group)
def curve(name,coords,r,mat='Trim',group='Body',cyclic=False):
 cu=bpy.data.curves.new(name,'CURVE');cu.dimensions='3D';cu.resolution_u=16;cu.bevel_depth=r;cu.bevel_resolution=1
 sp=cu.splines.new('POLY');sp.points.add(len(coords)-1)
 for p,co in zip(sp.points,coords):p.co=(*co,1)
 sp.use_cyclic_u=cyclic;o=bpy.data.objects.new(name,cu);sc.collection.objects.link(o);cu.materials.append(M[mat]);parts.setdefault(group,[]).append(o);return o
def cylinder(name,loc,r,depth,mat='Alloy',group='Body',axis='X',vertices=64):
 bpy.ops.mesh.primitive_cylinder_add(vertices=vertices,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
 bpy.ops.object.transform_apply(location=False,rotation=True,scale=True);finish(o,mat,group);bevel(o,.002,2);return o
def torus_x(name,loc,major,minor,mat='Rubber',group='Body',n=80,k=12):
 v=[];f=[]
 for i in range(n):
  a=2*pi*i/n
  for j in range(k):
   b=2*pi*j/k;v.append((loc[0]+minor*sin(b),loc[1]+(major+minor*cos(b))*sin(a),loc[2]+(major+minor*cos(b))*cos(a)))
 for i in range(n):
  for j in range(k):f.append((i*k+j,((i+1)%n)*k+j,((i+1)%n)*k+(j+1)%k,i*k+(j+1)%k))
 return mesh(name,v,f,mat,group)
def rounded(poly,r=.045,steps=6):
 out=[]
 for i,p in enumerate(poly):
  a=Vector(poly[i-1]);b=Vector(p);c=Vector(poly[(i+1)%len(poly)])
  d=min(r,(a-b).length*.3,(c-b).length*.3)
  u=b+(a-b).normalized()*d;v=b+(c-b).normalized()*d
  for j in range(steps+1):
   t=j/steps;out.append(tuple((1-t)**2*u+2*(1-t)*t*b+t*t*v))
 return out
def interp(y,tab):
 for (a,b),(c,d) in zip(tab,tab[1:]):
  if y<=c:return b+(d-b)*(y-a)/(c-a)
 return tab[-1][1]
def top(y):return interp(y,[(-2.08,.77),(-1.94,.875),(-1.6,.93),(-.92,.99),(-.55,1.005),(.9,1.00),(1.55,.965),(1.87,.91),(2.02,.81)])
def width(y):return interp(y,[(-2.08,.73),(-1.94,.83),(-1.65,.875),(-1.3,.89),(-.7,.862),(.6,.87),(1.3,.89),(1.7,.85),(2.02,.75)])
def bottom(y):
 z=.225
 for wy in [-1.31,1.31]:
  dy=abs(y-wy)
  if dy<.367:z=max(z,.315+sqrt(.367**2-dy**2))
 return z
def sx(y,z):
 t=max(0,min(1,(z-.225)/(top(y)-.225)))
 return width(y)+interp(t,[(0,-.045),(.13,-.018),(.38,-.004),(.62,-.002),(.8,-.008),(1,-.03)])
# A continuous rounded monocoque replaces disconnected fascia and side patches.
sections=[(-2.08,.53,.77,.29),(-2.055,.64,.81,.27),(-1.98,.77,.85,.245),(-1.80,.848,.90,.225),(-1.53,.882,.947,.218),(-1.31,.89,.966,.218),(-.93,.866,.994,.224),(-.65,.861,1.001,.224),(.15,.863,1.001,.224),(.7,.875,.997,.224),(1.10,.89,.984,.224),(1.40,.887,.973,.227),(1.73,.849,.947,.242),(1.95,.773,.879,.275),(2.02,.652,.79,.30)]
# Cross section has a gently crowned hood, tight shoulder and tucked-under rocker.
profile=[(0,1.035),(.45,1.025),(.78,1.0),(.935,.966),(.984,.884),(1,.72),(.997,.50),(.986,.26),(.950,.095),(.84,.02),(.50,0),(0,0),(-.50,0),(-.84,.02),(-.950,.095),(-.986,.26),(-.997,.50),(-1,.72),(-.984,.884),(-.935,.966),(-.78,1.0),(-.45,1.025)]
v=[];f=[];nk=len(profile)
for y,w,zt,zb in sections:
 for xx,zz in profile:v.append((w*xx,y,zb+(zt-zb)*zz))
for i in range(len(sections)-1):
 for j in range(nk):f.append((i*nk+j,i*nk+(j+1)%nk,(i+1)*nk+(j+1)%nk,(i+1)*nk+j))
f.append(tuple(range(nk-1,-1,-1)));f.append(tuple((len(sections)-1)*nk+j for j in range(nk)))
hull=mesh('Continuous pressed monocoque',v,f)
bpy.context.view_layer.objects.active=hull;hull.select_set(True)
sub=hull.modifiers.new('Body surface curvature','SUBSURF');sub.levels=3;bpy.ops.object.modifier_apply(modifier=sub.name)
# Functional holes are boolean openings in the actual solid, not black discs.
for wy in [-1.31,1.31]:
 bpy.ops.mesh.primitive_cylinder_add(vertices=96,radius=.363,depth=2.3,location=(0,wy,.315),rotation=(0,pi/2,0));cut=bpy.context.object
 bpy.context.view_layer.objects.active=hull;mod=hull.modifiers.new('Open wheelhouse','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cut;bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cut,do_unlink=True)
bpy.ops.mesh.primitive_cube_add(size=1,location=(0,.36,1.11));cut=bpy.context.object;cut.scale=(1.48,2.38,1.49);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
bev=cut.modifiers.new('Cabin corner','BEVEL');bev.width=.11;bev.segments=4;bpy.ops.object.modifier_apply(modifier=bev.name)
bpy.context.view_layer.objects.active=hull;mod=hull.modifiers.new('Open passenger cabin','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cut;bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cut,do_unlink=True)
bevel(hull,.006,2);hull.select_set(False)
ys=[-2.08+4.1*i/180 for i in range(181)]
for s in [-1,1]:
 for wy in [-1.31,1.31]:
  coords=[]
  for i in range(81):
   a=-.22+(pi+.44)*i/80;y=wy-.365*cos(a);z=.315+.365*sin(a);coords.append((s*(sx(y,z)-.008),y,z))
  curve('Rolled wheel arch lip',coords,.0055,'Paint')
  v=[];f=[]
  for i in range(65):
   a=-.3+(pi+.6)*i/64
   for x in [.67,.846]:v.append((s*x,wy-.358*cos(a),.315+.358*sin(a)))
  for i in range(64):f.append((2*i,2*i+1,2*i+3,2*i+2))
  mesh('Wheel arch liner',v,f,'Trim')
 # Sills, door shut lines, handles and fuel cover
 curve('Rocker bright edge',[(s*.835,y,.257) for y in [-.935,-.75,0,.75,.935]],.009,'Paint')
 for line in [0,1,2]:
  coords=[]
  for i in range(40):
   z=.276+(.997-.276)*i/39
   if line==0:y=-.80-.065*sin(pi*i/39)
   elif line==1:y=.30+.035*(1-i/39)
   else:y=1.10+.34*(i/39)**1.4
   if z<bottom(y)+.015:continue
   coords.append((s*(sx(y,z)+.0025),y,z))
  curve('Door shut gap',coords,.0022,'Trim')
 for y in [.12,1.03]:
  uvball('Handle recess',(s*(sx(y,.914)+.003),y,.913),(.007,.112,.027),'Trim')
  uvball('Body colour pull handle',(s*(sx(y,.918)+.017),y,.924),(.019,.096,.016),'Paint')
  curve('Handle chrome underline',[(s*(sx(y,.916)+.032),y-.067,.916),(s*(sx(y,.916)+.034),y+.067,.916)],.0028,'Alloy')
 if s==1:
  pts=[]
  for i in range(65):
   a=2*pi*i/64;y=1.63+.105*math.copysign(abs(cos(a))**.5,cos(a));z=.857+.072*math.copysign(abs(sin(a))**.5,sin(a));pts.append((s*(sx(y,z)+.002),y,z))
  curve('Fuel flap shut line',pts,.0018,'Trim')
cube('Underbody',(0,0,.245),(1.47,3.55,.06),'Trim',.035)
for s in [-1,1]:
 curve('Bonnet panel gap',[(s*(width(y)-.11),y,top(y)+.012) for y in [-1.99,-1.85,-1.6,-1.3,-1.04,-.85]],.002,'Trim')
# Cabin side surface: side glass follows a tapered upper body, no opaque capsule underneath.
def roofedge(y):
 return float(np.interp(y,[-.258,0,.3,.65,.9,1.04,1.153],[1.411,1.432,1.436,1.430,1.419,1.406,1.391]))
def cabx(y,z):return .830-.160*max(0,min(1,(z-.99)/.45))+.012*sin((y+.8)*1.9)
def cp(s,y,z,off=0):return(s*(cabx(y,z)+off),y,z)
def panel(name,points,mat='Paint',group='Body',thick=.0):
 o=mesh(name,points,[tuple(range(len(points)))],mat,group)
 if thick:
  mod=o.modifiers.new('Panel gauge','SOLIDIFY');mod.thickness=thick;bevel(o,.005,3)
 return o
# The door shoulder rises smoothly into the glazing belt.
for s in [-1,1]:
 v=[];f=[]
 for i in range(61):
  y=-.865+2.49*i/60;zlow=.957
  ok,hit,no,idx=hull.ray_cast(Vector((s*1.3,y,zlow)),Vector((-s,0,0)))
  xl=hit.x if ok else s*(width(y)-.015);xt=cp(s,y,1.017)[0]
  for k in range(9):
   t=k/8;v.append((xl*(1-t)+xt*t+s*.002*sin(pi*t),y,zlow+(1.017-zlow)*t))
 for i in range(60):
  for k in range(8):a=i*9+k;f.append((a,a+9,a+10,a+1))
 o=mesh('Waist rail sheet',v,f,'Paint');mod=o.modifiers.new('Waist thickness','SOLIDIFY');mod.thickness=.005
# Each side surround is one sheet with real window openings, then curved in X.
windows=[ [(-.79,1.034),(-.34,1.345),(-.19,1.395),(.247,1.408),(.265,1.031)],[(.35,1.032),(.333,1.411),(.872,1.391),(1.053,1.333),(1.17,1.035)],[(1.215,1.036),(1.119,1.307),(1.295,1.207),(1.434,1.052)] ]
outer=[(-.89,1.007),(-.40,1.351),(-.258,roofedge(-.258))]
outer += [(y,roofedge(y)) for y in [-.258+1.411*i/48 for i in range(1,49)]]
outer += [(1.37,1.219),(1.66,1.006)]
def prism_yz(name,poly,half,mat='Paint'):
 n=len(poly);vv=[(x,y,z) for x in [-half,half] for y,z in poly]
 ff=[tuple(range(n-1,-1,-1)),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
 o=mesh(name,vv,ff,mat)
 import bmesh
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(o.data);bm.free()
 return o
for s in [-1,1]:
 surround=prism_yz('Integrated cabin side surround',outer,.009)
 for k,poly in enumerate(windows):
  rp=rounded(poly,.055,8)
  cut=prism_yz('Window forming tool',rp,.15)
  bpy.context.view_layer.objects.active=surround
  mod=surround.modifiers.new('Glazing aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cut;bpy.ops.object.modifier_apply(modifier=mod.name)
  parts['Body'].remove(cut);bpy.data.objects.remove(cut,do_unlink=True)
  # A small structured fan follows the same curved side surface without nonplanar n-gons.
  cy=sum(y for y,z in rp)/len(rp);cz=sum(z for y,z in rp)/len(rp)
  vv=[cp(s,cy,cz,.011)];ff=[];nr=len(rp)
  for ring in range(1,5):
   t=ring/4
   vv += [cp(s,cy+(y-cy)*t,cz+(z-cz)*t,.011) for y,z in rp]
  for i in range(nr):ff.append((0,1+i,1+(i+1)%nr))
  for r in range(3):
   a=1+r*nr;b=a+nr
   for i in range(nr):ff.append((a+i,a+(i+1)%nr,b+(i+1)%nr,b+i))
  mesh('Curved side glass '+str(k),vv,ff,'Glass')
  pts=[cp(s,y,z,.013) for y,z in rp]
  curve('Window bonded rubber',pts,.0045,'Trim',cyclic=True)
  curve('Polished window trim',[cp(s,y,z,.017) for y,z in rp],.002,'Alloy',cyclic=True)
 # Subdivide planar triangles before curving the stamped metal panel.
 import bmesh
 bm=bmesh.new();bm.from_mesh(surround.data);bmesh.ops.triangulate(bm,faces=list(bm.faces));bmesh.ops.subdivide_edges(bm,edges=list(bm.edges),cuts=1,use_grid_fill=True);bm.to_mesh(surround.data);bm.free()
 for v in surround.data.vertices:
  depth,y,z=v.co;v.co=cp(s,y,z,depth)
 # Remove stray bridge faces created when a holed polygon is tessellated.
 def inside_window(y,z,poly):
  inside=False
  for i,(ay,az) in enumerate(poly):
   by,bz=poly[i-1]
   if (az>z)!=(bz>z) and y<(by-ay)*(z-az)/(bz-az)+ay:inside=not inside
  return inside
 bm=bmesh.new();bm.from_mesh(surround.data)
 bad=[]
 for face in bm.faces:
  c=face.calc_center_median()
  if any(inside_window(c.y,c.z,rounded(poly,.055,8)) for poly in windows):
   # Keep the narrow perpendicular aperture walls, remove only crossing sheets.
   if abs(face.normal.x)>.4:bad.append(face)
 bmesh.ops.delete(bm,geom=bad,context='FACES');bm.to_mesh(surround.data);bm.free()
 bevel(surround,.002,2)
 panel('Gloss B pillar',[cp(s,y,z,.012) for y,z in [(.266,1.02),(.250,1.43),(.332,1.43),(.348,1.02)]],'Trim',thick=.003)
 panel('Quarter divider',[cp(s,y,z,.011) for y,z in [(1.17,1.025),(1.053,1.342),(1.12,1.331),(1.215,1.025)]],'Trim',thick=.003)
 curve('Belt weatherstrip',[cp(s,y,1.017,.009) for y in [-.84,-.4,0,.4,.8,1.2,1.5]],.007,'Trim')
 curve('Polished belt',[cp(s,y,1.028,.010) for y in [-.84,-.4,0,.4,.8,1.2,1.5]],.0025,'Alloy')
 # Side mirrors with base stalk, two-piece housing, reflecting inset, amber repeater.
 curve('Mirror stalk',[cp(s,-.69,1.055),(s*.911,-.72,1.047)],.025,'Trim')
 o=uvball('Mirror shell',(s*.964,-.736,1.088),(.106,.132,.057),'Paint')
 for v in o.data.vertices:
  v.co.z*=.78+.22*(v.co.y/.132+1)/2;v.co.x*=.85+.15*(v.co.y/.132+1)/2
 uvball('Mirror black lower',(s*.965,-.733,1.064),(.106,.124,.036),'Trim')
 uvball('Mirror reflective insert',(s*.966,-.633,1.089),(.079,.008,.043),'Alloy')
# Curved windshield/rear glazing and roof loft.
def glass_grid(name,rows,n=32):
 v=[];f=[]
 for y,z,w in rows:
  for j in range(n+1):
   u=-1+2*j/n;v.append((w*u,y-.023*(1-u*u) if y<0 else y+.021*(1-u*u),z+.018*(1-u*u)))
 for i in range(len(rows)-1):
  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,'Glass')
 edge=[v[j] for j in range(n+1)]+[v[i*(n+1)+n] for i in range(1,len(rows))]+[v[(len(rows)-1)*(n+1)+j] for j in range(n-1,-1,-1)]+[v[i*(n+1)] for i in range(len(rows)-2,0,-1)]
 curve(name+' bonded surround',edge,.013,'Trim',cyclic=True);return o
rows=[]
for i in range(17):
 t=i/16;rows.append((-.865+.607*t,1.012+.399*t,.802-.13*t))
glass_grid('Laminated windscreen',rows)
rows=[]
for i in range(15):
 t=i/14;rows.append((1.153+.539*t,1.391-.378*t,.648+.14*t))
glass_grid('Heated hatch glass',rows)
v=[];f=[]
for j in range(41):
 u=-1+2*j/40;x=.788*u;y=1.692+.021*(1-u*u);z=1.013+.018*(1-u*u)
 ok,hit,no,idx=hull.ray_cast(Vector((x,y+.09,2)),Vector((0,0,-1)))
 if not ok:hit=Vector((x,y+.09,.93))
 for k in range(5):
  t=k/4;v.append((x,y*(1-t)+hit.y*t,z*(1-t)+(hit.z+.003)*t+.006*sin(pi*t)))
for j in range(40):
 for k in range(4):a=j*5+k;f.append((a,a+5,a+6,a+1))
o=mesh('Tailgate glazing shoulder',v,f);mod=o.modifiers.new('Hatch shoulder gauge','SOLIDIFY');mod.thickness=.01
# Roof sheet; integrated panoramic glass centre bounded by metal strips.
v=[];f=[]
for i in range(35):
 t=i/34;y=-.258+1.411*t
 for j in range(41):
  u=-1+2*j/40;z=roofedge(y)+(.018+.016*sin(pi*t))*(1-u*u);yy=y+(-.023*(1-t)+.021*t)*(1-u*u);v.append((cabx(y,roofedge(y))*u,yy,z))
for i in range(34):
 for j in range(40):a=i*41+j;f.append((a,a+1,a+42,a+41))
o=mesh('Roof outer panel',v,f);mod=o.modifiers.new('Roof gauge','SOLIDIFY');mod.thickness=.009
# Panoramic insert over roof, surface shaped identically and inset inside surrounding metal.
v=[];f=[]
for i in range(21):
 y=-.09+.97*i/20
 for j in range(25):
  x=-.53+1.06*j/24;u=x/cabx(y,roofedge(y));z=roofedge(y)+(.018+.016*sin(pi*(y+.258)/1.411))*(1-u*u)+.003;v.append((x,y,z))
for i in range(20):
 for j in range(24):a=i*25+j;f.append((a,a+1,a+26,a+25))
mesh('Panoramic roof dark laminate',[(x,y,z-.0015) for x,y,z in v],f,'Trim')
mesh('Panoramic roof glass',v,f,'Glass')
# Rear spoiler, rear hatch shut line, wipers.
cube('Roof spoiler',(0,1.208,1.395),(1.37,.13,.023),'Paint',.016)
for s in [-1,1]:
 curve('Tailgate seam',[(s*.66,1.28,1.345),(s*.775,1.72,.97),(s*.722,1.91,.60),(s*.58,1.986,.558)],.0025,'Trim')
curve('Tailgate lower seam',[(-.58,1.986,.558),(0,2.007,.553),(.58,1.986,.558)],.0025,'Trim')
for s in [-1,1]:
 curve('Wiper arm',[(s*.12,-.887,1.039),(s*.36,-.785,1.109)],.005,'Trim');curve('Wiper blade',[(s*.11,-.802,1.105),(s*.58,-.758,1.122)],.006,'Trim')
curve('Rear wiper',[(.08,1.66,1.076),(-.36,1.593,1.127)],.007,'Trim')
# Lamp geometry uses a single baked three-zone emission tile.
def lamp(name,points,zone,thick=.004):
 o=panel(name,points,'Lamps',thick=thick);o['lamp_zone']=zone;lampobjs.append(o);return o
# Original face: slim rounded trapezoid projectors, twin interrupted DRLs, broad lower grille.
for s in [-1,1]:
 outline=rounded([(.40,.707),(.755,.738),(.790,.814),(.772,.849),(.460,.815)],.013,4)
 pts=[(s*x,-2.06,z) for x,z in outline]
 panel('Headlamp graphite housing',pts,'Trim',thick=.004)
 curve('Headlamp polished bezel',pts,.003,'Trim',cyclic=True)
 for x,y,z in [(.545,-2.074,.766),(.683,-2.009,.791)]:
  uvball('Projector optical reflector',(s*x,y,z),(.041,.009,.028),'Alloy',seg=24,rings=12)
  o=uvball('Projector lens',(s*x,y-.015,z),(.022,.004,.018),'Lamps',seg=24,rings=12);o['lamp_zone']=0;lampobjs.append(o)
 for a,b in [((.437,-2.078,.722),(.605,-2.038,.737)),((.625,-2.031,.742),(.754,-1.979,.769))]:
  o=curve('Warm white running light',[(s*a[0],a[1],a[2]),(s*b[0],b[1],b[2])],.008,'Lamps');o['lamp_zone']=0;lampobjs.append(o)
 panel('Headlamp lens cover',pts,'Glass',thick=0)
 # Lower side air intakes and fog optics
 panel('Fog recess',[(s*.618,-2.02,.313),(s*.705,-2.02,.332),(s*.760,-2.02,.466),(s*.698,-2.02,.507),(s*.655,-2.02,.471)],'Trim',thick=.005)
 cylinder('Fog chrome collar',(s*.675,-2.044,.402),.029,.013,'Alloy',axis='Y',vertices=32)
 o=uvball('Fog lens',(s*.675,-2.055,.402),(.021,.006,.021),'Lamps',seg=20,rings=10);o['lamp_zone']=0;lampobjs.append(o)
 # Tail lenses wrap into the rear quarters.
 p=[(s*.47,2.018,.807),(s*.744,1.976,.790),(s*.821,1.779,.831),(s*.815,1.751,.889),(s*.596,1.989,.878)]
 panel('Tail lamp smoked backing',p,'Trim',thick=.004)
 pp=list(p)
 lamp('Red tail lens',pp,1,.002)
 for zz in [.817,.857]:
  o=curve('Tail LED lightguide',[(s*.50,2.034,zz),(s*.724,1.995,zz-.01),(s*.816,1.8,zz+.011)],.008,'Lamps');o['lamp_zone']=1;lampobjs.append(o)
 lamp('Amber front indicator',[(s*.739,-1.959,.813),(s*.770,-1.923,.822),(s*.769,-1.922,.833),(s*.738,-1.957,.825)],2)
 lamp('Rear indicator',[(s*.52,2.029,.860),(s*.72,1.99,.853),(s*.72,1.993,.864),(s*.52,2.032,.871)],2)
 lamp('Rear bumper reflector',[(s*.50,2.026,.44),(s*.72,1.99,.44),(s*.72,1.995,.46),(s*.50,2.031,.46)],1)
# Front recesses are backed dark cavities with repeated physical grille slats.
panel('Upper grille cavity',[(-.393,-2.112,.715),(.393,-2.112,.715),(.435,-2.101,.814),(-.435,-2.101,.814)],'Trim',thick=.007)
curve('Upper grille surround',[(-.39,-2.121,.715),(.39,-2.121,.715),(.435,-2.11,.814),(-.435,-2.11,.814)],.008,'Alloy',cyclic=True)
for z in [.733,.759,.785]:curve('Upper grille louvre',[(-.378,-2.123,z),(0,-2.133,z),(.378,-2.123,z)],.006,'Trim')
for i in range(25):
 x=-.37+i*.74/24;curve('Upper grille mesh',[(x-.013,-2.119,.725),(x+.013,-2.119,.797)],.0028,'Alloy')
panel('Lower grille cavity',[(-.555,-2.086,.29),(.555,-2.086,.29),(.445,-2.11,.476),(-.445,-2.11,.476)],'Trim',thick=.01)
for z in [.316,.345,.376,.408,.44]:
 w=.55-(z-.29)*.58;curve('Lower intake aero slat',[(-w,-2.10,z),(0,-2.128,z), (w,-2.10,z)],.009,'Trim')
for x in [-.36,-.18,0,.18,.36]:curve('Lower grille support',[(x,-2.12,.303),(x,-2.12,.466)],.004,'Trim')
cube('Front blank registration mount',(0,-2.137,.559),(.39,.017,.105),'Trim',.01)
cube('Rear blank registration recess',(0,2.038,.712),(.45,.017,.125),'Trim',.018)
cube('Rear diffuser',(0,1.945,.284),(1.28,.09,.095),'Trim',.05)
for x in [-.52,-.26,0,.26,.52]:cube('Diffuser vane',(x,1.951,.264),(.012,.06,.030),'Trim',.004)
cylinder('Exhaust tip',(-.54,1.960,.279),.027,.055,'Alloy',axis='Y',vertices=40)
cylinder('Exhaust dark opening',(-.54,1.989,.279),.020,.002,'Trim',axis='Y',vertices=32)
o=curve('High mounted brake light',[(-.25,1.319,1.38),(.25,1.319,1.38)],.007,'Lamps');o['lamp_zone']=1;lampobjs.append(o)
# Cabin interior: bucket seats, rear bench, door cards, console and instrument binnacle.
cube('Cabin carpet',(0,.29,.355),(1.45,2.70,.055),'Interior',.04)
for s in [-1,1]:
 seat=cube('Front seat cushion',(s*.37,-.07,.542),(.47,.52,.14),'Interior',.07)
 back=cube('Front seat back',(s*.37,.16,.812),(.47,.15,.53),'Interior',.062);back.rotation_euler[0]=.13
 cube('Front headrest',(s*.37,.195,1.106),(.24,.11,.17),'Interior',.045)
 for xx in [-.105,.105]:cylinder('Headrest post',(s*.37+xx,.18,1.017),.009,.12,'Alloy',axis='Z',vertices=12)
 for xx in [-.185,.185]:
  uvball('Seat lateral bolster',(s*.37+xx,-.07,.598),(.046,.225,.077),'Interior',seg=20,rings=10)
  curve('Seat piping',[(s*.37+xx,.064,.655),(s*.37+xx,.097,.91),(s*.37+xx,.144,1.023)],.003,'Trim')
 for y in [-.22,-.1,.02]:curve('Cushion stitched channel',[(s*.37-.13,y,.615),(s*.37+.13,y,.615)],.0015,'Trim')
 for y in [-.3,.82]:
  cube('Door interior card',(s*.79,y,.714),(.085,.90,.31),'Interior',.045)
  cube('Door armrest',(s*.731,y,.711),(.12,.49,.065),'Trim',.025)
  cube('Interior door pull',(s*.725,y-.14,.829),(.018,.118,.022),'Alloy',.006)
cube('Rear seat bench',(0,1.08,.55),(1.32,.46,.15),'Interior',.07)
cube('Rear seat back',(0,1.36,.783),(1.31,.17,.47),'Interior',.075)
for x in [-.44,0,.44]:cube('Rear headrest',(x,1.38,1.036),(.245,.12,.16),'Interior',.045)
cube('Parcel shelf',(0,1.51,.916),(1.3,.32,.05),'Interior',.03)
cube('Dashboard',(0,-.68,.889),(1.51,.39,.16),'Interior',.05)
cube('Dashboard lower',(0,-.70,.734),(1.40,.24,.21),'Trim',.04)
for x in [-.61,-.14,.14,.61]:
 cube('Air vent',(x,-.478,.882),(.17,.012,.065),'Trim',.009)
 for z in [.86,.88,.9]:curve('Vent grille',[(x-.073,-.486,z),(x+.073,-.486,z)],.002,'Alloy')
cube('Instrument hood',(.36,-.515,.965),(.36,.16,.105),'Trim',.04)
cube('Instrument glass',(.36,-.425,.942),(.285,.006,.066),'Glass',.01)
cube('Centre display',(0,-.48,.999),(.242,.025,.139),'Trim',.012)
cube('Display dark glass',(0,-.461,1.001),(.213,.004,.111),'Glass',.006)
cube('Centre console',(0,-.14,.556),(.235,.70,.22),'Trim',.04)
curve('Gear stalk',[(0,-.29,.656),(0,-.32,.727)],.012,'Alloy');uvball('Gear knob',(0,-.32,.736),(.035,.036,.033),'Interior',seg=20,rings=12)
for y in [.03,.16]:torus_x('Cup holder rim',(0,y,.679),.035,.005,'Trim')
# Steering column points toward the driver's chest. Hub is the required separate node origin.
hub=Vector((.36,-.37,.884));axis=Vector((0,.86,.5)).normalized()
curve('Steering column',[(.36,-.62,.782),tuple(hub)],.029,'Trim')
bpy.ops.mesh.primitive_torus_add(major_radius=.145,minor_radius=.014,major_segments=64,minor_segments=10,location=hub);o=bpy.context.object;o.name='Steering rim';o.rotation_euler=Vector((0,0,1)).rotation_difference(axis).to_euler();finish(o,'Interior','SteeringWheel')
rot=Vector((0,0,1)).rotation_difference(axis)
for a in [pi/6,5*pi/6,3*pi/2]:
 p=hub+rot@Vector((.13*cos(a),.13*sin(a),0));curve('Steering spoke',[tuple(hub),tuple(p)],.013,'Alloy','SteeringWheel')
o=uvball('Steering airbag',hub,(.064,.033,.065),'Trim','SteeringWheel',seg=24,rings=12)
# Four tyre/rim/brake assemblies, with axial symmetry about Blender X.
for s,label in [(1,'L'),(-1,'R')]:
 for y,axle in [(-1.31,'F'),(1.31,'R')]:
  group='Wheel_'+axle+label;cx=s*.7825;cz=.315;wheelgroups[group]=(cx,y,cz)
  # Hand-shaped radial tyre section, shoulder round-over and longitudinal tread grooves.
  profile=[(-.105,.225),(-.108,.264),(-.095,.295),(-.077,.312),(-.066,.315),(-.046,.315),(-.043,.310),(-.038,.310),(-.035,.315),(-.014,.315),(-.011,.310),(-.006,.310),(-.003,.315),(.018,.315),(.021,.31),(.026,.31),(.029,.315),(.051,.315),(.054,.311),(.059,.311),(.062,.315),(.076,.312),(.096,.294),(.108,.262),(.105,.225)]
  v=[];f=[];n=96;k=len(profile)
  for i in range(n):
   a=2*pi*i/n
   for xx,r in profile:v.append((cx+xx,y+r*sin(a),cz+r*cos(a)))
  for i in range(n):
   for j in range(k-1):f.append((i*k+j,((i+1)%n)*k+j,((i+1)%n)*k+j+1,i*k+j+1))
  mesh('Moulded all-season tyre',v,f,'Rubber',group)
  for side in [-1,1]:
   torus_x('Sidewall bead',(cx+side*.103,y,cz),.235,.003,'Rubber',group,n=96,k=6)
   torus_x('Sidewall embossed ring',(cx+side*.103,y,cz),.283,.0015,'Rubber',group,n=96,k=5)
  # Small tread sipes cut visually as recessed dark channels across the shoulder.
  for i in range(64):
   a=2*pi*i/64
   for ss in [-1,1]:
    coords=[]
    for j in range(4):
     xx=ss*(.037+.043*j/3);aa=a+.055*j/3;r=.3138-(max(0,abs(xx)-.065))*.16
     coords.append((cx+xx,y+r*sin(aa),cz+r*cos(aa)))
    curve('Tread sipe',coords,.0011,'Trim',group)
  torus_x('Rim barrel',(cx,y,cz),.222,.012,'Alloy',group,n=80,k=8)
  # Dark cavity over barrel's outer cap then disc & open split spokes.
  outer=cx+s*.109
  torus_x('Inner barrel shadow',(outer-s*.07,y,cz),.211,.01,'Trim',group,n=64,k=6)
  cylinder('Ventilated brake disc',(outer-s*.047,y,cz),.185,.010,'BrakeSteel',group,vertices=64)
  torus_x('Machined rim lip',(outer,y,cz),.226,.009,'Alloy',group,n=96,k=8)
  torus_x('Inner bead',(outer+.001*s,y,cz),.209,.003,'Alloy',group,n=80,k=6)
  cube('Brake caliper',(outer-s*.012,y-.135,cz+.036),(.036,.072,.13),'Trim',.011,group)
  for i in range(10):
   a=2*pi*i/10
   # sculpted tapered split-spoke polygon extruded in X, avoids toy solid disc wheels
   for delta in [-.042,.042]:
    aa=a+delta
    points=[]
    for xx in [outer-s*.005,outer+s*.014]:
     for rad,an in [(.062,aa-.083),(.208,aa-.038),(.209,aa+.035),(.071,aa+.075)]:points.append((xx,y+rad*sin(an),cz+rad*cos(an)))
    o=mesh('Forged split spoke',points,[(0,1,2,3),(7,6,5,4),(0,4,5,1),(1,5,6,2),(2,6,7,3),(3,7,4,0)],'Alloy',group);bevel(o,.0025,2)
  cylinder('Wheel centre cap',(outer+s*.019,y,cz),.045,.025,'Alloy',group,vertices=48)
  for i in range(5):
   a=2*pi*i/5;cylinder('Lug socket',(outer+s*.033,y+.075*sin(a),cz+.075*cos(a)),.009,.005,'Trim',group,vertices=12)
   cylinder('Wheel bolt',(outer+s*.037,y+.075*sin(a),cz+.075*cos(a)),.0055,.006,'Alloy',group,vertices=6)
  for i in range(20):
   a=2*pi*i/20;cylinder('Rotor drill',(outer-s*.040,y+.162*sin(a),cz+.162*cos(a)),.003,.002,'Trim',group,vertices=8)
# Body-adhering side details use the real skin for their mounting depth.
side_names=['Door shut','Handle recess','Body colour pull','Handle chrome','Fuel flap','Rocker bright','Rolled wheel arch']
for o in parts['Body']:
 if any(o.name.startswith(n) for n in side_names):
  if o.type=='CURVE':
   for sp in o.data.splines:
    for p in sp.points:
     x,y,z,_=p.co;sg=1 if x>0 else -1
     ok,hit,no,idx=hull.ray_cast(Vector((sg*1.3,y,z)),Vector((-sg,0,0)))
     if ok:p.co.x=hit.x+sg*.003
  else:
   x,y,z=o.location;sg=1 if x>0 else -1
   ok,hit,no,idx=hull.ray_cast(Vector((sg*1.3,y,z)),Vector((-sg,0,0)))
   if ok:o.location.x=hit.x+sg*(.013 if 'pull' in o.name else .003)
# Bonnet seams follow the actual crowned top panel.
for o in parts['Body']:
 if o.name.startswith('Bonnet panel gap'):
  for sp in o.data.splines:
   for pt in sp.points:
    x,y,z,_=pt.co;x=math.copysign(.54+.13*max(0,min(1,(y+1.97)/1.1)),x)
    ok,hit,no,idx=hull.ray_cast(Vector((x,y,2)),Vector((0,0,-1)))
    if ok:pt.co=(x,y,hit.z+.002,1)
# Tessellate broad trim inlays before conforming to the curved body.
import bmesh
for o in parts['Body']:
 if o.type=='MESH' and any(o.name.startswith(n) for n in ['Headlamp graphite','Headlamp lens cover','Upper grille cavity','Lower grille cavity','Fog recess','Tail lamp smoked','Red tail lens','Rear indicator','Rear bumper reflector','Rear blank']):
  bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.triangulate(bm,faces=list(bm.faces));bmesh.ops.subdivide_edges(bm,edges=list(bm.edges),cuts=8,use_grid_fill=True);bm.to_mesh(o.data);bm.free()
# Dense trim paths follow the curved shell instead of cutting chords through it.
for o in parts['Body']:
 if o.type=='CURVE' and any(o.name.startswith(n) for n in ['Headlamp','Warm white','Upper grille','Lower grille','Lower intake','Tail LED']):
  for sp in o.data.splines:
   old=[Vector(pt.co[:3]) for pt in sp.points];dense=[]
   pairs=list(zip(old,old[1:]))+([(old[-1],old[0])] if sp.use_cyclic_u else [])
   for a,b in pairs:
    n=max(1,int((b-a).length/.015))
    dense += [a.lerp(b,i/n) for i in range(n)]
   if not sp.use_cyclic_u:dense.append(old[-1])
   sp.points.add(len(dense)-len(sp.points))
   for pt,co in zip(sp.points,dense):pt.co=(*co,1)
# Mount rear lamps and fascia onto the actual body shell.
rearnames=['Tail lamp','Red tail','Tail LED','Rear indicator','Rear bumper reflector','Rear blank','Tailgate']
for o in parts['Body']:
 if not any(o.name.startswith(n) for n in rearnames):continue
 def rearpoint(p,offset):
  direction=Vector((p.x,p.y,0)).normalized();start=Vector((direction.x*3,direction.y*3,p.z))
  ok,hit,no,idx=hull.ray_cast(start,-direction)
  return hit+direction*offset if ok else p
 offset=.012
 if o.name.startswith(('Red tail','Rear indicator','Rear bumper reflector')):offset=.020
 if o.name.startswith('Tail LED'):offset=.029
 if o.type=='MESH':
  for ve in o.data.vertices:ve.co=o.matrix_world.inverted()@rearpoint(o.matrix_world@ve.co,offset)
 elif o.type=='CURVE':
  for sp in o.data.splines:
   for pt in sp.points:pt.co=(*rearpoint(Vector(pt.co[:3]),offset),1)
# Project exterior front light/recess assemblies to the continuous bumper skin.
frontnames=['Headlamp','Projector','Warm white','Amber front','Fog ','Upper grille','Lower grille','Lower intake','Front blank']
for o in parts['Body']:
 if any(o.name.startswith(n) for n in frontnames):
  # Place at a curved face: original part depth is kept so bezels still catch light.
  def face_y(x,z):
   ok,hit,no,idx=hull.ray_cast(Vector((x,-3,z)),Vector((0,1,0)))
   return hit.y-.003 if ok else -2.096+.36*(abs(x)/.89)**3+.025*((z-.57)/.35)**2
  if o.type=='MESH':
   for v in o.data.vertices:
    p=o.matrix_world@v.co
    # Original positions define thickness about the authored front-facing plane.
    baseline=-2.10 if abs(p.x)<.43 else (-2.10+.16*(abs(p.x)-.43)/.40)
    p.y=face_y(p.x,p.z)+(p.y-baseline)*.08-(.015 if 'Projector' in o.name else .022 if 'lens cover' in o.name else .003)
    v.co=o.matrix_world.inverted()@p
  elif o.type=='CURVE':
   for sp in o.data.splines:
    for p in sp.points:
     x,y,z,_=p.co;p.co.y=face_y(x,z)-.010
# Convert, apply, generate valid UVs and normals, merge into requested six meshes.
bpy.ops.object.select_all(action='DESELECT')
for group,objs in list(parts.items()):
 for o in objs:
  bpy.context.view_layer.objects.active=o;o.select_set(True)
  zone=o.get('lamp_zone',None)
  if o.type=='CURVE':bpy.ops.object.convert(target='MESH');o=bpy.context.object
  for mod in list(o.modifiers):bpy.ops.object.modifier_apply(modifier=mod.name)
  bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
  me=o.data
  if not me.uv_layers:me.uv_layers.new(name='UVMap')
  uv=me.uv_layers.active.data
  for p in me.polygons:
   n=p.normal;axis=max(range(3),key=lambda i:abs(n[i]));axes=[i for i in range(3) if i!=axis]
   for li in p.loop_indices:
    co=me.vertices[me.loops[li].vertex_index].co
    uv[li].uv=((zone+.5)/3,.5) if zone is not None else (co[axes[0]]*2.5,co[axes[1]]*2.5)
  o.select_set(False)
 # conversions retain object identity but resolve by references
 valid=[o for o in objs if o.name in bpy.data.objects]
 for o in valid:o.select_set(True)
 bpy.context.view_layer.objects.active=valid[0];bpy.ops.object.join();o=bpy.context.object;o.name=group
 # Clean tiny degenerates before applying final triangulation.
 import bmesh
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bmesh.ops.dissolve_degenerate(bm,dist=1e-7,edges=list(bm.edges));bm.to_mesh(o.data);bm.free()
 dec=o.modifiers.new('Game mesh reduction','DECIMATE');dec.ratio=.40;dec.use_collapse_triangulate=True;bpy.ops.object.modifier_apply(modifier=dec.name)
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.triangulate(bm,faces=list(bm.faces));bad=[f for f in bm.faces if f.calc_area()<1e-10];bmesh.ops.delete(bm,geom=bad,context='FACES');bm.to_mesh(o.data);bm.free()
 pivot=wheelgroups.get(group,tuple(hub) if group=='SteeringWheel' else (0,0,0))
 sc.cursor.location=pivot;bpy.ops.object.origin_set(type='ORIGIN_CURSOR');o.select_set(False)
# Enforce game triangle ceiling if aperture tessellation changes with Blender versions.
total=sum(len(p.vertices)-2 for ob in sc.objects if ob.type=='MESH' for p in ob.data.polygons)
if total>98000:
 ob=bpy.data.objects['Body'];bodytris=sum(len(p.vertices)-2 for p in ob.data.polygons)
 bpy.context.view_layer.objects.active=ob
 mod=ob.modifiers.new('Delivery triangle ceiling','DECIMATE');mod.ratio=(98000-(total-bodytris))/bodytris;mod.use_collapse_triangulate=True;bpy.ops.object.modifier_apply(modifier=mod.name)
for name in wheelgroups:
 ob=bpy.data.objects[name]
 for ve in ob.data.vertices:ve.co.z=max(-.315,ve.co.z)
# Physical body width is 1.78m; mirrors are conventionally excluded from quoted body width.
# Normalize exact overall length/height to spec while retaining axle centres and tyres.
body=bpy.data.objects['Body']
# Scale just the overhangs, preserving axle spacing, cabin and pivots.
verts=body.data.vertices
ymin=min(v.co.y for v in verts);ymax=max(v.co.y for v in verts);zmax=max(v.co.z for v in verts)
for v in verts:
 if v.co.y< -1.70:v.co.y=-1.70+(v.co.y+1.70)*(.38/(-ymin-1.70))
 if v.co.y>1.70:v.co.y=1.70+(v.co.y-1.70)*(.32/(ymax-1.70))
 if v.co.z>1.35:v.co.z=1.35+(v.co.z-1.35)*(.12/(zmax-1.35))
markers={'Seat_Driver':(.36,-.04,.60),'Door_L':(1.02,-.25,.02),'Door_R':(-1.02,-.25,.02),'Exhaust':(-.54,1.985,.279),'Light_Head_L':(.63,-2.02,.79),'Light_Head_R':(-.63,-2.02,.79),'Light_Tail_L':(.66,1.985,.85),'Light_Tail_R':(-.66,1.985,.85)}
for name,loc in markers.items():
 o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.location=loc;o.empty_display_type='PLAIN_AXES';o.empty_display_size=.08
asset=[o for o in sc.objects if o.type in {'MESH','EMPTY'}]
for o in asset:o.select_set(True)
sc.cursor.location=(0,0,0)
# Store a source checkpoint before the presentation stage.
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(ROOT,'hatchback-source.blend'))
path=os.path.join(ROOT,'euro-hatchback.glb')
bpy.ops.export_scene.gltf(filepath=path,export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False)
# Set exact runtime paint factor, not baked colour. Patch is part of the reproducible build.
data=open(path,'rb').read();length,typ=struct.unpack_from('<II',data,12);doc=json.loads(data[20:20+length]);tail=data[20+length:]
for m in doc['materials']:
 if m['name']=='Paint':
  m['pbrMetallicRoughness']['baseColorFactor']=[.55,.03,.03,1]
  m.setdefault('extensions',{})['KHR_materials_clearcoat']={'clearcoatFactor':1.0,'clearcoatRoughnessFactor':.05}
 if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
extensions=doc.setdefault('extensionsUsed',[])
if 'KHR_materials_clearcoat' not in extensions:extensions.append('KHR_materials_clearcoat')
j=json.dumps(doc,separators=(',',':')).encode();j+=b' '*((-len(j))%4)
open(path,'wb').write(struct.pack('<III',0x46546c67,2,12+8+len(j)+len(tail))+struct.pack('<II',len(j),0x4e4f534a)+j+tail)
paints=[{'name':'Oxblood Red','linear_rgb':[.55,.03,.03]},{'name':'Graphite Metallic','linear_rgb':[.19,.21,.24]},{'name':'Atlantic Blue','linear_rgb':[.018,.08,.22]},{'name':'Pearl Silver','linear_rgb':[.63,.66,.69]},{'name':'Forest Green','linear_rgb':[.035,.14,.10]},{'name':'Warm Ivory','linear_rgb':[.80,.76,.64]}]
tri=sum(len(p.vertices)-2 for o in asset if o.type=='MESH' for p in o.data.polygons)
points=[o.matrix_world@Vector(v) for o in asset if o.type=='MESH' for v in o.bound_box]
def gl(v):return [round(v[0],6),round(v[2],6),round(-v[1],6)]
gpoints=[gl(p) for p in points];lo=[min(p[i] for p in gpoints) for i in range(3)];hi=[max(p[i] for p in gpoints) for i in range(3)]
meta={'contract':'Forge Garage v2','units':'metres','axes':{'front':'+Z','up':'+Y','left':'+X'},'nodes':[{'name':o.name,'type':o.type,'world_position':gl(o.matrix_world.translation)} for o in asset], 'bounding_box':{'min':lo,'max':hi,'size':[round(hi[i]-lo[i],6) for i in range(3)]},'body_dimensions_nominal':[1.78,1.47,4.10],'wheelbase':2.62,'triangles':tri,'paints':paints,'width_note':'1.78 m body; mirrors extend beyond body width.'}
open(os.path.join(ROOT,'nodes.json'),'w').write(json.dumps(meta,indent=2))
notes=["Original modern European five-door compact hatchback, metre scale, Garage v2 pivots and sockets.",f"Triangles: {tri:,}; six rigid mesh nodes; GLB {os.path.getsize(path)/1048576:.2f} MiB.","Materials: Paint, Rubber, Alloy, BrakeSteel, Trim, Interior, Glass (BLEND), Lamps (emissive).","Cycles-baked tiled base/normal: Paint 1024px, Rubber/Alloy/Trim/Interior 512px, BrakeSteel 256px, Glass/Lamps 128px; ORM 256px maximum.","Poly Haven CC0 IDs: metal_plate, rubber_tiles, fabric_leather_01; https://polyhaven.com/license (source maps from dl.polyhaven.org).","Differences: original grille/lamp styling, more angular cabin, shallow fascia recesses and simplified interior; cabin joins still need refinement against reference."]
open(os.path.join(ROOT,'NOTES.md'),'w').write('\n'.join(notes)+'\n')
print('FIRST_COMPLETE_GLB',tri,os.path.getsize(path),flush=True)
# Presentation renders use precisely the exported tile materials and factor colours.
nt=M['Paint'].node_tree;bs=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');bn=next(n for n in nt.nodes if n.type=='TEX_IMAGE' and n.image.name=='Paint_Base')
mix=nt.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;nt.links.new(bn.outputs[0],mix.inputs[1]);nt.links.new(mix.outputs[0],bs.inputs['Base Color'])
def paint(rgb):mix.inputs[2].default_value=(*rgb,1)
paint(paints[0]['linear_rgb'])
bpy.ops.object.select_all(action='DESELECT')
bpy.ops.mesh.primitive_plane_add(size=200);ground=bpy.context.object;ground.name='Studio Ground';ground.location.z=-.002
mg=bpy.data.materials.new('Studio warm grey');mg.diffuse_color=(.27,.29,.32,1);mg.use_nodes=True;mg.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.27,.29,.32,1);mg.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.8;ground.data.materials.append(mg)
def area(name,loc,power,size,color):
 bpy.ops.object.light_add(type='AREA',location=loc);o=bpy.context.object;o.name=name;o.data.energy=power;o.data.shape='RECTANGLE';o.data.size=size;o.data.size_y=size*.35;o.data.color=color;o.rotation_euler=(Vector((0,0,.6))-o.location).to_track_quat('-Z','Y').to_euler()
area('Large daylight key',(3.3,-4.5,6),850,5,(1,.91,.81));area('Long roof reflection',(-3,1.4,4.4),700,4,(.77,.86,1));area('Front fill',(-1,-5,2.7),220,3,(1,1,1))
bpy.ops.object.camera_add();camera=bpy.context.object;sc.camera=camera;camera.data.lens=52

def view(loc,target=(0,0,.74),ortho=None):
 camera.location=loc;camera.rotation_euler=(Vector(target)-camera.location).to_track_quat('-Z','Y').to_euler();camera.data.type='ORTHO' if ortho else 'PERSP'
 if ortho:camera.data.ortho_scale=ortho

def render(name,w,h,samples=24):
 sc.render.resolution_x=w;sc.render.resolution_y=h;sc.render.resolution_percentage=100;sc.cycles.samples=samples;sc.render.filepath=os.path.join(ROOT,name);bpy.ops.render.render(write_still=True)
view((5.0,-6.7,2.6));render('final.png',1920,1080,40)
view((6.5,0,1.00),(0,0,.74),5.05);render('side.png',1600,900,24)
view((5.0,-6.7,2.6));clay=bpy.data.materials.new('Clay');clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.42,.42,.42,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.65
sc.view_layers[0].material_override=clay;render('clay.png',1920,1080,24);sc.view_layers[0].material_override=None
# Compose actual same-angle renders to 3x2 PNG using Blender image pixels (no external images).
frames=[]
for i,p in enumerate(paints):
 paint(p['linear_rgb']);render('paint_%d.png'%i,640,360,12)
 im=bpy.data.images.load(os.path.join(ROOT,'paint_%d.png'%i),check_existing=False);a=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4);frames.append(a.copy());bpy.data.images.remove(im)
grid=np.zeros((720,1920,4),np.float32)
for i,a in enumerate(frames):
 row=1-i//3;col=i%3;grid[row*360:(row+1)*360,col*640:(col+1)*640]=a
im=bpy.data.images.new('Six paint variants',1920,720,alpha=True);im.pixels.foreach_set(grid.ravel());im.filepath_raw=os.path.join(ROOT,'paints.png');im.file_format='PNG';im.save()
paint(paints[0]['linear_rgb'])
print('BUILD_COMPLETE',flush=True)
