"""Original modern 3-door rally hatchback (Rally2/WRC class). Blender 5.2, clean scene, CC0 scan-derived Cycles bakes.
Run: blender -b --python-exit-code 1 --python build_rally-car.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
import bmesh

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 = (.68, .73, .80, 1)
sc.world.node_tree.nodes['Background'].inputs[1].default_value = .48
sc.view_settings.view_transform = 'AgX'
print('RALLY_CAR_V3_POLISHED_BUILD', flush=True)

M = {}
parts = {}
wheelgroups = {}
lampobjs = []
PH = ['metal_plate', 'rubber_tiles', 'fabric_leather_01', 'brown_mud_dry']

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

# Bake 8 realistic materials with Poly Haven scans + procedural rally dust/mud/weave
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 = 140 if name != 'CarbonFibre' else 280
    noise.inputs['Roughness'].default_value = .72
    nt.links.new(tex.outputs['UV'], noise.inputs['Vector'])

    ramp = nt.nodes.new('ShaderNodeValToRGB')
    ramp.color_ramp.elements[0].color = tuple(v * .92 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])

    # Paint UV layout: V in [0.46, 1.0] is clean near-white paint with subtle micro-dust;
    # V in [0.0, 0.46] is lower body with gravel mud & warm dust splashes baked into the neutral base texture
    colorout = ramp.outputs[0]
    roughout = None

    if name == 'Paint':
        mud_diff = image_node(nt, download('brown_mud_dry', 'diff'))
        mud_arm = image_node(nt, download('brown_mud_dry', 'arm'))
        mud_arm.image.colorspace_settings.name = 'Non-Color'
        sep_uv = nt.nodes.new('ShaderNodeSeparateXYZ')
        nt.links.new(tex.outputs['UV'], sep_uv.inputs[0])

        grad_math = nt.nodes.new('ShaderNodeMath')
        grad_math.operation = 'SUBTRACT'
        grad_math.inputs[0].default_value = 0.50
        nt.links.new(sep_uv.outputs['Y'], grad_math.inputs[1])

        grad_mul = nt.nodes.new('ShaderNodeMath')
        grad_mul.operation = 'MULTIPLY'
        grad_mul.inputs[1].default_value = 2.6
        nt.links.new(grad_math.outputs[0], grad_mul.inputs[0])

        splash_noise = nt.nodes.new('ShaderNodeTexNoise')
        splash_noise.inputs['Scale'].default_value = 22.0
        splash_noise.inputs['Detail'].default_value = 12.0
        splash_noise.inputs['Roughness'].default_value = 0.85
        nt.links.new(tex.outputs['UV'], splash_noise.inputs['Vector'])

        mud_bw = nt.nodes.new('ShaderNodeRGBToBW')
        nt.links.new(mud_diff.outputs['Color'], mud_bw.inputs[0])

        mask_add = nt.nodes.new('ShaderNodeMath')
        mask_add.operation = 'MULTIPLY_ADD'
        mask_add.inputs[1].default_value = 0.82
        nt.links.new(splash_noise.outputs['Fac'], mask_add.inputs[0])
        nt.links.new(grad_mul.outputs[0], mask_add.inputs[2])

        mask_ramp = nt.nodes.new('ShaderNodeValToRGB')
        mask_ramp.color_ramp.elements[0].position = 0.34
        mask_ramp.color_ramp.elements[0].color = (0, 0, 0, 1)
        mask_ramp.color_ramp.elements[1].position = 0.72
        mask_ramp.color_ramp.elements[1].color = (1, 1, 1, 1)
        nt.links.new(mask_add.outputs[0], mask_ramp.inputs[0])

        # Richer gravel mud/dust neutral-earth tone on lower panels
        mud_col = nt.nodes.new('ShaderNodeValToRGB')
        mud_col.color_ramp.elements[0].color = (0.38, 0.31, 0.24, 1)
        mud_col.color_ramp.elements[1].color = (0.72, 0.65, 0.56, 1)
        nt.links.new(mud_bw.outputs[0], mud_col.inputs[0])

        mix_paint = nt.nodes.new('ShaderNodeMixRGB')
        mix_paint.blend_type = 'MIX'
        nt.links.new(mask_ramp.outputs['Color'], mix_paint.inputs['Fac'])
        nt.links.new(ramp.outputs[0], mix_paint.inputs[1])
        nt.links.new(mud_col.outputs[0], mix_paint.inputs[2])
        colorout = mix_paint.outputs[0]

        rough_mix = nt.nodes.new('ShaderNodeMixRGB')
        nt.links.new(mask_ramp.outputs['Color'], rough_mix.inputs['Fac'])
        rough_mix.inputs[1].default_value = (rough, rough, rough, 1)
        rough_mix.inputs[2].default_value = (0.82, 0.82, 0.82, 1)
        rough_bw = nt.nodes.new('ShaderNodeRGBToBW')
        nt.links.new(rough_mix.outputs[0], rough_bw.inputs[0])
        roughout = rough_bw.outputs[0]

    elif name == 'CarbonFibre':
        wave = nt.nodes.new('ShaderNodeTexWave')
        wave.wave_type = 'BANDS'
        wave.bands_direction = 'DIAGONAL'
        wave.inputs['Scale'].default_value = 95.0
        wave.inputs['Distortion'].default_value = 0.5
        nt.links.new(tex.outputs['UV'], wave.inputs['Vector'])
        cf_ramp = nt.nodes.new('ShaderNodeValToRGB')
        cf_ramp.color_ramp.elements[0].color = (0.016, 0.018, 0.021, 1)
        cf_ramp.color_ramp.elements[1].color = (0.052, 0.056, 0.064, 1)
        nt.links.new(wave.outputs['Fac'], cf_ramp.inputs[0])
        colorout = cf_ramp.outputs[0]

    elif name == 'Rubber':
        mud_diff = image_node(nt, download('brown_mud_dry', 'diff'))
        mud_bw = nt.nodes.new('ShaderNodeRGBToBW')
        nt.links.new(mud_diff.outputs['Color'], mud_bw.inputs[0])
        rub_ramp = nt.nodes.new('ShaderNodeValToRGB')
        rub_ramp.color_ramp.elements[0].color = (0.022, 0.024, 0.026, 1)
        rub_ramp.color_ramp.elements[1].color = (0.14, 0.115, 0.092, 1)
        nt.links.new(mud_bw.outputs[0], rub_ramp.inputs[0])
        mix_rub = nt.nodes.new('ShaderNodeMixRGB')
        mix_rub.inputs['Fac'].default_value = 0.32
        nt.links.new(ramp.outputs[0], mix_rub.inputs[1])
        nt.links.new(rub_ramp.outputs[0], mix_rub.inputs[2])
        colorout = mix_rub.outputs[0]

    nt.links.new(colorout, 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 = .07 if name == 'Paint' else .22
    bump.inputs['Distance'].default_value = .00015 if name == 'Paint' else .0009
    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 = .03 if name == 'Paint' else .20
        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.0, .94, .82, 1)  # zone 0: warm white LED / rally pod
        cr.color_ramp.elements[1].position = .333
        cr.color_ramp.elements[1].color = (.48, .005, .008, 1) # zone 1: red tail/brake
        e = cr.color_ramp.elements.new(.667)
        e.color = (1.0, .28, .01, 1)                           # zone 2: amber indicator
        nt.links.new(sep.outputs['X'], cr.inputs[0])
        colorout = cr.outputs[0]

    base = bake('Base', size, 'EMIT', colorout)
    norm = bake('Normal', size, 'NORMAL')
    comb = nt.nodes.new('ShaderNodeCombineXYZ')
    comb.inputs[0].default_value = 1.0
    if roughout is not None:
        nt.links.new(roughout, comb.inputs[1])
    elif 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 = .10
        mul.inputs[2].default_value = max(.03, rough - .05)
        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.0
        bs.inputs['Coat Roughness'].default_value = .05
    if name == 'Glass':
        bs.inputs['Alpha'].default_value = .36
        bs.inputs['Roughness'].default_value = .06
        m.surface_render_method = 'BLENDED'
        m.diffuse_color = (.12, .17, .20, .35)
    if name == 'Lamps':
        nt.links.new(bn.outputs[0], bs.inputs['Emission Color'])
        bs.inputs['Emission Strength'].default_value = .95
    M[name] = m
    return m

# Eight materials max: Paint, Rubber, WhiteRim, Metal, CarbonFibre, Interior, Glass, Lamps
baked_material('Paint', 'metal_plate', (.98, .98, .97), .30, .10, 1024)
baked_material('Rubber', 'rubber_tiles', (.030, .032, .035), .74, 0.0, 512)
baked_material('WhiteRim', 'metal_plate', (.92, .91, .89), .22, .12, 512)
baked_material('Metal', 'metal_plate', (.54, .56, .59), .28, .92, 512)
baked_material('CarbonFibre', 'rubber_tiles', (.028, .031, .036), .28, .18, 512)
baked_material('Interior', 'fabric_leather_01', (.042, .045, .050), .80, 0.0, 512)
baked_material('Glass', None, (.07, .10, .12), .06, .05, 128)
baked_material('Lamps', None, (1, 1, 1), .16, .15, 128)
sc.cycles.samples = 24

# Geometry utilities
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', smooth=True):
    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)
    return finish(o, mat, group, smooth)

def bevel(o, w=.008, n=2):
    mod = o.modifiers.new('EdgeBevel', 'BEVEL')
    mod.width = w
    mod.segments = n
    return o

def cube(name, loc, scale, mat='Paint', radius=.012, 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, 2)
    return o

def uvball(name, loc, scale, mat='Paint', group='Body', seg=28, rings=14):
    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='CarbonFibre', group='Body', cyclic=False, res=14):
    cu = bpy.data.curves.new(name, 'CURVE')
    cu.dimensions = '3D'
    cu.resolution_u = res
    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='Metal', group='Body', axis='X', vertices=48):
    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_axis(name, loc, major, minor, mat='Rubber', group='Body', axis='X', n=72, k=12):
    v = []
    f = []
    for i in range(n):
        a = 2 * pi * i / n
        for j in range(k):
            b = 2 * pi * j / k
            if axis == 'X':
                v.append((loc[0] + minor * sin(b), loc[1] + (major + minor * cos(b)) * sin(a), loc[2] + (major + minor * cos(b)) * cos(a)))
            elif axis == 'Y':
                v.append((loc[0] + (major + minor * cos(b)) * cos(a), loc[1] + minor * sin(b), loc[2] + (major + minor * cos(b)) * sin(a)))
            else: # Z
                v.append((loc[0] + (major + minor * cos(b)) * cos(a), loc[1] + (major + minor * cos(b)) * sin(a), loc[2] + minor * sin(b)))
    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 torus_x(name, loc, major, minor, mat='Rubber', group='Body', n=72, k=12):
    return torus_axis(name, loc, major, minor, mat, group, 'X', n, k)

def rounded(poly, r=.04, 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]

# Target dimensions: 4.10m long (-2.05 to +2.05), 1.88m wide (-0.94 to +0.94), 1.42m high (0 to 1.42), wheelbase 2.63m (-1.315 to +1.315)
WY_F = -1.315
WY_R = 1.315
WRAD = 0.335

def top(y):
    return interp(y, [(-2.05, .74), (-1.92, .845), (-1.60, .915), (-.98, .985), (-.50, 1.00), (.90, 1.005), (1.55, .97), (1.88, .91), (2.05, .80)])

def width(y):
    return interp(y, [
        (-2.05, .76), (-1.92, .86), (-1.68, .930), (-1.315, .940), (-0.95, .918),
        (-0.55, .875), (0.45, .878), (0.92, .922), (1.315, .940), (1.68, .918),
        (1.92, .850), (2.05, .76)
    ])

def bottom(y):
    z = .215
    for wy in [WY_F, WY_R]:
        dy = abs(y - wy)
        if dy < .395:
            z = max(z, .325 + sqrt(.395**2 - dy**2))
    return z

def sx(y, z):
    t = max(0, min(1, (z - .215) / (top(y) - .215)))
    return width(y) + interp(t, [(0, -.030), (.14, -.008), (.42, 0.0), (.68, -.004), (.85, -.014), (1, -.035)])

# Continuous sculpted monocoque hull with box-flared rally fenders
sections = [
    (-2.05, .58, .700, .235),
    (-2.02, .70, .750, .230),
    (-1.93, .82, .798, .222),
    (-1.76, .90, .862, .215),
    (-1.55, .935, .938, .212),
    (-1.315, .940, .962, .212),
    (-1.02, .918, 1.015, .215),
    (-0.68, .876, 1.030, .218),
    (0.10, .876, 1.045, .218),
    (0.68, .888, 1.055, .218),
    (1.02, .928, 1.065, .215),
    (1.315, .940, 1.070, .215),
    (1.62, .922, 1.075, .225),
    (1.86, .868, 1.035, .255),
    (1.98, .790, .955, .275),
    (2.05, .670, .855, .290)
]

profile = [
    (0, 1.032), (.44, 1.022), (.77, .998), (.93, .962), (.985, .93),
    (1.0, .82), (.995, .64), (.985, .32), (.96, .09), (.85, .02), (.50, 0), (0, 0),
    (-.50, 0), (-.85, .02), (-.96, .09), (-.985, .32), (-.995, .64), (-1.0, .82),
    (-.985, .93), (-.93, .962), (-.77, .998), (-.44, 1.022)
]

v = []
f = []
nk = len(profile)
for y, w, zt, zb in sections:
    for xx, zz in profile:
        nose_round = .065 * max(0, 1 - (y + 2.05) / .32) * 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('Rally monocoque hull', v, f, 'Paint')
bpy.context.view_layer.objects.active = hull
hull.select_set(True)
sub = hull.modifiers.new('Subsurf', 'SUBSURF')
sub.levels = 3
bpy.ops.object.modifier_apply(modifier=sub.name)

# Sculpt angular creased door hollow and wide rally box-arch flat creases
for ve in hull.data.vertices:
    x, y, z = ve.co
    if abs(x) > .66:
        if -1.65 < y < 1.75:
            z_crease = .89 + .035 * (y + 1.0)
            if abs(z - z_crease) < .055:
                ve.co.x += math.copysign(.009 * math.exp(-((z - z_crease) / .028)**2), x)
        if -.88 < y < .88:
            fade = sin(pi * (y + .88) / 1.76)**.7
            depth = .028 * fade * math.exp(-((z - (.48 + .06 * (y + .88))) / .12)**2)
            ve.co.x -= math.copysign(depth, x)
        for wy in [WY_F, WY_R]:
            dy = abs(y - wy)
            if dy < .48 and .25 < z < .85:
                arch_bulge = .028 * max(0, 1 - (dy / .48)**2) * math.exp(-((z - .56) / .22)**2)
                ve.co.x += math.copysign(arch_bulge, x)
hull.data.update()

# Cut open wheelhouses
for wy in [WY_F, WY_R]:
    bpy.ops.mesh.primitive_cylinder_add(vertices=96, radius=.382, depth=2.4, location=(0, wy, .335), rotation=(0, pi / 2, 0))
    cut = bpy.context.object
    bpy.context.view_layer.objects.active = hull
    mod = hull.modifiers.new('WheelhouseCut', '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['CarbonFibre'])
for face in hull.data.polygons:
    c = face.center
    if any(abs(math.hypot(c.y - wy, c.z - .335) - .382) < .004 for wy in [WY_F, WY_R]) and abs(face.normal.x) < .25:
        face.material_index = 1

# Cut open cabin interior space so roll cage and bucket seats are clearly visible through windows
bpy.ops.mesh.primitive_cube_add(size=1, location=(0, .32, 1.08))
cut = bpy.context.object
cut.scale = (1.46, 2.68, 1.42)
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
bev = cut.modifiers.new('CabinCorner', 'BEVEL')
bev.width = .10
bev.segments = 4
bpy.ops.object.modifier_apply(modifier=bev.name)
bpy.context.view_layer.objects.active = hull
mod = hull.modifiers.new('OpenCabin', '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, .005, 2)
hull.select_set(False)

# Wide CarbonFibre Wheel Arch Overfenders, Wheelhouse Liners, Coilover Struts & Mud Flaps (x4)
for s in [-1, 1]:
    for wy, is_front in [(WY_F, True), (WY_R, False)]:
        # Sculpted wide arch flare & lip (CarbonFibre, not Paint per contract!)
        v_arch = []
        f_arch = []
        n_arch = 64
        for i in range(n_arch + 1):
            a = -.18 + (pi + .36) * i / n_arch
            y = wy - .384 * cos(a)
            z = .335 + .384 * sin(a)
            x_base = s * (sx(y, z) - .002)
            v_arch.append((x_base, y, z))
            v_arch.append((x_base + s * .028, y - .022 * cos(a), z + .024 * sin(a)))
            v_arch.append((x_base - s * .055, y + .005 * cos(a), z - .005 * sin(a)))
        for i in range(n_arch):
            b0 = 3 * i
            f_arch.append((b0, b0 + 1, b0 + 4, b0 + 3))
            f_arch.append((b0 + 1, b0 + 2, b0 + 5, b0 + 4))
        mesh('Wide wheel arch flare', v_arch, f_arch, 'CarbonFibre')

        # Deep wheelhouse liner & inner splash wall
        v_lin = []
        f_lin = []
        for i in range(56):
            a = -.25 + (pi + .50) * i / 55
            for x in [.63, .91]:
                v_lin.append((s * x, wy - .378 * cos(a), .335 + .378 * sin(a)))
        for i in range(55):
            f_lin.append((2 * i, 2 * i + 1, 2 * i + 3, 2 * i + 2))
        mesh('Wheel arch liner', v_lin, f_lin, 'CarbonFibre')

        vv = []
        ff = []
        for i in range(65):
            a = -.25 + (pi + .50) * i / 64
            for r in [.25, .41]:
                vv.append((s * .63, wy - r * cos(a), .335 + r * sin(a)))
        for i in range(64):
            ff.append((2 * i, 2 * i + 1, 2 * i + 3, 2 * i + 2))
        mesh('Inner wheelhouse wall', vv, ff, 'CarbonFibre')

        # Visible Rally Coilover Damper & Spring inside the wheel arch gap
        cylinder('Coilover strut tube', (s * .68, wy, .54), .024, .32, 'Metal', axis='Z', vertices=20)
        pts_spring = []
        for k in range(65):
            ang = 2 * pi * 5.5 * (k / 64)
            pts_spring.append((s * .68 + .038 * cos(ang), wy + .038 * sin(ang), .44 + .20 * (k / 64)))
        curve('Rally coilover spring', pts_spring, .007, 'Lamps')

        # Flexible Rubber Mud Flap behind every wheel (x4)
        y_flap = wy + .375
        flap_pts = [
            (s * .74, y_flap, .095),
            (s * .938, y_flap, .095),
            (s * .942, y_flap - .012, .365),
            (s * .74, y_flap - .012, .365)
        ]
        o_flap = mesh('Mud flap', flap_pts, [(0, 1, 2, 3)], 'Rubber')
        mod = o_flap.modifiers.new('FlapThick', 'SOLIDIFY')
        mod.thickness = .008
        bevel(o_flap, .002, 2)
        cube('Mud flap bracket', (s * .84, y_flap + .006, .285), (.16, .008, .028), 'Metal', .002)
        for bx in [.78, .84, .90]:
            cylinder('Mud flap bolt', (s * bx, y_flap + .009, .285), .004, .008, 'Metal', axis='Y', vertices=8)

    # Side skirts / aerodynamic carbon rocker sill extensions
    cube('Carbon side skirt', (s * .89, 0.0, .212), (.075, 1.84, .035), 'CarbonFibre', .006)
    cube('Side skirt vertical fin', (s * .928, .82, .245), (.008, .18, .085), 'CarbonFibre', .002)

    # 3-Door Hatchback layout: ONE long front door per side + rear quarter panel seam
    for line in [0, 1]:
        coords = []
        for i in range(40):
            z = .255 + (1.035 - .255) * i / 39
            if line == 0:
                y = -.96 - .045 * sin(pi * i / 39)
            else:
                y = .44 + .03 * (1 - i / 39)
            if z < bottom(y) + .015:
                continue
            coords.append((s * (sx(y, z) + .0025), y, z))
        curve('Door shut gap', coords, .0022, 'CarbonFibre')

    # Single door handle per side (3-door hatchback)
    for y in [.22]:
        uvball('Handle recess', (s * (sx(y, .94) + .003), y, .94), (.007, .115, .026), 'CarbonFibre')
        uvball('Door pull handle', (s * (sx(y, .95) + .016), y, .95), (.018, .098, .016), 'Paint')

cube('Underbody floor pan', (0, 0, .218), (1.52, 3.60, .05), 'CarbonFibre', .02)

# Integrated Front Carbon Chin Spoiler & Heavy Aluminium Sump Guard curving under front bumper (matching sheet.jpg!)
chin_v = []
chin_f = []
for i in range(25):
    u = -1 + 2 * i / 24
    x = u * .87
    ok, hit, no, idx = hull.ray_cast(Vector((x, -3.0, .238)), Vector((0, 1, 0)))
    yb = (hit.y if ok else -1.96 + .16 * u * u) - .022
    chin_v.extend([
        (x, yb, .218),
        (x, yb - .015, .242),
        (x, yb + .14, .242),
        (x, yb + .14, .218)
    ])
for i in range(24):
    b0 = 4 * i
    for j in range(4):
        chin_f.append((b0 + j, b0 + 4 + j, b0 + 4 + (j + 1) % 4, b0 + (j + 1) % 4))
bevel(mesh('Front carbon chin spoiler', chin_v, chin_f, 'CarbonFibre'), .004, 2)

for s in [-1, 1]:
    canard_pts = [
        (s * .74, -1.96, .30), (s * .90, -1.80, .33),
        (s * .91, -1.72, .345), (s * .76, -1.86, .31)
    ]
    o_can = mesh('Front bumper dive canard', canard_pts, [(0, 1, 2, 3)], 'CarbonFibre')
    o_can.modifiers.new('CanardThick', 'SOLIDIFY').thickness = .006
    bevel(o_can, .002, 2)

# Heavy Brushed-Aluminium Sump Guard / Skid Plate wrapping from front lower grille mouth under the engine bay
sump_v = [
    (-.48, -2.042, .265), (.48, -2.042, .265),
    (.54, -1.985, .185), (-.54, -1.985, .185),
    (-.56, -1.75, .155), (.56, -1.75, .155),
    (-.52, -1.30, .150), (.52, -1.30, .150)
]
sump_f = [(0, 1, 2, 3), (3, 2, 5, 4), (4, 5, 7, 6)]
o_sump = mesh('Aluminium sump guard', sump_v, sump_f, 'Metal')
mod_s = o_sump.modifiers.new('SumpThick', 'SOLIDIFY')
mod_s.thickness = .012
bevel(o_sump, .003, 2)
for sx_slot in [-.28, -.14, 0.0, .14, .28]:
    cube('Sump guard cooling slot', (sx_slot, -2.012, .222), (.038, .035, .022), 'CarbonFibre', .002)
for bx in [-.40, -.20, 0.0, .20, .40]:
    cylinder('Sump guard bolt', (bx, -2.038, .252), .006, .010, 'Metal', axis='Y', vertices=8)

# Bonnet panel seams, Quick-Release Bonnet Pins, and Flush-Mounted Angular Bonnet Louver Vents
for s in [-1, 1]:
    curve('Bonnet panel gap', [(s * (width(y) - .12), y, top(y) + .012) for y in [-1.96, -1.82, -1.58, -1.28, -1.04, -.98]], .0022, 'CarbonFibre')
    # Bonnet quick-release latch pin
    ok_p, hit_p, _, _ = hull.ray_cast(Vector((s * .50, -1.82, 2.0)), Vector((0, 0, -1)))
    zp = (hit_p.z if ok_p else .86) + .003
    cylinder('Bonnet pin plate', (s * .50, -1.82, zp), .020, .004, 'Metal', axis='Z', vertices=20)
    torus_axis('Bonnet latch ring', (s * .50, -1.82, zp + .004), .013, .0025, 'Metal', axis='Z', n=24, k=8)

    # Flush-mounted trapezoidal carbon bonnet extractor vents conformed to hood curvature
    vent_poly = []
    for vx, vy in [(.45, -1.56), (.69, -1.51), (.73, -1.23), (.49, -1.27)]:
        ok, hit, no, idx = hull.ray_cast(Vector((s * vx, vy, 2.0)), Vector((0, 0, -1)))
        vz = (hit.z if ok else top(vy)) + .003
        vent_poly.append((s * vx, vy, vz))
    o_vent = mesh('Bonnet vent surround', vent_poly, [(0, 1, 2, 3)], 'CarbonFibre')
    o_vent.modifiers.new('VentThick', 'SOLIDIFY').thickness = .006
    bevel(o_vent, .002, 2)
    for k in range(5):
        t_v = (k + 0.5) / 5.0
        vy_in = -1.54 + t_v * .27
        vy_out = -1.49 + t_v * .27
        vx_in = .47 + t_v * .03
        vx_out = .67 + t_v * .03
        ok1, hit1, _, _ = hull.ray_cast(Vector((s * vx_in, vy_in, 2.0)), Vector((0, 0, -1)))
        ok2, hit2, _, _ = hull.ray_cast(Vector((s * vx_out, vy_out, 2.0)), Vector((0, 0, -1)))
        z1 = (hit1.z if ok1 else top(vy_in)) + .007
        z2 = (hit2.z if ok2 else top(vy_out)) + .007
        curve('Bonnet vent louver slat', [(s * vx_in, vy_in, z1), (s * vx_out, vy_out, z2)], .0055, 'CarbonFibre')

# Steeply raked windscreen & 3-door rally greenhouse with polycarbonate rear quarter windows
def roofedge(y):
    return float(np.interp(y, [-.38, -.24, -.02, .28, .68, .98, 1.22, 1.44, 1.52], [1.358, 1.388, 1.402, 1.404, 1.394, 1.378, 1.354, 1.320, 1.300]))

def cabx(y, z):
    x = .828 - .168 * max(0, min(1, (z - 1.03) / .38)) + .006 * sin((y + 1) * 1.5)
    return x - .075 * max(0, min(1, (y - 1.38) / .48))**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('PanelGauge', 'SOLIDIFY')
        mod.thickness = thick
        bevel(o, .003, 2)
    return o

# 3-Door window geometry: long front door window + angled rally rear quarter window with circular air vents
windows = [
    [(-.94, 1.045), (-.62, 1.252), (-.36, 1.345), (-.08, 1.372), (.41, 1.366), (.43, 1.055)],
    [(.51, 1.058), (.49, 1.364), (.92, 1.342), (1.14, 1.292), (1.28, 1.188), (1.33, 1.082)]
]
outer = [(-1.01, 1.015), (-.74, 1.232), (-.50, 1.340), (-.38, roofedge(-.38))]
outer += [(y, roofedge(y)) for y in [-.38 + 1.90 * i / 64 for i in range(1, 65)]]
outer += [(1.82, 1.058), (1.89, 1.012)]

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)
    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('Cabin side pressing ' + str(sgn), outer, .008)
    for k, poly in enumerate(windows):
        rp = rounded(poly, .055, 8)
        cut = prism_yz('WindowCut', rp, .15)
        bpy.context.view_layer.objects.active = surround
        mod = surround.modifiers.new('WinBool', '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('Side glass ' + str(k), vv, ff, 'Glass')
        curve('Window rubber seal', [cp(sgn, y, z, .011) for y, z in rp], .0045, 'CarbonFibre', cyclic=True)

        # Rally detail on rear quarter window (k == 1): top row of 5 circular polycarbonate air vents!
        if k == 1:
            for vi in range(5):
                vy = .59 + vi * .095
                vz = 1.318 - vi * .005
                vx, vyy, vzz = cp(sgn, vy, vz, .012)
                torus_x('Polycarbonate window vent', (vx, vyy, vzz), .022, .004, 'CarbonFibre', n=20, k=6)
        # Rally detail on front window (k == 0): sliding polycarbonate hatch frame
        if k == 0:
            slide_pts = [cp(sgn, -.28, 1.08, .012), cp(sgn, .08, 1.08, .012), cp(sgn, .08, 1.24, .012), cp(sgn, -.28, 1.24, .012)]
            curve('Polycarbonate slider frame', slide_pts, .003, 'CarbonFibre', cyclic=True)

    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('Carbon B pillar', [cp(sgn, y, z, .013) for y, z in [(.425, 1.04), (.405, 1.38), (.495, 1.38), (.515, 1.045)]], 'CarbonFibre', thick=.003)

    vv = []
    ff = []
    for i in range(81):
        y = -1.00 + 2.86 * i / 80
        zt = 1.025 + .018 * (y + 1.00) / 2.86
        zl = .950 + .035 * 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(80):
        for j in range(4):
            a = i * 5 + j
            ff.append((a, a + 5, a + 6, a + 1))
    o = mesh('Door belt shoulder', vv, ff, 'Paint')
    o.modifiers.new('BeltGauge', 'SOLIDIFY').thickness = .006
    curve('Belt weatherstrip', [cp(sgn, y, 1.036 + .015 * (y + 1) / 2.3, .008) for y in [-.98, -.5, 0, .5, 1.0, 1.32]], .0045, 'CarbonFibre')

    # Aero Carbon Fibre Rally Wing Mirrors (mounted cleanly on front door corner)
    mx, my, mz = cp(sgn, -.78, 1.06, .005)
    curve('Aero mirror stalk', [(mx, my, mz), (sgn * .90, -.76, 1.08)], .014, 'CarbonFibre')
    uvball('Carbon mirror housing', (sgn * .915, -.76, 1.095), (.068, .092, .038), 'CarbonFibre')
    uvball('Mirror glass', (sgn * .915, -.688, 1.095), (.052, .008, .028), 'Metal')

def glass_grid(name, rows, n=36):
    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 + ' seal', edge, .008, 'CarbonFibre', cyclic=True)
    return o

# Steeply raked windscreen
rows = []
for i in range(25):
    t = i / 24
    y = -1.00 + .62 * t
    z = float(np.interp(y, [-1.00, -.74, -.50, -.38], [1.022, 1.220, 1.334, 1.358])) - .004 * sin(pi * t)
    rows.append((y, z, cabx(y, z) - .017))
glass_grid('Steep raked windscreen', rows)

# Rear hatch glass
rows = []
for i in range(23):
    t = i / 22
    y = 1.52 + .35 * t
    z = 1.300 - .260 * t
    rows.append((y, z, cabx(y, z) - .019))
glass_grid('Rear tailgate glass', rows)

# Hatch lower glass flange
vv = []
ff = []
for j in range(37):
    u = -1 + 2 * j / 36
    x = (cabx(1.87, 1.040) - .019) * u
    y = 1.87 + .018 * (1 - u * u)
    z = 1.040 + .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, .98))
    for k in range(5):
        t = k / 4
        vv.append((x, y, z * (1 - t) + (hit.z + .003) * t))
for j in range(36):
    for k in range(4):
        a = j * 5 + k
        ff.append((a, a + 5, a + 6, a + 1))
o = mesh('Hatch glass flange', vv, ff, 'Paint')
o.modifiers.new('FlangeGauge', 'SOLIDIFY').thickness = .006

# Crowned Roof Panel
vv = []
ff = []
for i in range(45):
    t = i / 44
    y = -.38 + 1.90 * t
    for j in range(37):
        u = -1 + 2 * j / 36
        z = roofedge(y) + (.012 + .006 * sin(pi * t)) * (1 - u * u)
        yy = y + (-.020 * (1 - t) + .016 * t) * (1 - u * u)
        vv.append((cabx(y, roofedge(y)) * u, yy, z))
for i in range(44):
    for j in range(36):
        a = i * 37 + j
        ff.append((a, a + 1, a + 38, a + 37))
o = mesh('Roof outer panel', vv, ff, 'Paint')
o.modifiers.new('RoofGauge', 'SOLIDIFY').thickness = .008

# ROOF AIR SCOOP: Prominent aerodynamic central roof intake hood above windscreen header (matching sheet.jpg!)
scoop_v = [
    (-.22, -.34, 1.368), (.22, -.34, 1.368), (.195, -.34, 1.438), (-.195, -.34, 1.438),
    (-.21, -.08, 1.388), (.21, -.08, 1.388), (.185, -.08, 1.442), (-.185, -.08, 1.442),
    (-.13, .34, 1.396), (.13, .34, 1.396), (.115, .34, 1.416), (-.115, .34, 1.416)
]
scoop_f = [
    (0, 4, 7, 3), (1, 2, 6, 5), (3, 7, 6, 2), (0, 1, 5, 4),
    (4, 8, 11, 7), (5, 6, 10, 9), (7, 11, 10, 6), (4, 5, 9, 8),
    (8, 9, 10, 11)
]
o_scoop = mesh('Roof air scoop shell', scoop_v, scoop_f, 'CarbonFibre')
bevel(o_scoop, .009, 3)
scoop_cap_v = [
    (-.190, -.32, 1.439), (.190, -.32, 1.439),
    (.180, -.08, 1.443), (-.180, -.08, 1.443),
    (-.112, .32, 1.417), (.112, .32, 1.417)
]
mesh('Roof scoop painted top', scoop_cap_v, [(0, 1, 2, 3), (3, 2, 5, 4)], 'Paint')
panel('Roof scoop intake opening', [(-.195, -.344, 1.374), (.195, -.344, 1.374), (.180, -.344, 1.430), (-.180, -.344, 1.430)], 'CarbonFibre', thick=.006)
cube('Roof scoop center vane', (0, -.34, 1.402), (.008, .04, .048), 'CarbonFibre', .001)

# BIG BI-PLANE RALLY REAR WING (CarbonFibre, mounted at rear hatch top with wide sculpted endplates)
vv_sp = []
ff_sp = []
for i in range(37):
    u = -1 + 2 * i / 36
    for yy, zz in [(1.42, 1.33), (1.68, 1.31), (1.70, 1.28), (1.45, 1.30)]:
        vv_sp.append((u * .73, yy + .02 * (1 - u * u), zz + .012 * (1 - u * u)))
for i in range(36):
    for j in range(4):
        ff_sp.append((i * 4 + j, (i + 1) * 4 + j, (i + 1) * 4 + (j + 1) % 4, i * 4 + (j + 1) % 4))
ff_sp.extend([(3, 2, 1, 0), (144, 145, 146, 147)])
bevel(mesh('Rear wing lower deck', vv_sp, ff_sp, 'CarbonFibre'), .005, 2)

vv_up = []
ff_up = []
for i in range(37):
    u = -1 + 2 * i / 36
    z_dip = -.012 * (1 - u * u)
    for yy, zz in [(1.56, 1.385 + z_dip), (1.92, 1.416 + z_dip), (1.93, 1.394 + z_dip), (1.58, 1.366 + z_dip)]:
        vv_up.append((u * .75, yy, zz))
for i in range(36):
    for j in range(4):
        ff_up.append((i * 4 + j, (i + 1) * 4 + j, (i + 1) * 4 + (j + 1) % 4, i * 4 + (j + 1) % 4))
ff_up.extend([(3, 2, 1, 0), (144, 145, 146, 147)])
bevel(mesh('Rear wing upper airfoil', vv_up, ff_up, 'CarbonFibre'), .005, 2)

for s in [-1, 1]:
    ep_poly = [
        (s * .745, 1.38, 1.27),
        (s * .765, 1.60, 1.418),
        (s * .770, 1.96, 1.420),
        (s * .755, 1.91, 1.23),
        (s * .735, 1.64, 1.18)
    ]
    panel('Rear wing endplate', ep_poly, 'CarbonFibre', thick=.014)
    py_poly = [
        (s * .28, 1.48, 1.30),
        (s * .28, 1.60, 1.385),
        (s * .28, 1.86, 1.395),
        (s * .28, 1.68, 1.29)
    ]
    panel('Rear wing center pylon', py_poly, 'CarbonFibre', thick=.012)

# BONNET LIGHT POD WITH FOUR EXTRA LAMPS (Mounted on front bonnet nose, with realistic multi-element rally reflector optics!)
pod_v = [
    (-.46, -1.93, .785), (.46, -1.93, .785),
    (.43, -1.93, .902), (-.43, -1.93, .902),
    (-.38, -1.58, .885), (.38, -1.58, .885),
    (.34, -1.58, .922), (-.34, -1.58, .922)
]
pod_f = [
    (0, 1, 2, 3), (4, 7, 6, 5),
    (0, 4, 5, 1), (3, 2, 6, 7),
    (0, 3, 7, 4), (1, 5, 6, 2)
]
o_pod = mesh('Bonnet light pod housing', pod_v, pod_f, 'CarbonFibre')
bevel(o_pod, .012, 3)

for lx in [-.315, -.105, .105, .315]:
    # Individual circular lamp barrel, carbon bezel, metallic reflector dish, 4 internal LED projector lenses, and clear Glass cover
    cylinder('Pod lamp barrel', (lx, -1.90, .848), .084, .12, 'CarbonFibre', axis='Y', vertices=36)
    torus_axis('Pod lamp bezel', (lx, -1.964, .848), .080, .009, 'CarbonFibre', axis='Y', n=36, k=8)
    cylinder('Pod lamp reflector dish', (lx, -1.954, .848), .073, .010, 'Metal', axis='Y', vertices=32)
    torus_axis('Pod lamp LED halo ring', (lx, -1.962, .848), .064, .0035, 'Lamps', axis='Y', n=32, k=6)
    parts['Body'][-1]['lamp_zone'] = 0
    lampobjs.append(parts['Body'][-1])
    # 4 internal high-power LED emitter lenses + center chrome boss inside each lamp
    cylinder('Pod lamp center boss', (lx, -1.960, .848), .016, .010, 'CarbonFibre', axis='Y', vertices=16)
    for dx_l, dz_l in [(-.028, -.026), (.028, -.026), (-.028, .026), (.028, .026)]:
        cylinder('Pod internal LED reflector', (lx + dx_l, -1.959, .848 + dz_l), .020, .008, 'Metal', axis='Y', vertices=18)
        o_pl = cylinder('Pod internal LED emitter', (lx + dx_l, -1.963, .848 + dz_l), .014, .008, 'Lamps', axis='Y', vertices=16)
        o_pl['lamp_zone'] = 0
        lampobjs.append(o_pl)
    # Clear Glass protective lens cover
    cylinder('Pod lamp glass lens', (lx, -1.968, .848), .072, .003, 'Glass', axis='Y', vertices=32)
    for bi in range(6):
        ba = 2 * pi * bi / 6
        cylinder('Pod bezel screw', (lx + .078 * cos(ba), -1.972, .848 + .078 * sin(ba)), .0035, .005, 'Metal', axis='Y', vertices=6)

# Tailgate seams and wipers
for sgn in [-1, 1]:
    curve('Tailgate seam', [(sgn * .65, 1.54, 1.28), (sgn * .76, 1.87, 1.04), (sgn * .70, 1.98, .62), (sgn * .55, 2.00, .56)], .002, 'CarbonFibre')
    curve('Wiper arm', [(sgn * .14, -1.00, 1.04), (sgn * .36, -.90, 1.10)], .004, 'CarbonFibre')
    curve('Wiper blade', [(sgn * .14, -.92, 1.09), (sgn * .58, -.86, 1.12)], .0045, 'CarbonFibre')

def lamp(name, points, zone, thick=.004):
    o = panel(name, points, 'Lamps', thick=thick)
    o['lamp_zone'] = zone
    lampobjs.append(o)
    return o

# ORIGINAL FRONT FACE: Slim angular LED headlights with twin L-hook DRL blades, hexagonal mesh grille & lower rally intakes
for s in [-1, 1]:
    outline = rounded([(.43, .71), (.72, .725), (.87, .815), (.84, .865), (.48, .805)], .014, 5)
    pts = [(s * x, -2.03, z) for x, z in outline]
    panel('Headlamp housing', pts, 'CarbonFibre', thick=.004)
    curve('Headlamp bezel', pts, .003, 'CarbonFibre', cyclic=True)
    for j in range(2):
        x = .54 + .12 * j
        z = .765 + .018 * j
        pp = [(s * (x - .032), -2.05, z - .015), (s * (x + .032), -2.05, z - .010), (s * (x + .030), -2.05, z + .014), (s * (x - .032), -2.05, z + .016)]
        panel('Projector reflector', pp, 'Metal', thick=.003)
        lamp('Projector LED', [(s * (x - .020), -2.06, z - .008), (s * (x + .020), -2.06, z - .006), (s * (x + .020), -2.06, z + .008), (s * (x - .020), -2.06, z + .010)], 0, .002)
    o = curve('Slim LED DRL signature', [(s * .45, -2.055, .722), (s * .70, -2.03, .738), (s * .84, -1.96, .815), (s * .81, -1.95, .848)], .0055, 'Lamps')
    o['lamp_zone'] = 0
    lampobjs.append(o)
    panel('Headlamp lens cover', pts, 'Glass', thick=0)

    # Aggressive side brake-cooling bumper intakes
    intake_pts = [(s * .58, -2.00, .295), (s * .86, -1.95, .315), (s * .88, -1.94, .485), (s * .62, -1.99, .495)]
    panel('Side brake duct recess', intake_pts, 'CarbonFibre', thick=.006)
    for z_slat in [.355, .425]:
        curve('Side intake vane', [(s * .60, -2.01, z_slat), (s * .85, -1.96, z_slat)], .006, 'CarbonFibre')

    # Original angular C-claw LED tail lights wrapping rear quarters (with full red lens body + bright LED signature)
    p = [(s * x, 2.01, z) for x, z in rounded([(.43, .800), (.78, .782), (.85, .830), (.83, .902), (.54, .882)], .014, 6)]
    panel('Tail lamp housing', p, 'CarbonFibre', thick=.003)
    lamp('Red tail lamp body', [(s * x, 2.012, z) for x, z in rounded([(.45, .808), (.76, .790), (.83, .832), (.81, .894), (.55, .876)], .012, 5)], 1, .004)
    panel('Tail lamp lens', p, 'Glass')
    o = curve('Tail LED claw', p, .006, 'Lamps', cyclic=True)
    o['lamp_zone'] = 1
    lampobjs.append(o)
    o2 = curve('Tail LED inner bar', [(s * .46, 2.02, .845), (s * .76, 1.98, .835), (s * .83, 1.82, .858)], .005, 'Lamps')
    o2['lamp_zone'] = 1
    lampobjs.append(o2)
    lamp('Amber front indicator', [(s * .75, -1.94, .805), (s * .81, -1.90, .815), (s * .81, -1.90, .828), (s * .75, -1.94, .818)], 2)
    lamp('Rear indicator', [(s * .58, 2.02, .855), (s * .76, 1.98, .852), (s * .76, 1.98, .866), (s * .58, 2.02, .868)], 2)
    lamp('Rear bumper reflector', [(s * .52, 2.01, .42), (s * .76, 1.97, .42), (s * .76, 1.97, .44), (s * .52, 2.01, .44)], 1)

# Rear Tailgate Sculpted Recess & Grip Handle (Painted body-colour licence recess so it doesn't look like a black patch!)
panel('Rear tailgate recess', [(x, 2.035, z) for x, z in rounded([(-.34, .62), (.34, .62), (.28, .78), (-.28, .78)], .015, 5)], 'Paint', thick=.003)
curve('Rear tailgate recess seam', [(x, 2.038, z) for x, z in rounded([(-.34, .62), (.34, .62), (.28, .78), (-.28, .78)], .015, 5)], .002, 'CarbonFibre', cyclic=True)
curve('Tailgate character crease', [(-.54, 2.015, .80), (0, 2.035, .79), (.54, 2.015, .80)], .005, 'Paint')

# Original Trapezoidal Upper Grille with vertical + honeycomb mesh bars
grille_pts = [
    (x, -2.065 + .03 * abs(x), z)
    for x, z in rounded([(-.44, .64), (.44, .64), (.38, .775), (-.38, .775)], .02, 6)
]
panel('Upper grille cavity', grille_pts, 'CarbonFibre', thick=.008)
curve('Upper grille surround', grille_pts, .009, 'CarbonFibre', cyclic=True)
for i in range(19):
    x = -.38 + i * .76 / 18
    curve('Upper grille vertical bar', [(x, -2.075, .648), (x, -2.075, .768)], .0035, 'CarbonFibre')
for z_bar in [.685, .728]:
    curve('Upper grille cross bar', [(-.41, -2.075, z_bar), (0, -2.085, z_bar), (.41, -2.075, z_bar)], .0035, 'CarbonFibre')

# Broad Lower Bumper Intercooler Intake with metallic intercooler core visible inside
panel('Lower grille cavity', [(-.55, -2.04, .295), (.55, -2.04, .295), (.48, -2.06, .485), (-.48, -2.06, .485)], 'CarbonFibre', thick=.01)
cube('Front intercooler core', (0, -1.98, .385), (.82, .04, .16), 'Metal', .005)
for z in [.320, .358, .396, .434, .468]:
    w = .53 - (z - .295) * .30
    curve('Lower intake mesh slat', [(-w, -2.05, z), (0, -2.07, z), (w, -2.05, z)], .005, 'CarbonFibre')
for x in [-.34, -.17, 0, .17, .34]:
    curve('Lower grille strut', [(x, -2.06, .305), (x, -2.06, .475)], .004, 'CarbonFibre')

# Rear Rally Diffuser & Single Large Right-Side Chrome Exhaust Pipe (matching sheet.jpg!)
cube('Rear rally diffuser', (0, 1.955, .285), (1.44, .14, .12), 'CarbonFibre', .02)
for x in [-.56, -.28, 0, .28, .56]:
    cube('Diffuser strake', (x, 1.985, .265), (.016, .11, .065), 'CarbonFibre', .003)

# Prominent Chrome Exhaust Pipe protruding cleanly from right side of rear diffuser
EXH_X = -0.38
EXH_Y = 2.045
EXH_Z = 0.285
cylinder('Chrome exhaust tip', (EXH_X, 1.985, EXH_Z), .048, .13, 'Metal', axis='Y', vertices=40)
torus_axis('Exhaust rolled lip', (EXH_X, 2.048, EXH_Z), .044, .005, 'Metal', axis='Y', n=36, k=8)
cylinder('Exhaust inner bore', (EXH_X, 2.046, EXH_Z), .039, .008, 'CarbonFibre', axis='Y', vertices=32)

o_hbl = curve('High mounted brake light', [(-.22, 1.56, 1.31), (.22, 1.56, 1.31)], .007, 'Lamps')
o_hbl['lamp_zone'] = 1
lampobjs.append(o_hbl)

# RALLY INTERIOR: Stripped race floor, tubular Roll Cage (Metal), Two Bucket Seats with winged headrests, Dashboard, Door cards, Handbrake
cube('Stripped race floor', (0, .28, .34), (1.44, 2.65, .05), 'Interior', .02)

# Full FIA-style Tubular Roll Cage visible through front, side and rear windows!
cage_r = .022
for s in [-1, 1]:
    curve('Roll cage B pillar', [(s * .64, .38, .38), (s * .62, .40, .98), (s * .52, .42, 1.30)], cage_r, 'Metal')
    curve('Roll cage A pillar', [(s * .62, -.72, .40), (s * .60, -.76, .98), (s * .49, -.34, 1.28), (s * .52, .42, 1.30)], cage_r, 'Metal')
    curve('Roll cage rear stay', [(s * .52, .42, 1.30), (s * .54, 1.32, .68)], cage_r, 'Metal')
    curve('Roll cage door bar upper', [(s * .62, -.72, .82), (s * .63, .38, .52)], .018, 'Metal')
    curve('Roll cage door bar lower', [(s * .62, -.72, .50), (s * .63, .38, .84)], .018, 'Metal')

curve('Roll cage roof header', [(-.49, -.34, 1.28), (.49, -.34, 1.28)], cage_r, 'Metal')
curve('Roll cage B hoop top', [(-.52, .42, 1.30), (.52, .42, 1.30)], cage_r, 'Metal')
curve('Roll cage harness bar', [(-.62, .40, .88), (.62, .40, .88)], cage_r, 'Metal')
curve('Roll cage rear diagonal 1', [(-.52, .42, 1.30), (.54, 1.32, .68)], .020, 'Metal')
curve('Roll cage rear diagonal 2', [(.52, .42, 1.30), (-.54, 1.32, .68)], .020, 'Metal')
curve('Roll cage B diagonal', [(-.50, .42, 1.28), (.60, .38, .45)], .018, 'Metal')
curve('Roll cage roof V-brace 1', [(0, -.34, 1.28), (-.50, .42, 1.30)], .018, 'Metal')
curve('Roll cage roof V-brace 2', [(0, -.34, 1.28), (.50, .42, 1.30)], .018, 'Metal')

# Two Deep Winged Rally Bucket Seats (Driver + Co-Driver)
for s in [-1, 1]:
    sx_seat = s * .35
    cube('Bucket seat base', (sx_seat, -.05, .51), (.44, .50, .11), 'Interior', .03)
    back = cube('Bucket seat backrest', (sx_seat, .18, .80), (.44, .12, .56), 'Interior', .04)
    back.rotation_euler[0] = .12
    for xx in [-.19, .19]:
        uvball('Seat thigh bolster', (sx_seat + xx, -.05, .58), (.045, .24, .085), 'Interior', seg=18, rings=10)
        uvball('Seat shoulder wing', (sx_seat + xx * 1.12, .15, .92), (.055, .14, .13), 'Interior', seg=18, rings=10)
        cube('Seat halo ear', (sx_seat + xx * 1.05, .14, 1.11), (.045, .19, .14), 'Interior', .015)
    cube('Bucket headrest pad', (sx_seat, .21, 1.11), (.26, .09, .16), 'Interior', .03)
    for hx in [-.09, .09]:
        curve('Racing harness strap', [(sx_seat + hx, .40, .88), (sx_seat + hx, .16, .98), (sx_seat + hx, .08, .58)], .016, 'CarbonFibre')
    cube('Carbon door card', (s * .77, -.15, .70), (.06, 1.15, .32), 'CarbonFibre', .02)

# Rally flocked dashboard, co-driver trip computer, tall vertical hydraulic handbrake & sequential shifter
cube('Rally dashboard', (0, -.66, .87), (1.46, .38, .16), 'Interior', .04)
cube('Instrument binnacle', (.35, -.50, .95), (.32, .16, .10), 'CarbonFibre', .025)
cube('Co-driver rally display', (-.32, -.48, .92), (.24, .04, .12), 'CarbonFibre', .01)
cube('Center switch panel', (0, -.46, .78), (.22, .18, .26), 'CarbonFibre', .02)
curve('Sequential shifter', [(.10, -.22, .55), (.10, -.26, .86)], .012, 'Metal')
uvball('Shifter knob', (.10, -.26, .88), (.028, .028, .042), 'CarbonFibre', seg=16, rings=10)
curve('Hydraulic handbrake', [(.04, -.12, .55), (.04, -.16, .92)], .010, 'Metal')

# SteeringWheel node (separate mesh with origin at hub)
hub = Vector((.35, -.35, .875))
axis = Vector((0, .88, .47)).normalized()
curve('Steering column', [(.35, -.62, .77), tuple(hub)], .028, 'CarbonFibre')
bpy.ops.mesh.primitive_torus_add(major_radius=.142, minor_radius=.015, major_segments=56, minor_segments=10, location=hub)
o_sw = bpy.context.object
o_sw.name = 'Steering rim'
o_sw.rotation_euler = Vector((0, 0, 1)).rotation_difference(axis).to_euler()
finish(o_sw, '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((.128 * cos(a), .128 * sin(a), 0))
    curve('Steering spoke', [tuple(hub), tuple(p)], .012, 'Metal', 'SteeringWheel')
uvball('Steering hub boss', hub, (.058, .035, .058), 'CarbonFibre', 'SteeringWheel', seg=20, rings=10)

# FOUR GRAVEL TYRES ON WHITE MULTI-SPOKE RIMS WITH BRAKE DISCS & CALIPERS
for s, label in [(1, 'L'), (-1, 'R')]:
    for y, axle in [(WY_F, 'F'), (WY_R, 'R')]:
        group = 'Wheel_' + axle + label
        cx = s * .805
        cz = WRAD
        wheelgroups[group] = (cx, y, cz)

        # Chunky Gravel Tyre profile with deep tread blocks and square rally shoulders
        profile_w = [
            (-.114, .212), (-.118, .255), (-.108, .298), (-.094, .326),
            (-.078, .335), (-.055, .335), (-.050, .326), (-.042, .326),
            (-.037, .335), (-.014, .335), (-.009, .326), (.000, .326),
            (.005, .335), (.028, .335), (.033, .326), (.041, .326),
            (.046, .335), (.068, .335), (.073, .326), (.080, .335),
            (.094, .326), (.108, .298), (.118, .255), (.114, .212)
        ]
        v = []
        f = []
        n = 96
        k = len(profile_w)
        for i in range(n):
            a = 2 * pi * i / n
            lug = .0038 * (1 if (i % 2 == 0) else -1)
            for xx, r in profile_w:
                rr = r + (lug if r > .328 else 0)
                v.append((cx + xx, y + rr * sin(a), cz + rr * 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('Gravel rally tyre', v, f, 'Rubber', group)

        # Sidewall protection ribs (no lettering per contract!)
        for side in [-1, 1]:
            torus_x('Sidewall bead', (cx + side * .112, y, cz), .222, .0035, 'Rubber', group, n=72, k=6)
            torus_x('Sidewall shoulder rib', (cx + side * .110, y, cz), .282, .0025, 'Rubber', group, n=72, k=6)

        # Aggressive gravel shoulder lug blocks around outer & inner shoulders
        for i in range(36):
            a = 2 * pi * i / 36
            for ss in [-1, 1]:
                coords = [
                    (cx + ss * .045, y + .334 * sin(a), cz + .334 * cos(a)),
                    (cx + ss * .098, y + .326 * sin(a + .035), cz + .326 * cos(a + .035))
                ]
                curve('Gravel tread lug', coords, .005, 'Rubber', group, res=6)

        # White Multi-Spoke Rally Rim (12 flat spokes + outer lip + recessed center bowl matching sheet.jpg)
        outer_x = cx + s * .108
        torus_x('Rim barrel', (cx, y, cz), .212, .012, 'WhiteRim', group, n=72, k=8)
        torus_x('Inner barrel shadow', (outer_x - s * .065, y, cz), .198, .010, 'CarbonFibre', group, n=56, k=6)
        cylinder('Ventilated brake disc', (outer_x - s * .042, y, cz), .178, .012, 'Metal', group, vertices=56)
        o_cal = cube('Rally brake caliper', (outer_x - s * .022, y - .125 * (-1 if axle == 'F' else 1), cz + .035), (.034, .075, .125), 'Lamps', .010, group)
        o_cal['lamp_zone'] = 1
        lampobjs.append(o_cal)

        # Outer white rim flange & recessed gravel guard ring
        torus_x('White rim outer lip', (outer_x, y, cz), .215, .011, 'WhiteRim', group, n=80, k=8)
        torus_x('White rim inner ring', (outer_x - s * .002, y, cz), .190, .007, 'WhiteRim', group, n=80, k=6)
        torus_x('White hub ring', (outer_x - s * .003, y, cz), .082, .010, 'WhiteRim', group, n=48, k=8)

        # 12 Bold Multi-spokes in WhiteRim material
        for i in range(12):
            a = 2 * pi * i / 12
            points = []
            for xx in [outer_x - s * .010, outer_x + s * .006]:
                for rad, an in [(.072, a - .062), (.196, a - .038), (.196, a + .038), (.072, a + .062)]:
                    points.append((xx, y + rad * sin(an), cz + rad * cos(an)))
            o_sp = mesh('White rally 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)], 'WhiteRim', group)
            bevel(o_sp, .002, 2)

        cylinder('Wheel center hub', (outer_x - s * .004, y, cz), .072, .022, 'WhiteRim', group, vertices=40)
        cylinder('Center cap recess', (outer_x + s * .005, y, cz), .032, .008, 'CarbonFibre', group, vertices=24)
        for i in range(5):
            a = 2 * pi * i / 5
            cylinder('Lug nut socket', (outer_x + s * .004, y + .054 * sin(a), cz + .054 * cos(a)), .0085, .006, 'CarbonFibre', group, vertices=10)
            cylinder('Wheel stud bolt', (outer_x + s * .007, y + .054 * sin(a), cz + .054 * cos(a)), .005, .006, 'Metal', group, vertices=6)

# Conform surface-mounted side details to hull skin
side_names = ['Door shut', 'Handle recess', 'Door pull']
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)

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(.52 + .14 * max(0, min(1, (y + 1.96) / 1.0)), 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)

# Subdivide front/rear trim panels before conforming to curved fascia
for o in parts['Body']:
    if o.type == 'MESH' and any(o.name.startswith(n) for n in ['Headlamp housing', 'Headlamp lens', 'Upper grille cavity', 'Lower grille cavity', 'Side brake duct', 'Tail lamp', 'Red tail', 'Amber front', 'Rear indicator', 'Rear bumper reflector', 'Rear tailgate recess']):
        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', 'Red tail')) else 7, use_grid_fill=True)
        bm.to_mesh(o.data)
        bm.free()

for o in parts['Body']:
    if o.type == 'CURVE' and any(o.name.startswith(n) for n in ['Headlamp', 'Slim LED', 'Upper grille', 'Lower grille', 'Lower intake', 'Side intake', 'Tail LED', 'Tailgate', 'Rear tailgate recess']):
        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 onto rear skin
rearnames = ['Tail lamp', 'Red tail', 'Tail LED', 'Rear indicator', 'Rear bumper reflector', 'Tailgate', 'Rear tailgate recess']
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 += .095
        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 lens'):
        offset = .007
    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 front lights & grilles onto front bumper skin
frontnames = ['Headlamp', 'Projector', 'Slim LED', 'Amber front', 'Side brake', 'Side intake', 'Upper grille', 'Lower grille', 'Lower intake']
for o in parts['Body']:
    if any(o.name.startswith(n) for n in frontnames):
        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.04 + .32 * (abs(x) / .94)**3 + .02 * ((z - .56) / .35)**2
        if o.type == 'MESH':
            for v in o.data.vertices:
                p = o.matrix_world @ v.co
                baseline = -2.05 if abs(p.x) < .43 else (-2.05 + .15 * (abs(p.x) - .43) / .42)
                p.y = face_y(p.x, p.z) + (p.y - baseline) * .08 - (.014 if 'Projector' in o.name else .020 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) - .009

# Convert, apply modifiers, map UVs (mapping Z height to V for Paint so lower body gets rally mud & dust!), and join into the 6 required mesh nodes
bpy.ops.object.select_all(action='DESELECT')
for group, objs in list(parts.items()):
    for o in objs:
        if o.name not in bpy.data.objects:
            continue
        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:
            mat_name = me.materials[p.material_index].name if p.material_index < len(me.materials) and me.materials[p.material_index] else ''
            n = p.normal
            axis_i = max(range(3), key=lambda i: abs(n[i]))
            axes = [i for i in range(3) if i != axis_i]
            for li in p.loop_indices:
                co = o.matrix_world @ me.vertices[me.loops[li].vertex_index].co
                if zone is not None:
                    uv[li].uv = ((zone + .5) / 3, .5)
                elif mat_name == 'Paint':
                    u_coord = (co.y + co.x * 0.5) * 0.45
                    v_coord = max(0.04, min(0.96, (co.z - 0.18) / 1.15))
                    uv[li].uv = (u_coord, v_coord)
                else:
                    uv[li].uv = (co[axes[0]] * 2.4, co[axes[1]] * 2.4)
        o.select_set(False)

    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

    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('GameMeshReduction', 'DECIMATE')
    dec.ratio = .46
    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]
    if bad:
        bmesh.ops.delete(bm, geom=bad, context='FACES')
    bmesh.ops.recalc_face_normals(bm, faces=list(bm.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 triangle budget (60k to 110k, aim ~95k)
total = sum(len(p.vertices) - 2 for ob in sc.objects if ob.type == 'MESH' for p in ob.data.polygons)
print('PRE_CLAMP_TRIS', total, flush=True)
if total > 102000:
    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('TriangleCeiling', 'DECIMATE')
    mod.ratio = (95000 - (total - bodytris)) / bodytris
    mod.use_collapse_triangulate = True
    bpy.ops.object.modifier_apply(modifier=mod.name)

# Exact wheel grounding: tyres touch Z=0 (Y=0 in glTF)
for name in wheelgroups:
    w = bpy.data.objects[name]
    zs = [(w.matrix_world @ v.co).z for v in w.data.vertices]
    dz = -min(zs)
    for v in w.data.vertices:
        v.co.z += dz
    w.data.update()

# Exact dimensional calibration of Body to 4.10m length (-2.05 to +2.05), 1.88m width (-0.94 to +0.94), 1.42m height (0 to 1.42)
body_ob = bpy.data.objects['Body']
b_verts = [v for v in body_ob.data.vertices]
xmin = min(v.co.x for v in b_verts)
xmax = max(v.co.x for v in b_verts)
ymin = min(v.co.y for v in b_verts)
ymax = max(v.co.y for v in b_verts)
zmax = max(v.co.z for v in b_verts)

sx_scale = 0.94 / max(abs(xmin), abs(xmax))
for v in b_verts:
    v.co.x *= sx_scale
    if v.co.y < 0:
        v.co.y *= -2.05 / ymin
    else:
        v.co.y *= 2.05 / ymax
    if v.co.z > 1.25:
        v.co.z = 1.25 + (v.co.z - 1.25) * (0.17 / (zmax - 1.25))
body_ob.data.update()

# Clean any degenerate triangles after scaling
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()

# Required Empty nodes (no mesh): Seat_Driver, Door_L, Door_R, Exhaust, Light_Head_L, Light_Head_R, Light_Tail_L, Light_Tail_R
markers = {
    'Seat_Driver': (.35, -.05, .58),
    'Door_L': (.94, -.20, .02),
    'Door_R': (-.94, -.20, .02),
    'Exhaust': (EXH_X, EXH_Y, EXH_Z),
    'Light_Head_L': (.64, -2.03, .77),
    'Light_Head_R': (-.64, -2.03, .77),
    'Light_Tail_L': (.66, 1.98, .84),
    'Light_Tail_R': (-.66, 1.98, .84)
}
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)

path = os.path.join(ROOT, 'rally-car.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
)

# Patch GLB JSON for exact Paint contract: baseColorFactor = [0.92, 0.92, 0.90, 1.0], KHR_materials_clearcoat = 1.0 / 0.05, occlusionTexture, Glass BLEND
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'] = [0.92, 0.92, 0.90, 1.0]
        m.setdefault('extensions', {})['KHR_materials_clearcoat'] = {'clearcoatFactor': 1.0, 'clearcoatRoughnessFactor': 0.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)

# Six real rally paint colours (default [0.92, 0.92, 0.90] first per contract!)
paints = [
    {'name': 'Gravel Stage White', 'linear_rgb': [0.92, 0.92, 0.90]},
    {'name': 'Nardo Tarmac Grey', 'linear_rgb': [0.34, 0.36, 0.38]},
    {'name': 'Monte Carlo Rally Red', 'linear_rgb': [0.62, 0.03, 0.03]},
    {'name': 'Nordic Ice Blue', 'linear_rgb': [0.03, 0.16, 0.48]},
    {'name': 'Safari Acid Green', 'linear_rgb': [0.12, 0.48, 0.06]},
    {'name': 'Acropolis Sunburst Gold', 'linear_rgb': [0.74, 0.38, 0.02]}
]

tri = sum(len(p.vertices) - 2 for o in asset if o.type == 'MESH' for p in o.data.polygons)
verts_all = [o.matrix_world @ v.co for o in asset if o.type == 'MESH' for v in o.data.vertices]
def gl(v):
    return [round(v[0], 6), round(v[2], 6), round(-v[1], 6)]
gpoints = [gl(p) for p in verts_all]
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)]},
    'wheelbase': 2.63,
    'triangles': tri,
    'paints': [p['linear_rgb'] for p in paints],
    'paint_names': [p['name'] for p in paints]
}
open(os.path.join(ROOT, 'nodes.json'), 'w').write(json.dumps(meta, indent=2))

notes = [
    "Original modern 3-door rally hatchback (4.10x1.88x1.42 m, 2.63 m wheelbase) with wide arches, bi-plane rear wing, roof scoop, 4-lamp bonnet pod, sump guard, mud flaps, gravel tyres on white multi-spoke rims, roll cage and bucket seats.",
    f"Triangles: {tri:,} across 6 rigid mesh nodes (Body, Wheel_FL/FR/RL/RR, SteeringWheel) + 8 Empty socket nodes; GLB {os.path.getsize(path) / 1048576:.2f} MiB.",
    "Materials (8): Paint (clearcoat + lower-body mud/dust in neutral base texture), Rubber, WhiteRim, Metal, CarbonFibre, Interior, Glass (BLEND), Lamps (emissive).",
    "Texture sizes: Paint 1024px base/normal, Rubber/WhiteRim/Metal/CarbonFibre/Interior 512px base/normal, Glass/Lamps 128px; all ORM maps 256px max.",
    "Poly Haven CC0 IDs: metal_plate, rubber_tiles, fabric_leather_01, brown_mud_dry (https://polyhaven.com/license).",
    "Differences from reference: original angular LED headlight DRL hooks, hexagonal grille mesh, C-claw tail lamps and 3-door window vent layout; zero logos or tyre text."
]
open(os.path.join(ROOT, 'NOTES.md'), 'w').write('\n'.join(notes) + '\n')
print('POLISHED_GLB', tri, os.path.getsize(path), meta['bounding_box']['size'], flush=True)

# Presentation renders using exported tile materials and Paint baseColorFactor multiply
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.0
nt.links.new(bn.outputs[0], mix.inputs[1])
nt.links.new(mix.outputs[0], bs.inputs['Base Color'])

def set_paint(rgb):
    mix.inputs[2].default_value = (*rgb, 1.0)

set_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 = -.001
mg = bpy.data.materials.new('Studio Ground Mat')
mg.use_nodes = True
mg.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value = (.34, .33, .31, 1)
mg.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value = .82
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 * .4
    o.data.color = color
    o.rotation_euler = (Vector((0, 0, .65)) - o.location).to_track_quat('-Z', 'Y').to_euler()

area('Key Daylight', (3.6, -4.8, 5.8), 900, 5.2, (1.0, .93, .84))
area('Fill Sky', (-3.4, 1.5, 4.5), 720, 4.2, (.78, .87, 1.0))
area('Front Low Fill', (-1.2, -5.2, 2.4), 260, 3.2, (1.0, .98, .95))

bpy.ops.object.camera_add()
camera = bpy.context.object
sc.camera = camera
camera.data.lens = 50

def view(loc, target=(0, 0, .68), 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=28):
    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)

# 1. final.png (3/4 front view, 1920x1080)
view((4.4, -5.6, 2.05), (0, -.05, .66))
render('final.png', 1920, 1080, 40)

# 2. side.png (orthographic side view, nose facing left to match sheet.jpg!)
view((6.5, 0, .70), (0, 0, .70), 4.75)
render('side.png', 1920, 1080, 28)

# 3. rear.png (3/4 rear inspection render)
view((-4.3, 5.5, 2.05), (0, .05, .68))
render('rear.png', 1280, 720, 24)

# 4. clay.png (same view as final, plain grey material)
view((4.4, -5.6, 2.05), (0, -.05, .66))
clay = bpy.data.materials.new('Clay')
clay.use_nodes = True
clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value = (.44, .44, .44, 1)
clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value = .62
sc.view_layers[0].material_override = clay
render('clay.png', 1920, 1080, 28)
sc.view_layers[0].material_override = None

# 5. paints.png (3x2 grid of the 6 paints in 3/4 view)
frames = []
for i, p in enumerate(paints):
    set_paint(p['linear_rgb'])
    render(f'paint_{i}.png', 640, 360, 16)
    im = bpy.data.images.load(os.path.join(ROOT, f'paint_{i}.png'), 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()
set_paint(paints[0]['linear_rgb'])
print('BUILD_COMPLETE', flush=True)
