"""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('V2_MODERN_HATCH_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':'Mozilla/5.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=(.32,.003,.006,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=.42;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,.38,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,.705,.29),(-2.055,.64,.755,.27),(-1.98,.77,.795,.245),(-1.80,.848,.866,.225),(-1.53,.882,.947,.218),(-1.31,.89,.966,.218),(-1.02,.876,1.026,.224),(-.65,.879,1.042,.224),(.15,.880,1.057,.224),(.7,.883,1.068,.224),(1.10,.89,1.075,.224),(1.40,.89,1.075,.227),(1.73,.858,1.09,.242),(1.95,.79,1.025,.275),(2.02,.652,.87,.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),(.986,.94),(1,.84),(.988,.70),(.972,.31),(.950,.095),(.84,.02),(.50,0),(0,0),(-.50,0),(-.84,.02),(-.950,.095),(-.972,.31),(-.988,.70),(-1,.84),(-.986,.94),(-.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:
  nose_round=.085*max(0,1-(y+2.08)/.35)*max(0,(.65-zz)/.65)**2 if y< -1.73 else 0
  v.append((w*xx,y+nose_round,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)
# Stamped door hollow: a rising character crease below the shoulder, fading at the arches.
for ve in hull.data.vertices:
 x,y,z=ve.co
 if abs(x)>.67 and -.98<y<1.08:
  fade=sin(pi*(y+.98)/2.06)**.75
  depth=.021*fade*math.exp(-((z-(.49+.085*(y+.98)))/.115)**2)
  ve.co.x-=math.copysign(depth,x)
hull.data.update()
# 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=.355,depth=2.3,location=(0,wy,.326),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)
hull.data.materials.append(M['Trim'])
for face in hull.data.polygons:
 c=face.center
 if any(abs(math.hypot(c.y-wy,c.z-.326)-.355)<.0025 for wy in [-1.31,1.31]) and abs(face.normal.x)<.20:face.material_index=1
bpy.ops.mesh.primitive_cube_add(size=1,location=(0,.35,1.11));cut=bpy.context.object;cut.scale=(1.49,2.74,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-.357*cos(a);z=.326+.357*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-.35*cos(a),.326+.35*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')
  vv=[];ff=[]
  for i in range(81):
   a=-.3+(pi+.6)*i/80
   for r in [.29,.405]:vv.append((s*.655,wy-r*cos(a),.326+r*sin(a)))
  for i in range(80):ff.append((2*i,2*i+1,2*i+3,2*i+2))
  mesh('Inner wheelhouse back wall',vv,ff,'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+(1.069-.276)*i/39
   if line==0:y=-1.0-.055*sin(pi*i/39)
   elif line==1:y=.30+.035*(1-i/39)
   else:y=1.02+.35*(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.07]:
  uvball('Handle recess',(s*(sx(y,.981)+.003),y,.981),(.007,.112,.027),'Trim')
  uvball('Body colour pull handle',(s*(sx(y,.992)+.017),y,.992),(.019,.096,.016),'Paint')
  curve('Handle chrome underline',[(s*(sx(y,.984)+.032),y-.067,.984),(s*(sx(y,.984)+.034),y+.067,.984)],.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.10,-1.045]],.002,'Trim')
# V2 glazing: continuously arched upper door outline and thick swept C pillar.
def roofedge(y):
 return float(np.interp(y,[-.42,-.28,-.05,.25,.65,.95,1.20,1.43,1.51],[1.398,1.43,1.447,1.447,1.437,1.424,1.402,1.365,1.345]))
def cabx(y,z):
 x=.836-.166*max(0,min(1,(z-1.06)/.39))+.007*sin((y+1)*1.6)
 return x-.077*max(0,min(1,(y-1.40)/.49))**1.5
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,.004,3)
 return o
# Build the curved side sheet as a constrained polygon with boolean window apertures.
windows=[ [(-.968,1.074),(-.66,1.281),(-.40,1.382),(-.13,1.412),(.25,1.409),(.268,1.084)],[(.353,1.086),(.340,1.410),(.84,1.386),(1.069,1.331),(1.224,1.222),(1.281,1.099)] ]
outer=[(-1.033,1.041),(-.77,1.257),(-.54,1.376),(-.42,roofedge(-.42))]
outer += [(y,roofedge(y)) for y in [-.42+1.93*i/64 for i in range(1,65)]]
outer += [(1.82,1.087),(1.89,1.037)]
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 sgn in [-1,1]:
 surround=prism_yz('Integrated cabin side surround',outer,.007)
 for k,poly in enumerate(windows):
  rp=rounded(poly,.07,10)
  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)
  cy=sum(y for y,z in rp)/len(rp);cz=sum(z for y,z in rp)/len(rp)
  vv=[cp(sgn,cy,cz,.008)]+[cp(sgn,y,z,.008) for y,z in rp]
  ff=[(0,1+i,1+(i+1)%len(rp)) for i in range(len(rp))]
  mesh('Curved side glass '+str(k),vv,ff,'Glass')
  curve('Window bonded rubber',[cp(sgn,y,z,.011) for y,z in rp],.004,'Trim',cyclic=True)
  curve('Polished window surround',[cp(sgn,y,z,.015) for y,z in rp],.0023,'Alloy',cyclic=True)
 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=2,use_grid_fill=True);bm.to_mesh(surround.data);bm.free()
 for ve in surround.data.vertices:
  depth,y,z=ve.co;ve.co=cp(sgn,y,z,depth)
 bevel(surround,.002,2)
 panel('Gloss B pillar',[cp(sgn,y,z,.013) for y,z in [(.267,1.064),(.250,1.423),(.340,1.424),(.353,1.067)]],'Trim',thick=.002)
 # Rear quarter fixed pane division belongs to the rear door, not an extra door.
 curve('Rear glazing division',[cp(sgn,1.08,1.10,.014),cp(sgn,1.055,1.327,.014)],.006,'Trim')
 # Blend the raised belt into the rounded main shell.
 vv=[];ff=[]
 for i in range(101):
  y=-1.026+2.91*i/100;zt=1.052+.017*(y+1.026)/2.91;zl=.974+.038*max(0,min(1,(y+.95)/.3))
  ok,hit,no,idx=hull.ray_cast(Vector((sgn*1.3,y,zl)),Vector((-sgn,0,0)))
  xl=hit.x if ok else sgn*(width(y)-.02)
  for j in range(5):
   t=j/4;vv.append((xl*(1-t)+cp(sgn,y,zt)[0]*t,y,zl+(zt-zl)*t))
 for i in range(100):
  for j in range(4):a=i*5+j;ff.append((a,a+5,a+6,a+1))
 o=mesh('Blended door belt pressing',vv,ff);mod=o.modifiers.new('Sheet gauge','SOLIDIFY');mod.thickness=.006
 curve('Belt weatherstrip',[cp(sgn,y,1.063+.014*(y+1)/2.3,.008) for y in [-1,-.6,-.2,.2,.6,1,1.3]],.0045,'Trim')
 curve('Polished belt',[cp(sgn,y,1.069+.014*(y+1)/2.3,.010) for y in [-1,-.6,-.2,.2,.6,1,1.3]],.0028,'Alloy')
 curve('Mirror stalk',[cp(sgn,-.84,1.085),(sgn*.927,-.84,1.078)],.018,'Trim')
 uvball('Mirror shell',(sgn*.932,-.844,1.119),(.081,.114,.050),'Paint')
 uvball('Mirror black lower',(sgn*.933,-.842,1.095),(.081,.107,.029),'Trim')
 uvball('Mirror reflective insert',(sgn*.934,-.754,1.119),(.060,.009,.035),'Alloy')
def glass_grid(name,rows,n=40):
 v=[];f=[]
 for y,z,w in rows:
  for j in range(n+1):
   u=-1+2*j/n;v.append((w*u,y-.022*(1-u*u) if y<0 else y+.018*(1-u*u),z+.012*(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,.009,'Trim',cyclic=True);return o
rows=[]
for i in range(25):
 t=i/24;y=-1.025+.605*t;z=float(np.interp(y,[-1.025,-.77,-.54,-.42],[1.050,1.250,1.372,1.398]))-.004*sin(pi*t);rows.append((y,z,cabx(y,z)-.017))
glass_grid('Laminated windscreen',rows)
rows=[]
for i in range(23):
 t=i/22;y=1.51+.366*t;z=1.345-.279*t;rows.append((y,z,cabx(y,z)-.019))
glass_grid('Heated hatch glass',rows)
# The hatch glass now terminates at the rear body skin: no horizontal boot shelf.
vv=[];ff=[]
for j in range(41):
 u=-1+2*j/40;x=(cabx(1.876,1.066)-.019)*u;y=1.876+.018*(1-u*u);z=1.066+.012*(1-u*u)
 ok,hit,no,idx=hull.ray_cast(Vector((x,y,2)),Vector((0,0,-1)))
 if not ok:hit=Vector((x,y,1.00))
 for k in range(5):
  t=k/4;vv.append((x,y,z*(1-t)+(hit.z+.003)*t))
for j in range(40):
 for k in range(4):a=j*5+k;ff.append((a,a+5,a+6,a+1))
o=mesh('Hatch lower glass flange',vv,ff);mod=o.modifiers.new('Hatch flange gauge','SOLIDIFY');mod.thickness=.006
vv=[];ff=[]
for i in range(49):
 t=i/48;y=-.42+1.93*t
 for j in range(41):
  u=-1+2*j/40;z=roofedge(y)+(.016+.009*sin(pi*t))*(1-u*u);yy=y+(-.022*(1-t)+.018*t)*(1-u*u);vv.append((cabx(y,roofedge(y))*u,yy,z))
for i in range(48):
 for j in range(40):a=i*41+j;ff.append((a,a+1,a+42,a+41))
o=mesh('Roof outer panel',vv,ff);mod=o.modifiers.new('Roof gauge','SOLIDIFY');mod.thickness=.008
vv=[];ff=[]
for i in range(29):
 y=-.22+1.47*i/28;t=(y+.42)/1.93
 for j in range(29):
  x=-.54+1.08*j/28;u=x/cabx(y,roofedge(y));z=roofedge(y)+(.016+.009*sin(pi*t))*(1-u*u)+.0025;yy=y+(-.022*(1-t)+.018*t)*(1-u*u);vv.append((x,yy,z))
for i in range(28):
 for j in range(28):a=i*29+j;ff.append((a,a+1,a+30,a+29))
mesh('Panoramic roof dark laminate',[(x,y,z-.0012) for x,y,z in vv],ff,'Trim');mesh('Panoramic roof glass',vv,ff,'Glass')
# Curved rear lip spoiler wraps the upper tailgate.
vv=[];ff=[]
for i in range(41):
 u=-1+2*i/40
 for yy,zz in [(1.43,1.371),(1.59,1.365),(1.60,1.342),(1.46,1.347)]:vv.append((u*.704,yy+.025*(1-u*u),zz+.018*(1-u*u)))
for i in range(40):
 for j in range(4):ff.append((i*4+j,(i+1)*4+j,(i+1)*4+(j+1)%4,i*4+(j+1)%4))
ff.extend([(3,2,1,0),(160,161,162,163)])
bevel(mesh('Roof spoiler',vv,ff),.006,3)
for sgn in [-1,1]:
 curve('Tailgate seam',[(sgn*.66,1.55,1.31),(sgn*.77,1.88,1.06),(sgn*.71,1.99,.63),(sgn*.56,2.006,.578)],.002,'Trim')
curve('Tailgate lower seam',[(-.56,2.006,.578),(0,2.01,.57),(.56,2.006,.578)],.002,'Trim')
for sgn in [-1,1]:
 curve('Wiper arm',[(sgn*.14,-1.03,1.069),(sgn*.36,-.93,1.127)],.004,'Trim');curve('Wiper blade',[(sgn*.14,-.947,1.117),(sgn*.59,-.892,1.147)],.0045,'Trim')
curve('Rear wiper',[(.10,1.851,1.107),(-.32,1.825,1.13)],.005,'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([(.415,.715),(.675,.731),(.833,.839),(.812,.881),(.491,.821)],.016,5)
 pts=[(s*x,-2.06,z) for x,z in outline]
 panel('Headlamp graphite housing',pts,'Trim',thick=.004)
 curve('Headlamp dark bezel',pts,.003,'Trim',cyclic=True)
 for j in range(3):
  x=.525+.09*j;z=.774+.018*j
  pp=[(s*(x-.027),-2.081,z-.014),(s*(x+.026),-2.081,z-.009),(s*(x+.025),-2.081,z+.012),(s*(x-.027),-2.081,z+.016)]
  panel('Projector optical reflector',pp,'Alloy',thick=.003)
  lamp('Projector warm LED',[(s*(x-.016),-2.092,z-.007),(s*(x+.017),-2.092,z-.005),(s*(x+.017),-2.092,z+.007),(s*(x-.016),-2.092,z+.009)],0,.002)
 o=curve('Warm white running light',[(s*.439,-2.084,.731),(s*.652,-2.06,.748),(s*.798,-1.99,.832)],.005,'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*x,2.02,z) for x,z in rounded([(.47,.807),(.744,.790),(.813,.831),(.807,.889),(.596,.878)],.015,7)]
 panel('Tail lamp smoked backing',p,'Trim',thick=.003)
 panel('Tail lamp outer lens',p,'Glass')
 pp=list(p)
 # Smoked housing with inset red light guides; no protruding solid red slab.
 o=curve('Tail LED perimeter',p,.0055,'Lamps',cyclic=True);o['lamp_zone']=1;lampobjs.append(o)
 for zz in [.841]:
  o=curve('Tail LED lightguide',[(s*.50,2.034,zz),(s*.724,1.995,zz-.01),(s*.816,1.8,zz+.011)],.004,'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*.61,2.029,.858),(s*.75,1.99,.856),(s*.75,1.993,.864),(s*.61,2.032,.866)],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',[(-.375,-2.112,.688),(.375,-2.112,.688),(.431,-2.101,.745),(.417,-2.101,.807),(-.417,-2.101,.807),(-.431,-2.101,.745)],'Trim',thick=.007)
curve('Upper grille surround',[(-.375,-2.121,.688),(.375,-2.121,.688),(.431,-2.11,.745),(.417,-2.11,.807),(-.417,-2.11,.807),(-.431,-2.11,.745)],.003,'Alloy',cyclic=True)
for z in [.707,.736,.765,.788]:curve('Upper grille louvre',[(-.378,-2.123,z),(0,-2.133,z),(.378,-2.123,z)],.006,'Trim')
for i in range(23):
 x=-.36+i*.72/22;curve('Upper grille mesh',[(x-.016,-2.119,.698),(x+.016,-2.119,.791)],.0022,'Trim')
panel('Lower grille cavity',[(-.64,-2.086,.29),(.64,-2.086,.29),(.53,-2.11,.476),(-.53,-2.11,.476)],'Trim',thick=.01)
for z in [.316,.345,.376,.408,.44]:
 w=.635-(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)
panel('Rear blank registration recess',[(x,2.038,z) for x,z in rounded([(-.225,.650),(.225,.650),(.225,.774),(-.225,.774)],.012,5)],'Trim',thick=.004)
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.593,1.35),(.25,1.593,1.35)],.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*.765;cz=.326;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:
    r*=.326/.315;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=.325-(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','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=3 if o.name.startswith('Tail lamp') else 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','Tailgate']):
  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):
  if o.name.startswith(('Tail lamp','Red tail','Tail LED','Rear indicator')):p.z+=.105
  direction=Vector((0,1,0));start=Vector((p.x,3,p.z))
  ok,hit,no,idx=hull.ray_cast(start,-direction)
  return hit+direction*offset if ok else p
 offset=.003
 if o.name.startswith(('Red tail','Rear indicator','Rear bumper reflector')):offset=.006
 if o.name.startswith('Tail LED'):offset=.009
 if o.name.startswith('Tail lamp outer'):offset=.006
 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.
  if o.name.startswith('Upper grille'):
   if o.type=='MESH':
    for ve in o.data.vertices:ve.co.z=.645+(ve.co.z-.688)*.72
   elif o.type=='CURVE':
    for sp in o.data.splines:
     for pt in sp.points:pt.co.z=.645+(pt.co.z-.688)*.72
  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
# Real lower bumper aperture. Keep the established curved mounting surface for the inset liner.
lowerpoly=rounded([(-.64,.29),(.64,.29),(.53,.476),(-.53,.476)],.015,6)
edge=[]
for x,z in lowerpoly:
 ok,hit,no,idx=hull.ray_cast(Vector((x,-3,z)),Vector((0,1,0)))
 edge.append(Vector((x,hit.y if ok else -2.04,z)))
# The painted return and black tunnel provide actual depth at grazing angles.
vv=[];ff=[]
for pt in edge:vv.extend([tuple(pt+Vector((0,-.002,0))),tuple(pt+Vector((0,.042,0)))])
for i in range(len(edge)):
 j=(i+1)%len(edge);ff.append((2*i,2*j,2*j+1,2*i+1))
mesh('Lower intake inner return',vv,ff,'Trim')
curve('Lower intake painted rolled rim',[tuple(pt+Vector((0,-.003,0))) for pt in edge],.004,'Paint',cyclic=True)
n=len(lowerpoly);vv=[(x,y,z) for y in [-2.5,-1.72] for x,z in lowerpoly]
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)]
cut=mesh('Intake cutting tool',vv,ff,'Trim')
bm=bmesh.new();bm.from_mesh(cut.data);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(cut.data);bm.free()
bpy.context.view_layer.objects.active=hull;mod=hull.modifiers.new('Open lower air intake','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cut;bpy.ops.object.modifier_apply(modifier=mod.name)
parts['Body'].remove(cut);bpy.data.objects.remove(cut,do_unlink=True)
for o in parts['Body']:
 if o.name.startswith(('Lower grille','Lower intake aero')):
  depth=.038 if o.name.startswith('Lower grille cavity') else .012
  if o.type=='MESH':
   for ve in o.data.vertices:ve.co.y+=depth
  elif o.type=='CURVE':
   for sp in o.data.splines:
    for pt in sp.points:pt.co.y+=depth
# 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(-.326,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)*(.45/(-ymin-1.70))
 if v.co.y>1.70:v.co.y=1.70+(v.co.y-1.70)*(.25/(ymax-1.70))
 if v.co.z>1.35:v.co.z=1.35+(v.co.z-1.35)*(.12/(zmax-1.35))
# Final reduction can create microscopic slivers: remove them after all dimensional edits.
for ob in sc.objects:
 if ob.type!='MESH':continue
 bm=bmesh.new();bm.from_mesh(ob.data);bmesh.ops.triangulate(bm,faces=list(bm.faces))
 bad=[f for f in bm.faces if f.calc_area()<1e-10]
 if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
 bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(ob.data);bm.free()
markers={'Seat_Driver':(.36,-.04,.60),'Door_L':(1.02,-.25,.02),'Door_R':(-1.02,-.25,.02),'Exhaust':(-.54,1.93,.279),'Light_Head_L':(.63,-2.08,.79),'Light_Head_R':(-.63,-2.08,.79),'Light_Tail_L':(.66,1.93,1.0),'Light_Tail_R':(-.66,1.93,1.0)}
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']:
 ormtex=m.get('pbrMetallicRoughness',{}).get('metallicRoughnessTexture')
 if ormtex:m['occlusionTexture']={'index':ormtex['index'],'strength':1.0}
 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 and LED signature; roof/beltline slightly higher than sheet; simplified interior and shallow intakes; width excludes mirrors."]
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,.74),(0,0,.74),5.05);render('side.png',1600,900,24)
ground.hide_render=True;sc.render.film_transparent=True;render('side_alpha.png',1600,900,16);sc.render.film_transparent=False;ground.hide_render=False
view((-4.6,6.4,2.7));render('rear.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)
