import bpy
import bmesh
import math
import os
import json
import struct
import hashlib
import urllib.request
import numpy as np
from mathutils import Vector, Matrix

# ==============================================================================
# 1. SCENE RESET & CONFIGURATION
# ==============================================================================
bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
scene.unit_settings.system = 'METRIC'
scene.unit_settings.scale_length = 1.0

WORK_DIR = os.path.dirname(os.path.abspath(__file__))
TEX_DIR = os.path.join(WORK_DIR, "textures")
os.makedirs(TEX_DIR, exist_ok=True)
SCRIPT_SHA256 = hashlib.sha256(open(os.path.abspath(__file__), "rb").read()).hexdigest()

# Enable GPU Cycles
scene.render.engine = 'CYCLES'
prefs = bpy.context.preferences
cprefs = prefs.addons['cycles'].preferences
for dev_type in ('OPTIX', 'CUDA', 'NONE'):
    try:
        cprefs.compute_device_type = dev_type
        cprefs.get_devices()
        if any(d.type != 'CPU' for d in cprefs.devices):
            for d in cprefs.devices:
                d.use = (d.type != 'CPU')
            scene.cycles.device = 'GPU'
            break
    except Exception:
        pass

scene.cycles.samples = 64
scene.cycles.use_denoising = True

# ==============================================================================
# 2. POLY HAVEN TEXTURE DOWNLOADER
# ==============================================================================
PH_ASSETS = ["metal_plate", "rusty_metal_02", "rubber_tiles", "fabric_leather_02"]

def ensure_ph_textures():
    for asset in PH_ASSETS:
        for map_type in ["diff", "nor_gl", "arm"]:
            fname = f"{asset}_{map_type}_1k.jpg"
            fpath = os.path.join(TEX_DIR, fname)
            if not os.path.exists(fpath):
                found_local = False
                parent_dir = os.path.dirname(WORK_DIR)
                if os.path.exists(parent_dir):
                    for d in os.listdir(parent_dir):
                        cand = os.path.join(parent_dir, d, "textures", fname)
                        if os.path.exists(cand):
                            import shutil
                            shutil.copy2(cand, fpath)
                            found_local = True
                            break
                if not found_local:
                    url = f"https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{fname}"
                    print(f"Downloading {url}...")
                    req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
                    with urllib.request.urlopen(req, timeout=30) as resp, open(fpath, 'wb') as out:
                        out.write(resp.read())

ensure_ph_textures()

def load_img(fname, colorspace='sRGB'):
    fpath = os.path.join(TEX_DIR, fname)
    img = bpy.data.images.load(fpath, check_existing=True)
    img.colorspace_settings.name = colorspace
    return img

# ==============================================================================
# 3. COORDINATE HELPER & GEOMETRY UTILITIES
# ==============================================================================
# Game coordinates (Forge Garage contract v2):
#   +X = Left side, -X = Right side, centred on X = 0
#   +Y = Up, 0 = Ground plane
#   +Z = Front, -Z = Rear, 0 = Midpoint between front and rear axles
# Blender Z-up coordinates via gv(x, y, z) -> Vector((x, -z, y))
def gv(x, y, z):
    return Vector((x, -z, y))

def new_mesh_obj(name, bm):
    me = bpy.data.meshes.new(name)
    bm.to_mesh(me)
    bm.free()
    obj = bpy.data.objects.new(name, me)
    bpy.context.collection.objects.link(obj)
    return obj

def shade_smooth_auto(obj, angle_deg=38.0):
    me = obj.data
    for poly in me.polygons:
        poly.use_smooth = True
    bm = bmesh.new()
    bm.from_mesh(me)
    rad = math.radians(angle_deg)
    for e in bm.edges:
        if len(e.link_faces) == 2:
            a = e.link_faces[0].normal.angle(e.link_faces[1].normal, 0.0)
            if a > rad:
                e.smooth = False
        elif len(e.link_faces) == 1:
            e.smooth = False
    bm.to_mesh(me)
    bm.free()

def add_bevel(obj, width=0.004, segments=2, angle_limit=30.0):
    mod = obj.modifiers.new(name="Bevel", type='BEVEL')
    mod.width = width
    mod.segments = segments
    mod.limit_method = 'ANGLE'
    mod.angle_limit = math.radians(angle_limit)
    mod.harden_normals = False

def assign_mat(obj, mat):
    if obj.data.materials:
        obj.data.materials[0] = mat
    else:
        obj.data.materials.append(mat)

# ==============================================================================
# 4. SOURCE MATERIALS (POLY HAVEN + PROCEDURAL WEAR FOR CYCLES BAKING)
# ==============================================================================
def build_source_mat(name, ph_asset, scale=4.0, is_paint=False, is_chrome=False, is_rubber=False, is_interior=False, is_underbody=False):
    mat = bpy.data.materials.new(name=name)
    nt = mat.node_tree
    nt.nodes.clear()

    out = nt.nodes.new('ShaderNodeOutputMaterial')
    bsdf = nt.nodes.new('ShaderNodeBsdfPrincipled')
    nt.links.new(bsdf.outputs['BSDF'], out.inputs['Surface'])

    tex_coord = nt.nodes.new('ShaderNodeTexCoord')
    mapping = nt.nodes.new('ShaderNodeMapping')
    mapping.inputs['Scale'].default_value = (scale, scale, scale)
    nt.links.new(tex_coord.outputs['UV'], mapping.inputs['Vector'])

    img_diff = nt.nodes.new('ShaderNodeTexImage')
    img_diff.image = load_img(f"{ph_asset}_diff_1k.jpg", 'sRGB')

    img_arm = nt.nodes.new('ShaderNodeTexImage')
    img_arm.image = load_img(f"{ph_asset}_arm_1k.jpg", 'Non-Color')

    img_nor = nt.nodes.new('ShaderNodeTexImage')
    img_nor.image = load_img(f"{ph_asset}_nor_gl_1k.jpg", 'Non-Color')

    for img_n in (img_diff, img_arm, img_nor):
        nt.links.new(mapping.outputs['Vector'], img_n.inputs['Vector'])

    sep_arm = nt.nodes.new('ShaderNodeSeparateColor')
    nt.links.new(img_arm.outputs['Color'], sep_arm.inputs['Color'])

    noise = nt.nodes.new('ShaderNodeTexNoise')
    noise.inputs['Scale'].default_value = 11.0
    noise.inputs['Detail'].default_value = 6.0
    nt.links.new(tex_coord.outputs['UV'], noise.inputs['Vector'])

    if is_paint:
        # Paint contract: neutral near-white texture (0.86 to 0.96) with subtle grey grime/dust baked in
        lum = nt.nodes.new('ShaderNodeRGBToBW')
        nt.links.new(img_diff.outputs['Color'], lum.inputs['Color'])

        math_var = nt.nodes.new('ShaderNodeMath')
        math_var.operation = 'MULTIPLY_ADD'
        math_var.inputs[1].default_value = 0.08
        math_var.inputs[2].default_value = 0.89
        nt.links.new(lum.outputs['Val'], math_var.inputs[0])

        comb_neutral = nt.nodes.new('ShaderNodeCombineColor')
        for ch in ('Red', 'Green', 'Blue'):
            nt.links.new(math_var.outputs['Value'], comb_neutral.inputs[ch])

        mix_col = nt.nodes.new('ShaderNodeMix')
        mix_col.data_type = 'RGBA'
        nt.links.new(comb_neutral.outputs['Color'], mix_col.inputs['A'])
        mix_col.inputs['B'].default_value = (0.76, 0.75, 0.74, 1.0)

        math_f = nt.nodes.new('ShaderNodeMath')
        math_f.operation = 'MULTIPLY'
        math_f.inputs[1].default_value = 0.24
        nt.links.new(noise.outputs['Fac'], math_f.inputs[0])
        nt.links.new(math_f.outputs['Value'], mix_col.inputs['Factor'])
        nt.links.new(mix_col.outputs['Result'], bsdf.inputs['Base Color'])

        mix_rough = nt.nodes.new('ShaderNodeMix')
        mix_rough.data_type = 'FLOAT'
        mix_rough.inputs['A'].default_value = 0.09
        mix_rough.inputs['B'].default_value = 0.19
        nt.links.new(math_f.outputs['Value'], mix_rough.inputs['Factor'])
        nt.links.new(mix_rough.outputs['Result'], bsdf.inputs['Roughness'])
        bsdf.inputs['Metallic'].default_value = 0.36

        nor_map = nt.nodes.new('ShaderNodeNormalMap')
        nor_map.inputs['Strength'].default_value = 0.03
        nt.links.new(img_nor.outputs['Color'], nor_map.inputs['Color'])
        nt.links.new(nor_map.outputs['Normal'], bsdf.inputs['Normal'])

    elif is_chrome:
        mix_col = nt.nodes.new('ShaderNodeMix')
        mix_col.data_type = 'RGBA'
        mix_col.inputs['A'].default_value = (0.88, 0.90, 0.92, 1.0)
        mix_col.inputs['B'].default_value = (0.58, 0.60, 0.63, 1.0)
        nt.links.new(sep_arm.outputs['Green'], mix_col.inputs['Factor'])
        nt.links.new(mix_col.outputs['Result'], bsdf.inputs['Base Color'])

        bsdf.inputs['Metallic'].default_value = 0.96
        math_r = nt.nodes.new('ShaderNodeMath')
        math_r.operation = 'MULTIPLY'
        math_r.inputs[1].default_value = 0.14
        nt.links.new(sep_arm.outputs['Green'], math_r.inputs[0])
        nt.links.new(math_r.outputs['Value'], bsdf.inputs['Roughness'])

        nor_map = nt.nodes.new('ShaderNodeNormalMap')
        nor_map.inputs['Strength'].default_value = 0.08
        nt.links.new(img_nor.outputs['Color'], nor_map.inputs['Color'])
        nt.links.new(nor_map.outputs['Normal'], bsdf.inputs['Normal'])

    elif is_rubber:
        mix_col = nt.nodes.new('ShaderNodeMix')
        mix_col.data_type = 'RGBA'
        mix_col.inputs['Factor'].default_value = 0.88
        nt.links.new(img_diff.outputs['Color'], mix_col.inputs['A'])
        mix_col.inputs['B'].default_value = (0.020, 0.021, 0.024, 1.0)
        nt.links.new(mix_col.outputs['Result'], bsdf.inputs['Base Color'])

        bsdf.inputs['Metallic'].default_value = 0.02
        math_r = nt.nodes.new('ShaderNodeMath')
        math_r.operation = 'MULTIPLY_ADD'
        math_r.inputs[1].default_value = 0.25
        math_r.inputs[2].default_value = 0.54
        nt.links.new(sep_arm.outputs['Green'], math_r.inputs[0])
        nt.links.new(math_r.outputs['Value'], bsdf.inputs['Roughness'])

        nor_map = nt.nodes.new('ShaderNodeNormalMap')
        nor_map.inputs['Strength'].default_value = 0.48
        nt.links.new(img_nor.outputs['Color'], nor_map.inputs['Color'])
        nt.links.new(nor_map.outputs['Normal'], bsdf.inputs['Normal'])

    elif is_interior:
        mix_col = nt.nodes.new('ShaderNodeMix')
        mix_col.data_type = 'RGBA'
        mix_col.inputs['Factor'].default_value = 0.78
        nt.links.new(img_diff.outputs['Color'], mix_col.inputs['A'])
        mix_col.inputs['B'].default_value = (0.060, 0.062, 0.066, 1.0)
        nt.links.new(mix_col.outputs['Result'], bsdf.inputs['Base Color'])

        bsdf.inputs['Metallic'].default_value = 0.04
        math_r = nt.nodes.new('ShaderNodeMath')
        math_r.operation = 'MULTIPLY_ADD'
        math_r.inputs[1].default_value = 0.24
        math_r.inputs[2].default_value = 0.44
        nt.links.new(sep_arm.outputs['Green'], math_r.inputs[0])
        nt.links.new(math_r.outputs['Value'], bsdf.inputs['Roughness'])

        nor_map = nt.nodes.new('ShaderNodeNormalMap')
        nor_map.inputs['Strength'].default_value = 0.45
        nt.links.new(img_nor.outputs['Color'], nor_map.inputs['Color'])
        nt.links.new(nor_map.outputs['Normal'], bsdf.inputs['Normal'])

    elif is_underbody:
        # Gunmetal metallic finish for alloy rim spoke pockets, brake discs, and underbody
        mix_col = nt.nodes.new('ShaderNodeMix')
        mix_col.data_type = 'RGBA'
        mix_col.inputs['Factor'].default_value = 0.88
        nt.links.new(img_diff.outputs['Color'], mix_col.inputs['A'])
        mix_col.inputs['B'].default_value = (0.075, 0.080, 0.088, 1.0)
        nt.links.new(mix_col.outputs['Result'], bsdf.inputs['Base Color'])

        bsdf.inputs['Metallic'].default_value = 0.72
        math_r = nt.nodes.new('ShaderNodeMath')
        math_r.operation = 'MULTIPLY_ADD'
        math_r.inputs[1].default_value = 0.20
        math_r.inputs[2].default_value = 0.30
        nt.links.new(sep_arm.outputs['Green'], math_r.inputs[0])
        nt.links.new(math_r.outputs['Value'], bsdf.inputs['Roughness'])

        nor_map = nt.nodes.new('ShaderNodeNormalMap')
        nor_map.inputs['Strength'].default_value = 0.35
        nt.links.new(img_nor.outputs['Color'], nor_map.inputs['Color'])
        nt.links.new(nor_map.outputs['Normal'], bsdf.inputs['Normal'])

    return mat

mat_paint_src = build_source_mat("Paint_Src", "metal_plate", scale=3.0, is_paint=True)
mat_chrome_src = build_source_mat("Chrome_Src", "metal_plate", scale=3.5, is_chrome=True)
mat_rubber_src = build_source_mat("Rubber_Src", "rubber_tiles", scale=5.0, is_rubber=True)
mat_interior_src = build_source_mat("Interior_Src", "fabric_leather_02", scale=4.0, is_interior=True)
mat_underbody_src = build_source_mat("Underbody_Src", "rusty_metal_02", scale=3.5, is_underbody=True)

# Direct contract materials: Glass and Lamps
mat_glass = bpy.data.materials.new(name="Glass")
mat_glass.blend_method = 'BLEND'
nt_g = mat_glass.node_tree
bsdf_g = nt_g.nodes.get("Principled BSDF")
bsdf_g.inputs['Base Color'].default_value = (0.34, 0.42, 0.46, 1.0)
bsdf_g.inputs['Roughness'].default_value = 0.03
bsdf_g.inputs['Metallic'].default_value = 0.05
bsdf_g.inputs['Alpha'].default_value = 0.28
if 'IOR' in bsdf_g.inputs:
    bsdf_g.inputs['IOR'].default_value = 1.48

# Lamps material with a textured 256px LED / lens atlas
# Bottom third (v=0..0.35): Warm white LED DRL & projector headlamp
# Middle third (v=0.35..0.66): Amber indicator
# Top third (v=0.66..1.0): Bright crimson red LED tail lamp ribbon & reflector
atlas_path = os.path.join(TEX_DIR, "Lamps_atlas.png")
if os.path.exists(atlas_path):
    os.remove(atlas_path)
for old_im in list(bpy.data.images):
    if "Lamps_atlas" in old_im.name:
        bpy.data.images.remove(old_im)

img_lamps = bpy.data.images.new("Lamps_atlas", width=256, height=256, alpha=False)
px_l = np.zeros((256, 256, 4), dtype=np.float32)
x_grid = np.linspace(0, 1, 256, endpoint=False)
flute = 0.84 + 0.16 * np.sin(x_grid * 2.0 * np.pi * 24.0)
for row in range(256):
    if row < 90:
        # Warm white LED DRL / projector lens
        y_rel = (row - 45.0) / 45.0
        c = np.array([0.96, 0.95, 0.90, 1.0])
    elif row < 170:
        # Amber indicator
        y_rel = (row - 127.0) / 42.0
        c = np.array([0.96, 0.50, 0.06, 1.0])
    else:
        # Ruby red LED tail light & bumper reflector
        y_rel = (row - 212.0) / 42.0
        c = np.array([0.92, 0.04, 0.03, 1.0])
    for col in range(256):
        x_rel = (col - 128.0) / 128.0
        r_dist = math.sqrt(x_rel*x_rel + y_rel*y_rel)
        ring_mod = 0.86 + 0.14 * math.cos(r_dist * math.pi * 5.0)
        f_val = flute[col] * ring_mod
        px_l[row, col, :3] = np.clip(c[:3] * f_val, 0.0, 1.0)
        px_l[row, col, 3] = 1.0

img_lamps.pixels.foreach_set(px_l.ravel())
img_lamps.filepath_raw = atlas_path
img_lamps.file_format = 'PNG'
img_lamps.save()

mat_lamps = bpy.data.materials.new(name="Lamps")
nt_l = mat_lamps.node_tree
nt_l.nodes.clear()
out_l = nt_l.nodes.new('ShaderNodeOutputMaterial')
bsdf_l = nt_l.nodes.new('ShaderNodeBsdfPrincipled')
nt_l.links.new(bsdf_l.outputs['BSDF'], out_l.inputs['Surface'])
tex_l = nt_l.nodes.new('ShaderNodeTexImage')
tex_l.image = img_lamps
nt_l.links.new(tex_l.outputs['Color'], bsdf_l.inputs['Base Color'])
nt_l.links.new(tex_l.outputs['Color'], bsdf_l.inputs['Emission Color'])
bsdf_l.inputs['Emission Strength'].default_value = 0.85
bsdf_l.inputs['Roughness'].default_value = 0.04
bsdf_l.inputs['Metallic'].default_value = 0.25
bsdf_l.inputs['Specular IOR Level'].default_value = 0.95

def set_uv_region(bm, v_min, v_max):
    uv_lay = bm.loops.layers.uv.verify()
    xs = [v.co.x for v in bm.verts]
    zs = [v.co.z for v in bm.verts]
    min_x, max_x = min(xs), max(xs)
    min_z, max_z = min(zs), max(zs)
    dx = max(1e-5, max_x - min_x)
    dz = max(1e-5, max_z - min_z)
    for f in bm.faces:
        for l in f.loops:
            u = (l.vert.co.x - min_x) / dx
            v_norm = (l.vert.co.z - min_z) / dz
            l[uv_lay].uv = (0.08 + 0.84 * u, v_min + (v_max - v_min) * (0.10 + 0.80 * v_norm))

# ==============================================================================
# 5. VEHICLE GEOMETRY (4.10m x 1.78m x 1.47m, Wheelbase 2.62m)
# ==============================================================================
WHEEL_R = 0.320
TRACK_HALF = 0.765
AXLE_Z_F = 1.310
AXLE_Z_R = -1.310

body_parts = []
lamp_parts = []

def add_body_part(obj, mat, is_lamp=False):
    assign_mat(obj, mat)
    if is_lamp:
        lamp_parts.append(obj)
    else:
        body_parts.append(obj)
    return obj

def eval_hull_x(z):
    """Returns exact half-width X of the outer painted hull at longitudinal parameter z."""
    zn = z / 2.04
    plan_taper = 1.0 - 0.038 * (zn ** 2) - 0.022 * (zn ** 4)
    if z > 1.48:
        t_front = (z - 1.48) / 0.56
        plan_taper *= (1.0 - 0.18 * (t_front ** 1.65))
    elif z < -1.48:
        t_rear = (-1.48 - z) / 0.56
        plan_taper *= (1.0 - 0.16 * (t_rear ** 1.65))
    return 0.865 * plan_taper

def eval_sculpted_z(z_param, x, y):
    """
    Maps longitudinal parameter z_param in [-2.04, +2.04] to true 3D sculpted game Z,
    giving the front nose and rear bumper authentic plan-view sweep and vertical profile!
    """
    ax = abs(x)
    if z_param > 1.15:
        tf = min(1.0, (z_param - 1.15) / (2.04 - 1.15)) ** 1.65
        dz_plan = -0.220 * ((ax / 0.68) ** 1.85)
        if y >= 0.53:
            dz_prof = -0.048 * (((y - 0.53) / 0.16) ** 1.8)
        elif y >= 0.23:
            dz_prof = -0.012 * math.sin(((0.53 - y) / 0.30) * math.pi)
        else:
            dz_prof = 0.010 * (((0.23 - y) / 0.06) ** 1.2)
        return z_param + tf * (dz_plan + dz_prof)
    elif z_param < -1.15:
        tr = min(1.0, (-1.15 - z_param) / (2.04 - 1.15)) ** 1.65
        dz_plan = 0.175 * ((ax / 0.68) ** 1.85)
        if y >= 0.66:
            # Tailgate recesses slightly forward above the rear bumper shelf (like sheet.jpg!)
            dz_prof = 0.042 * min(1.0, (y - 0.66) / 0.20)
        elif y < 0.28:
            dz_prof = 0.026 * ((0.28 - y) / 0.10)
        else:
            dz_prof = -0.008
        return z_param + tr * (dz_plan + dz_prof)
    return z_param

def eval_front_face_z(x, y=0.52):
    """Exact Z coordinate on the 3D sculpted front nose / bumper surface at (x, y)."""
    return eval_sculpted_z(2.040, x, y)

def eval_rear_face_z(x, y=0.52):
    """Exact Z coordinate on the 3D sculpted rear bumper / tailgate surface at (x, y)."""
    return eval_sculpted_z(-2.040, x, y)

def make_surface_patch(name, corners_xy, is_front, z_offset, thickness, mat, nu=10, nv=6, is_lamp=False, uv_v=(0.04, 0.30)):
    """
    Builds a curved 3D quad-grid panel (with solid beveled rim thickness) whose every vertex (u, v)
    conforms to eval_front_face_z(x, y) or eval_rear_face_z(x, y) + z_offset.
    Guarantees 100% flush seating on curved bumpers and fenders with zero poke-through or gaps!
    """
    p0, p1, p2, p3 = corners_xy
    bm = bmesh.new()
    front_grid = []
    back_grid = []
    sign_z = 1.0 if is_front else -1.0
    for iv in range(nv + 1):
        v = iv / nv
        row_f = []
        row_b = []
        for iu in range(nu + 1):
            u = iu / nu
            b0_x = (1.0 - u) * p0[0] + u * p1[0]
            b0_y = (1.0 - u) * p0[1] + u * p1[1]
            b1_x = (1.0 - u) * p3[0] + u * p2[0]
            b1_y = (1.0 - u) * p3[1] + u * p2[1]
            x = (1.0 - v) * b0_x + v * b1_x
            y = (1.0 - v) * b0_y + v * b1_y
            z_base = eval_front_face_z(x, y) if is_front else eval_rear_face_z(x, y)
            zf = z_base + sign_z * z_offset
            zb = z_base - sign_z * thickness
            row_f.append(bm.verts.new(gv(x, y, zf)))
            row_b.append(bm.verts.new(gv(x * 0.985, y, zb)))
        front_grid.append(row_f)
        back_grid.append(row_b)

    for iv in range(nv):
        for iu in range(nu):
            bm.faces.new((front_grid[iv][iu], front_grid[iv][iu+1], front_grid[iv+1][iu+1], front_grid[iv+1][iu]))
            bm.faces.new((back_grid[iv][iu+1], back_grid[iv][iu], back_grid[iv+1][iu], back_grid[iv+1][iu+1]))

    for iu in range(nu):
        bm.faces.new((front_grid[0][iu+1], front_grid[0][iu], back_grid[0][iu], back_grid[0][iu+1]))
        bm.faces.new((front_grid[nv][iu], front_grid[nv][iu+1], back_grid[nv][iu+1], back_grid[nv][iu]))
    for iv in range(nv):
        bm.faces.new((front_grid[iv][0], front_grid[iv+1][0], back_grid[iv+1][0], back_grid[iv][0]))
        bm.faces.new((front_grid[iv+1][nu], front_grid[iv][nu], back_grid[iv][nu], back_grid[iv+1][nu]))

    if is_lamp:
        set_uv_region(bm, uv_v[0], uv_v[1])
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    obj = new_mesh_obj(name, bm)
    shade_smooth_auto(obj, 40.0)
    return add_body_part(obj, mat, is_lamp=is_lamp)

def make_tube_along_game_points(name, pts_game, radius=0.008, scale_up=1.0, sides=10, mat=mat_chrome_src, is_lamp=False, uv_v=(0.04, 0.30)):
    bm = bmesh.new()
    pts = [gv(*p) for p in pts_game]
    rings = []
    n = len(pts)
    for i, p_vec in enumerate(pts):
        if i == 0:
            tan = (pts[1] - p_vec).normalized()
        elif i == n - 1:
            tan = (p_vec - pts[-2]).normalized()
        else:
            tan = (pts[i+1] - pts[i-1]).normalized()
        up = Vector((0, 0, 1))
        if abs(tan.dot(up)) > 0.95:
            up = Vector((1, 0, 0))
        side_v = tan.cross(up).normalized()
        up_v = side_v.cross(tan).normalized()

        ring = []
        for s in range(sides):
            ang = 2.0 * math.pi * s / sides
            offset = side_v * (math.cos(ang) * radius) + up_v * (math.sin(ang) * radius * scale_up)
            ring.append(bm.verts.new(p_vec + offset))
        rings.append(ring)

    for i in range(n - 1):
        r0 = rings[i]
        r1 = rings[i+1]
        for s in range(sides):
            sn = (s + 1) % sides
            bm.faces.new((r0[s], r0[sn], r1[sn], r1[s]))

    for end_i, flip in ((0, True), (-1, False)):
        r = rings[end_i]
        c = bm.verts.new(pts[end_i])
        for s in range(sides):
            sn = (s + 1) % sides
            if flip:
                bm.faces.new((r[sn], r[s], c))
            else:
                bm.faces.new((r[s], r[sn], c))

    if is_lamp:
        set_uv_region(bm, uv_v[0], uv_v[1])
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    obj = new_mesh_obj(name, bm)
    shade_smooth_auto(obj, 40.0)
    return add_body_part(obj, mat, is_lamp=is_lamp)

def make_box_game(name, center_game, half_extents_game, mat, bevel_w=0.004, rot_x_deg=0.0, rot_y_deg=0.0, is_lamp=False, uv_v=(0.04, 0.30)):
    bm = bmesh.new()
    bmesh.ops.create_cube(bm, size=2.0)
    hx, hy, hz = half_extents_game
    rx = math.radians(rot_x_deg)
    ry = math.radians(rot_y_deg)
    cx, sx = math.cos(rx), math.sin(rx)
    cy, sy = math.cos(ry), math.sin(ry)
    for v in bm.verts:
        gx = v.co.x * hx
        gy = v.co.y * hy
        gz = v.co.z * hz
        gy1 = gy * cx - gz * sx
        gz1 = gy * sx + gz * cx
        gx2 = gx * cy + gz1 * sy
        gz2 = -gx * sy + gz1 * cy
        v.co = gv(center_game[0] + gx2, center_game[1] + gy1, center_game[2] + gz2)
    if is_lamp:
        set_uv_region(bm, uv_v[0], uv_v[1])
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    obj = new_mesh_obj(name, bm)
    if bevel_w > 0:
        add_bevel(obj, bevel_w, 2)
    shade_smooth_auto(obj, 38.0)
    return add_body_part(obj, mat, is_lamp=is_lamp)

# ------------------------------------------------------------------------------
# 5A. MAIN PAINTED HATCHBACK BODY SHELL WITH 3D SCULPTED NOSE/TAIL & CABIN WELL
# ------------------------------------------------------------------------------
def eval_lower_body_section(z):
    # z in [-2.04, +2.04]
    zn = z / 2.04
    base_x = eval_hull_x(z)
    plan_taper = base_x / 0.865

    arch_f = math.exp(-((z - AXLE_Z_F) / 0.40) ** 2)
    arch_r = math.exp(-((z - AXLE_Z_R) / 0.40) ** 2)
    arch_flare = 0.020 * max(arch_f, arch_r)

    dz_f = abs(z - AXLE_Z_F)
    dz_r = abs(z - AXLE_Z_R)
    cut_y = 0.0
    for dz in (dz_f, dz_r):
        if dz < 0.375:
            h_arc = math.sqrt(max(0.0, 0.375**2 - dz**2))
            cut_y = max(cut_y, WHEEL_R + h_arc * 0.96)

    y_bot = 0.180
    if z < -1.50:
        y_bot += 0.065 * ((-1.50 - z) / 0.54) ** 1.5

    y_sill = max(y_bot + 0.024, cut_y)
    y_flank_low = max(0.35 + 0.025 * (1.0 - zn), y_sill + 0.032)

    # Dynamic Wedge Beltline rising from front fender (0.860) to C-pillar base (0.995 at Z=-1.80)
    t_long = min(1.0, max(0.0, (1.85 - z) / 3.65))
    y_belt = 0.858 + 0.138 * t_long
    if z > 1.30:
        t_f = (z - 1.30) / 0.74
        y_belt -= 0.215 * (t_f ** 1.42)
    elif z < -1.80:
        t_r = (-1.80 - z) / 0.24
        y_belt -= 0.075 * (t_r ** 1.35)

    y_waist = y_belt + 0.025

    x_shoulder = base_x + arch_flare * 0.50
    x_flank_low = 0.854 * plan_taper + arch_flare * 0.90
    x_sill = (0.808 if cut_y < 0.25 else 0.848) * plan_taper + arch_flare * 0.80
    x_rocker_in = 0.650 * plan_taper
    x_waist = 0.785 * plan_taper
    x_sill_in = 0.715 * plan_taper
    x_deck_in = 0.555 * plan_taper

    if z >= 0.74:
        # Smooth clamshell hood sloping down to front grille header (Y=0.665 at Z=+2.04)
        t_hood = min(1.0, (z - 0.74) / (2.04 - 0.74))
        y_center = 0.922 - 0.255 * (t_hood ** 1.32)
        y_sill_in = y_waist
        y_inner = y_center - 0.008
    elif z <= -1.80:
        # Fastback hatchback tailgate directly below rear window (Z from -1.80 to -2.04)
        t_tail = min(1.0, (-1.80 - z) / (2.04 - 1.80))
        y_center = 0.995 - 0.145 * (t_tail ** 1.30)
        y_sill_in = y_waist
        y_inner = y_center - 0.006
    else:
        # Open cabin zone (-1.80 < z < +0.74)
        d_edge = min(z - (-1.80), 0.74 - z)
        blend = min(1.0, d_edge / 0.08)
        y_cabin_well = 0.36
        y_sill_in = y_waist
        y_inner = (1.0 - blend) * 0.935 + blend * (y_cabin_well + 0.02)
        y_center = (1.0 - blend) * 0.945 + blend * y_cabin_well

    return [
        (0.0, y_bot),
        (x_rocker_in, y_bot),
        (x_sill, y_sill),
        (x_flank_low, y_flank_low),
        (x_shoulder, y_belt),
        (x_waist, y_waist),
        (x_sill_in, y_sill_in),
        (x_deck_in, y_inner),
        (0.0, y_center),
    ]

def build_lower_body_and_roof():
    bm = bmesh.new()
    N_Z = 116
    z_list = [-2.040 + 4.080 * (i / N_Z) for i in range(N_Z + 1)]

    rings = []
    for z in z_list:
        prof = eval_lower_body_section(z)
        y0 = prof[0][1]
        ring_verts = [bm.verts.new(gv(0.0, y0, eval_sculpted_z(z, 0.0, y0)))]
        for x, y in prof[1:8]:
            ring_verts.append(bm.verts.new(gv(x, y, eval_sculpted_z(z, x, y))))
        y8 = prof[8][1]
        ring_verts.append(bm.verts.new(gv(0.0, y8, eval_sculpted_z(z, 0.0, y8))))
        for x, y in reversed(prof[1:8]):
            ring_verts.append(bm.verts.new(gv(-x, y, eval_sculpted_z(z, -x, y))))
        rings.append(ring_verts)

    bm.verts.ensure_lookup_table()
    ring_len = len(rings[0]) # 16 verts: 0 is bottom center, 1..7 left, 8 top center, 9..15 right

    for i in range(len(rings) - 1):
        r0 = rings[i]
        r1 = rings[i + 1]
        for j in range(ring_len):
            jn = (j + 1) % ring_len
            bm.faces.new((r0[j], r0[jn], r1[jn], r1[j]))

    # Cap rear (end_idx=0) and front (end_idx=-1) by bridging left-to-right across centerline
    # with subdivided vertical strips so the nose and tail follow eval_sculpted_z(+-2.040, x, y) smoothly!
    for end_idx, z_cap_param, is_front_cap in ((0, -2.040, False), (-1, 2.040, True)):
        ring = rings[end_idx]
        # ring[0] is bottom center; ring[8] is top center;
        # pairs (1,15), (2,14), (3,13), (4,12), (5,11), (6,10), (7,9) span from bottom to top!
        # Add centerline vertices for levels 1..7 so the cap follows x=0 curvature cleanly:
        mid_verts = [ring[0]]
        for k in range(1, 8):
            v_l = ring[k]
            y_k = v_l.co.z # Blender Z is Game Y!
            z_k = eval_sculpted_z(z_cap_param, 0.0, y_k)
            mid_verts.append(bm.verts.new(gv(0.0, y_k, z_k)))
        mid_verts.append(ring[8])

        for k in range(8):
            m0 = mid_verts[k]
            m1 = mid_verts[k + 1]
            l0 = ring[k]
            l1 = ring[k + 1]
            r0 = ring[0] if k == 0 else ring[16 - k]
            r1 = ring[8] if k + 1 == 8 else ring[16 - (k + 1)]
            if is_front_cap:
                if k == 0:
                    bm.faces.new((m0, l1, m1))
                    bm.faces.new((m0, m1, r1))
                elif k == 7:
                    bm.faces.new((m0, l0, m1))
                    bm.faces.new((m0, m1, r0))
                else:
                    bm.faces.new((m0, l0, l1, m1))
                    bm.faces.new((m0, m1, r1, r0))
            else:
                if k == 0:
                    bm.faces.new((m0, m1, l1))
                    bm.faces.new((m0, r1, m1))
                elif k == 7:
                    bm.faces.new((m0, m1, l0))
                    bm.faces.new((m0, r0, m1))
                else:
                    bm.faces.new((m0, m1, l1, l0))
                    bm.faces.new((m0, r0, r1, m1))

    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    obj_hull = new_mesh_obj("Body_LowerHull", bm)
    shade_smooth_auto(obj_hull, 42.0)
    add_body_part(obj_hull, mat_paint_src)

    # 2. Sculpted Painted Roof Crown & Integrated Rear Roof Spoiler (Z from -1.46 to +0.16, crowning at Y = 1.470m)
    bm_roof = bmesh.new()
    nz_r = 28
    nx_r = 22
    top_grid = []
    bot_grid = []
    for iz in range(nz_r + 1):
        v = iz / nz_r
        z_base = -1.46 + 1.62 * v
        y_long = 1.470 - 0.130 * ((v - 0.62) ** 2)
        if v < 0.18:
            # Integrated aerodynamic hatchback roof spoiler lip kicking gently at the rear!
            y_long += 0.018 * ((0.18 - v) / 0.18)
        half_w = 0.565 + 0.035 * math.sin(v * math.pi) - 0.025 * (1.0 - v)
        row_t = []
        row_b = []
        for ix in range(nx_r + 1):
            u = -1.0 + 2.0 * (ix / nx_r)
            x = u * half_w
            arch_x = 1.0 - (u ** 2)
            edge_drop = 0.032 * (u ** 4)
            y_top = y_long - 0.020 * (1.0 - arch_x) - edge_drop
            z_curv = z_base + (0.022 if v > 0.5 else -0.022) * (1.0 - u**2)
            row_t.append(bm_roof.verts.new(gv(x, y_top, z_curv)))
            row_b.append(bm_roof.verts.new(gv(x * 0.96, y_top - 0.024, z_curv * 0.98)))
        top_grid.append(row_t)
        bot_grid.append(row_b)

    for iz in range(nz_r):
        for ix in range(nx_r):
            bm_roof.faces.new((top_grid[iz][ix], top_grid[iz][ix+1], top_grid[iz+1][ix+1], top_grid[iz+1][ix]))
            bm_roof.faces.new((bot_grid[iz][ix+1], bot_grid[iz][ix], bot_grid[iz+1][ix], bot_grid[iz+1][ix+1]))

    for ix in range(nx_r):
        bm_roof.faces.new((top_grid[0][ix+1], top_grid[0][ix], bot_grid[0][ix], bot_grid[0][ix+1]))
        bm_roof.faces.new((top_grid[nz_r][ix], top_grid[nz_r][ix+1], bot_grid[nz_r][ix+1], bot_grid[nz_r][ix]))
    for iz in range(nz_r):
        bm_roof.faces.new((top_grid[iz][0], top_grid[iz+1][0], bot_grid[iz+1][0], bot_grid[iz][0]))
        bm_roof.faces.new((top_grid[iz+1][nx_r], top_grid[iz][nx_r], bot_grid[iz][nx_r], bot_grid[iz+1][nx_r]))

    bmesh.ops.recalc_face_normals(bm_roof, faces=bm_roof.faces)
    obj_roof = new_mesh_obj("Body_RoofPanel", bm_roof)
    shade_smooth_auto(obj_roof, 42.0)
    add_body_part(obj_roof, mat_paint_src)

    # 3. Panoramic Black "GLASS ROOF PANEL" (explicitly called out on sheet.jpg!)
    bm_pg = bmesh.new()
    nz_p = 20
    nx_p = 14
    pg_verts = []
    for iz in range(nz_p + 1):
        v = 0.24 + 0.72 * (iz / nz_p)
        z_base = -1.46 + 1.62 * v
        y_long = 1.470 - 0.130 * ((v - 0.62) ** 2)
        half_w_glass = (0.565 + 0.035 * math.sin(v * math.pi) - 0.025 * (1.0 - v)) * 0.78
        row_p = []
        for ix in range(nx_p + 1):
            u = -1.0 + 2.0 * (ix / nx_p)
            x = u * half_w_glass
            arch_x = 1.0 - (u * 0.78) ** 2
            y_top = y_long - 0.020 * (1.0 - arch_x) + 0.0025
            z_curv = z_base + (0.022 if v > 0.5 else -0.022) * (1.0 - (u * 0.78)**2)
            row_p.append(bm_pg.verts.new(gv(x, y_top, z_curv)))
        pg_verts.append(row_p)

    for iz in range(nz_p):
        for ix in range(nx_p):
            bm_pg.faces.new((pg_verts[iz][ix], pg_verts[iz][ix+1], pg_verts[iz+1][ix+1], pg_verts[iz+1][ix]))

    bmesh.ops.recalc_face_normals(bm_pg, faces=bm_pg.faces)
    bm_pg2 = bm_pg.copy()
    obj_pg = new_mesh_obj("Body_GlassRoofPanel", bm_pg)
    shade_smooth_auto(obj_pg, 42.0)
    add_body_part(obj_pg, mat_rubber_src)

    for v in bm_pg2.verts:
        v.co.z += 0.002
    obj_pg_glass = new_mesh_obj("Body_GlassRoofLens", bm_pg2)
    shade_smooth_auto(obj_pg_glass, 42.0)
    add_body_part(obj_pg_glass, mat_glass)

build_lower_body_and_roof()

# ------------------------------------------------------------------------------
# 5B. SCULPTED CANTRAIL ROOF ARCHES, A-PILLARS, B-PILLARS, AND WIDE HATCHBACK C-PILLARS
# ------------------------------------------------------------------------------
def eval_window_top_arch(z):
    """
    Returns (x_abs, y) of the upper edge of the side window DLO opening at longitudinal z in [-1.40, +0.72].
    Stays below the roof outer edge (Y <= 1.365) so a sturdy painted cantrail arch sits above the windows!
    """
    if z >= 0.14:
        # A-pillar slope from (z=0.72, y=0.900) up to (z=0.14, y=1.350)
        t = (0.72 - z) / (0.72 - 0.14)
        y = 0.900 + 0.450 * (t ** 0.92)
    elif z >= -1.16:
        # Gently arched roof window line peaking at y=1.368 near z=-0.35, sloping to 1.305 at z=-1.16
        u = (0.14 - z) / (0.14 - (-1.16)) # 0 at front header, 1 at rear C-pillar top corner
        y = (1.0 - u) * 1.350 + u * 1.305 + 0.032 * math.sin(u * math.pi)
    else:
        # C-pillar front window edge curving down from (z=-1.16, y=1.305) to beltline (z=-1.40, y=0.982)
        t = (-1.16 - z) / (-1.16 - (-1.40))
        y = 1.305 - 0.323 * (t ** 1.25)

    # Tumblehome X coordinate as a function of height y and longitudinal position z
    y_belt = 0.858 + 0.138 * min(1.0, max(0.0, (1.85 - z) / 3.65))
    x_belt = eval_hull_x(z) * (0.785 / 0.865)
    t_h = min(1.0, max(0.0, (y - y_belt) / 0.50))
    x_abs = x_belt * (1.0 - 0.255 * (t_h ** 1.12))
    return x_abs, y

def build_pillars_and_cantrails():
    for side_sign, side_tag in ((1.0, "L"), (-1.0, "R")):
        # 1. Continuous Painted Cantrail Roof Arch sweeping from A-pillar base (Z=+0.74) over doors to C-pillar top (Z=-1.24)
        cant_pts = []
        for z_c in (0.74, 0.54, 0.34, 0.14, -0.10, -0.38, -0.66, -0.94, -1.20):
            x_w, y_w = eval_window_top_arch(min(0.72, max(-1.16, z_c)))
            # Cantrail sits just above the window top arch, bridging cleanly into Body_RoofPanel!
            cant_pts.append((side_sign * (x_w - 0.008), y_w + 0.032, z_c))
        make_tube_along_game_points(
            f"Body_CantrailArch_{side_tag}",
            cant_pts,
            radius=0.032,
            scale_up=1.10,
            sides=12,
            mat=mat_paint_src
        )

        # 2. Gloss-black B-Pillar between front and rear side windows (Z = -0.16)
        make_tube_along_game_points(
            f"Body_BPillar_{side_tag}",
            [
                (side_sign * 0.758, 0.910, -0.16),
                (side_sign * 0.695, 1.075, -0.17),
                (side_sign * 0.635, 1.235, -0.18),
                (side_sign * 0.592, 1.372, -0.19),
            ],
            radius=0.022,
            scale_up=1.65,
            sides=10,
            mat=mat_rubber_src
        )

        # 3. Broad, Sculpted Hatchback C-Pillar Panel (lofted between rear window edge and rear tailgate glass edge!)
        # Front edge of C-pillar: from (Z=-1.40, Y=0.982) up to (Z=-1.16, Y=1.365)
        # Rear edge of C-pillar: from (Z=-1.80, Y=0.995) up to (Z=-1.38, Y=1.415)
        bm_cp = bmesh.new()
        nv_cp = 10
        nu_cp = 6
        cp_out = []
        cp_in = []
        for iv in range(nv_cp + 1):
            v = iv / nv_cp # 0 = bottom beltline, 1 = top roof rail
            z_front = (1.0 - v) * (-1.40) + v * (-1.16) - 0.035 * math.sin(v * math.pi)
            z_rear  = (1.0 - v) * (-1.80) + v * (-1.38)
            row_o = []
            row_i = []
            for iu in range(nu_cp + 1):
                u = iu / nu_cp # 0 = front window border, 1 = rear tailgate border
                z = (1.0 - u) * z_front + u * z_rear
                y_bot = 0.982 + 0.013 * u
                y_top = 1.368 + 0.044 * u
                y = (1.0 - v) * y_bot + v * y_top
                x_base = eval_hull_x(z) * (0.785 / 0.865)
                x_top  = 0.575 - 0.045 * u
                # Smooth convex crown across the C-pillar surface
                crown = 0.014 * math.sin(u * math.pi) * math.sin(v * math.pi)
                x_abs = (1.0 - (v ** 1.08)) * x_base + (v ** 1.08) * x_top + crown
                row_o.append(bm_cp.verts.new(gv(side_sign * x_abs, y, z)))
                row_i.append(bm_cp.verts.new(gv(side_sign * (x_abs - 0.032), y - 0.005, z)))
            cp_out.append(row_o)
            cp_in.append(row_i)

        for iv in range(nv_cp):
            for iu in range(nu_cp):
                if side_sign > 0:
                    bm_cp.faces.new((cp_out[iv][iu], cp_out[iv+1][iu], cp_out[iv+1][iu+1], cp_out[iv][iu+1]))
                    bm_cp.faces.new((cp_in[iv][iu+1], cp_in[iv+1][iu+1], cp_in[iv+1][iu], cp_in[iv][iu]))
                else:
                    bm_cp.faces.new((cp_out[iv][iu+1], cp_out[iv+1][iu+1], cp_out[iv+1][iu], cp_out[iv][iu]))
                    bm_cp.faces.new((cp_in[iv][iu], cp_in[iv+1][iu], cp_in[iv+1][iu+1], cp_in[iv][iu+1]))

        for iu in range(nu_cp):
            bm_cp.faces.new((cp_out[0][iu], cp_out[0][iu+1], cp_in[0][iu+1], cp_in[0][iu]))
            bm_cp.faces.new((cp_out[nv_cp][iu+1], cp_out[nv_cp][iu], cp_in[nv_cp][iu], cp_in[nv_cp][iu+1]))
        for iv in range(nv_cp):
            bm_cp.faces.new((cp_out[iv+1][0], cp_out[iv][0], cp_in[iv][0], cp_in[iv+1][0]))
            bm_cp.faces.new((cp_out[iv][nu_cp], cp_out[iv+1][nu_cp], cp_in[iv+1][nu_cp], cp_in[iv][nu_cp]))

        bmesh.ops.recalc_face_normals(bm_cp, faces=bm_cp.faces)
        obj_cp = new_mesh_obj(f"Body_CPillarPanel_{side_tag}", bm_cp)
        shade_smooth_auto(obj_cp, 42.0)
        add_body_part(obj_cp, mat_paint_src)

build_pillars_and_cantrails()

# ------------------------------------------------------------------------------
# 5C. WHEEL ARCH FLARES & DARK INNER WHEEL WELLS
# ------------------------------------------------------------------------------
def build_wheel_arches_and_wells():
    bm_lip = bmesh.new()
    bm_well = bmesh.new()
    n_seg = 32
    r_inner = 0.360
    r_outer = 0.396

    for side in (-1.0, 1.0):
        for z_ax in (AXLE_Z_F, AXLE_Z_R):
            x_base = eval_hull_x(z_ax)
            x_outer = side * (x_base + 0.015)
            x_lip_in = side * (x_base - 0.025)
            x_well_deep = side * 0.510

            lip_ring_1, lip_ring_2, lip_ring_3 = [], [], []
            well_ring_out, well_ring_in = [], []

            for i in range(n_seg + 1):
                ang = -0.06 * math.pi + 1.12 * math.pi * (i / n_seg)
                cz = math.cos(ang)
                sy = math.sin(ang)
                z1 = z_ax + cz * r_inner
                y1 = max(0.185, WHEEL_R + sy * r_inner)
                z2 = z_ax + cz * r_outer
                y2 = max(0.185, WHEEL_R + sy * r_outer)

                v_l1 = bm_lip.verts.new(gv(x_lip_in, y1, z1))
                v_l2 = bm_lip.verts.new(gv(x_outer, y1 + 0.008, z1))
                v_l3 = bm_lip.verts.new(gv(x_outer - side * 0.006, y2, z2))
                lip_ring_1.append(v_l1)
                lip_ring_2.append(v_l2)
                lip_ring_3.append(v_l3)

                vw_o = bm_well.verts.new(gv(x_lip_in, y1, z1))
                vw_i = bm_well.verts.new(gv(x_well_deep, y1, z1))
                well_ring_out.append(vw_o)
                well_ring_in.append(vw_i)

            for i in range(n_seg):
                if side > 0:
                    bm_lip.faces.new((lip_ring_1[i], lip_ring_2[i], lip_ring_2[i+1], lip_ring_1[i+1]))
                    bm_lip.faces.new((lip_ring_2[i], lip_ring_3[i], lip_ring_3[i+1], lip_ring_2[i+1]))
                    bm_well.faces.new((well_ring_in[i], well_ring_out[i], well_ring_out[i+1], well_ring_in[i+1]))
                else:
                    bm_lip.faces.new((lip_ring_2[i], lip_ring_1[i], lip_ring_1[i+1], lip_ring_2[i+1]))
                    bm_lip.faces.new((lip_ring_3[i], lip_ring_2[i], lip_ring_2[i+1], lip_ring_3[i+1]))
                    bm_well.faces.new((well_ring_out[i], well_ring_in[i], well_ring_in[i+1], well_ring_out[i+1]))

            c_well = bm_well.verts.new(gv(x_well_deep, WHEEL_R, z_ax))
            for i in range(n_seg):
                if side > 0:
                    bm_well.faces.new((well_ring_in[i+1], well_ring_in[i], c_well))
                else:
                    bm_well.faces.new((well_ring_in[i], well_ring_in[i+1], c_well))

    bmesh.ops.recalc_face_normals(bm_lip, faces=bm_lip.faces)
    bmesh.ops.recalc_face_normals(bm_well, faces=bm_well.faces)
    obj_lip = new_mesh_obj("Body_ArchLips", bm_lip)
    shade_smooth_auto(obj_lip, 40.0)
    add_body_part(obj_lip, mat_paint_src)

    obj_well = new_mesh_obj("Body_WheelWells", bm_well)
    shade_smooth_auto(obj_well, 45.0)
    add_body_part(obj_well, mat_underbody_src)

build_wheel_arches_and_wells()

# ------------------------------------------------------------------------------
# 5D. CLEAR GLASS WINDOWS & CHROME WINDOW DAYLIGHT OPENING (DLO) SURROUND TRIM
# ------------------------------------------------------------------------------
def build_window_with_frame(name, corners_game, normal_game, frame_radius=0.0085, glass_inset=0.003, subdiv_u=12, subdiv_v=8, bulge=0.015, frame_mat=mat_chrome_src):
    p0, p1, p2, p3 = [gv(*c) for c in corners_game]
    n_vec = gv(*normal_game).normalized()

    bm_g = bmesh.new()
    grid_verts = []
    for iv in range(subdiv_v + 1):
        v = iv / subdiv_v
        row = []
        for iu in range(subdiv_u + 1):
            u = iu / subdiv_u
            b0 = (1 - u) * p0 + u * p1
            b1 = (1 - u) * p3 + u * p2
            pt = (1 - v) * b0 + v * b1
            arch_u = 4.0 * u * (1.0 - u)
            arch_v = 4.0 * v * (1.0 - v)
            pt += n_vec * (bulge * (0.65 * arch_u + 0.35 * arch_v) - glass_inset)
            row.append(bm_g.verts.new(pt))
        grid_verts.append(row)

    for iv in range(subdiv_v):
        for iu in range(subdiv_u):
            bm_g.faces.new((grid_verts[iv][iu], grid_verts[iv][iu+1], grid_verts[iv+1][iu+1], grid_verts[iv+1][iu]))

    raw_pts = []
    for iu in range(subdiv_u):
        raw_pts.append(grid_verts[0][iu].co.copy() + n_vec * glass_inset)
    for iv in range(subdiv_v):
        raw_pts.append(grid_verts[iv][subdiv_u].co.copy() + n_vec * glass_inset)
    for iu in range(subdiv_u, 0, -1):
        raw_pts.append(grid_verts[subdiv_v][iu].co.copy() + n_vec * glass_inset)
    for iv in range(subdiv_v, 0, -1):
        raw_pts.append(grid_verts[iv][0].co.copy() + n_vec * glass_inset)

    bmesh.ops.recalc_face_normals(bm_g, faces=bm_g.faces)
    obj_g = new_mesh_obj(f"{name}_Glass", bm_g)
    shade_smooth_auto(obj_g, 45.0)
    add_body_part(obj_g, mat_glass)

    nb = len(raw_pts)
    smooth_pts = []
    for i in range(nb):
        p_prev = raw_pts[(i - 1) % nb]
        p_curr = raw_pts[i]
        p_next = raw_pts[(i + 1) % nb]
        smooth_pts.append(0.20 * p_prev + 0.60 * p_curr + 0.20 * p_next)

    bm_f = bmesh.new()
    sides = 10
    rings_f = []
    for i in range(nb):
        p_prev = smooth_pts[(i - 1) % nb]
        p_curr = smooth_pts[i]
        p_next = smooth_pts[(i + 1) % nb]
        tan = (p_next - p_prev).normalized()
        bi = tan.cross(n_vec).normalized()
        r_v = []
        for s in range(sides):
            ang = 2.0 * math.pi * s / sides
            off = bi * (math.cos(ang) * frame_radius * 1.25) + n_vec * (math.sin(ang) * frame_radius * 0.85)
            r_v.append(bm_f.verts.new(p_curr + off))
        rings_f.append(r_v)

    for i in range(nb):
        r0 = rings_f[i]
        r1 = rings_f[(i + 1) % nb]
        for s in range(sides):
            sn = (s + 1) % sides
            bm_f.faces.new((r0[s], r1[s], r1[sn], r0[sn]))

    bmesh.ops.recalc_face_normals(bm_f, faces=bm_f.faces)
    obj_f = new_mesh_obj(f"{name}_Frame", bm_f)
    shade_smooth_auto(obj_f, 42.0)
    add_body_part(obj_f, frame_mat)

# Front Windshield (raked from Z=+0.73, Y=0.900 to Z=+0.15, Y=1.395)
build_window_with_frame(
    "Windshield",
    corners_game=[
        (0.705, 0.900, 0.725),
        (-0.705, 0.900, 0.725),
        (-0.555, 1.395, 0.155),
        (0.555, 1.395, 0.155),
    ],
    normal_game=(0.0, 0.65, 0.76),
    frame_radius=0.009, glass_inset=0.003, subdiv_u=14, subdiv_v=8, bulge=0.026, frame_mat=mat_rubber_src
)

# Rear Tailgate Window (raked between C-pillars from Z=-1.80, Y=0.995 to Z=-1.39, Y=1.410)
build_window_with_frame(
    "RearWindow",
    corners_game=[
        (-0.580, 0.995, -1.795),
        (0.580, 0.995, -1.795),
        (0.505, 1.408, -1.385),
        (-0.505, 1.408, -1.385),
    ],
    normal_game=(0.0, 0.72, -0.69),
    frame_radius=0.009, glass_inset=0.003, subdiv_u=14, subdiv_v=8, bulge=0.022, frame_mat=mat_rubber_src
)

# Arched Side Windows + Signature Full Chrome Window Surround Moulding (matching sheet.jpg!)
def build_arched_side_windows():
    for side_sign, side_tag in ((1.0, "L"), (-1.0, "R")):
        for wname, z_start, z_end in (("Front", 0.70, -0.145), ("Rear", -0.185, -1.395)):
            bm_sg = bmesh.new()
            nu_s = 14
            nv_s = 8
            grid_s = []
            for iv in range(nv_s + 1):
                v = iv / nv_s
                row_s = []
                for iu in range(nu_s + 1):
                    u = iu / nu_s
                    z = (1.0 - u) * z_start + u * z_end
                    y_bot = 0.858 + 0.138 * min(1.0, max(0.0, (1.85 - z) / 3.65)) + 0.026
                    x_bot = eval_hull_x(z) * (0.785 / 0.865) - 0.004
                    x_top, y_top = eval_window_top_arch(z)
                    y = (1.0 - v) * y_bot + v * y_top
                    # Slight outward barrel curvature across window height
                    bulge_x = 0.008 * 4.0 * v * (1.0 - v)
                    x_abs = (1.0 - (v ** 1.08)) * x_bot + (v ** 1.08) * (x_top - 0.004) + bulge_x
                    row_s.append(bm_sg.verts.new(gv(side_sign * x_abs, y, z)))
                grid_s.append(row_s)

            for iv in range(nv_s):
                for iu in range(nu_s):
                    if side_sign > 0:
                        bm_sg.faces.new((grid_s[iv][iu], grid_s[iv][iu+1], grid_s[iv+1][iu+1], grid_s[iv+1][iu]))
                    else:
                        bm_sg.faces.new((grid_s[iv][iu+1], grid_s[iv][iu], grid_s[iv+1][iu], grid_s[iv+1][iu+1]))

            bmesh.ops.recalc_face_normals(bm_sg, faces=bm_sg.faces)
            obj_sg = new_mesh_obj(f"SideWin_{wname}_{side_tag}_Glass", bm_sg)
            shade_smooth_auto(obj_sg, 42.0)
            add_body_part(obj_sg, mat_glass)

        # Continuous Chrome Daylight Opening (DLO) Window Surround Strip hugging eval_window_top_arch(z)!
        dlo_pts = []
        # Bottom beltline sweep from front A-pillar corner (Z=+0.71) back to C-pillar Hofmeister corner (Z=-1.40)
        for z_b in (0.71, 0.35, -0.05, -0.45, -0.85, -1.20, -1.40):
            y_b = 0.858 + 0.138 * min(1.0, max(0.0, (1.85 - z_b) / 3.65)) + 0.025
            x_b = eval_hull_x(z_b) * (0.785 / 0.865) + 0.003
            dlo_pts.append((side_sign * x_b, y_b, z_b))
        # Upper arch sweep from C-pillar corner (Z=-1.35) up to roof arch and down A-pillar to Z=+0.71
        for z_t in (-1.32, -1.22, -1.14, -0.85, -0.50, -0.15, 0.14, 0.42, 0.71):
            x_t, y_t = eval_window_top_arch(z_t)
            dlo_pts.append((side_sign * (x_t + 0.003), y_t, z_t))
        make_tube_along_game_points(f"ChromeWindowSurround_{side_tag}", dlo_pts, radius=0.0080, scale_up=1.15, sides=10, mat=mat_chrome_src)

build_arched_side_windows()

# ------------------------------------------------------------------------------
# 5E. EXTERIOR SCULPTING DETAILS: HOOD CREASES, HANDLES, MIRRORS, WIPERS
# ------------------------------------------------------------------------------
def build_exterior_trims_and_fittings():
    for side in (-1.0, 1.0):
        tag = "L" if side > 0 else "R"

        # Subtle Hood Power-Dome / Side Character Rib (visible on sheet.jpg 3/4 view!)
        hood_pts = []
        for hz in (0.76, 1.05, 1.35, 1.65, 1.88):
            hx = side * (0.44 - 0.06 * ((hz - 0.76) / 1.12))
            t_hood = (hz - 0.74) / (2.04 - 0.74)
            hy = 0.922 - 0.255 * (t_hood ** 1.32) - 0.006
            hz_real = eval_sculpted_z(hz, hx, hy) + 0.003
            hood_pts.append((hx, hy, hz_real))
        make_tube_along_game_points(f"HoodCrease_{tag}", hood_pts, radius=0.0075, scale_up=0.45, sides=8, mat=mat_paint_src)

        # Sharp shoulder character line & lower door dynamic crease (exact hull X + 0.003)
        sh_pts = []
        for sz in (1.05, 0.65, 0.25, -0.15, -0.55, -0.95, -1.35):
            sx_val = side * (eval_hull_x(sz) + 0.003)
            sy_val = 0.840 + 0.115 * ((1.05 - sz) / 2.40)
            sh_pts.append((sx_val, sy_val, sz))
        make_tube_along_game_points(f"ShoulderCrease_{tag}", sh_pts, radius=0.0080, scale_up=0.55, sides=8, mat=mat_paint_src)

        low_pts = []
        for lz in (0.82, 0.42, 0.02, -0.38, -0.78):
            lx_val = side * (eval_hull_x(lz) * (0.854 / 0.865) + 0.004)
            ly_val = 0.340 + 0.065 * ((0.82 - lz) / 1.60)
            low_pts.append((lx_val, ly_val, lz))
        make_tube_along_game_points(f"LowerDoorCrease_{tag}", low_pts, radius=0.0095, scale_up=0.65, sides=8, mat=mat_paint_src)

        # 4 Body-coloured Pull Door Handles with Chrome Top Strip ("DOOR HANDLES" on sheet.jpg)
        for hz, hy, hname in ((0.02, 0.845, "Front"), (-0.78, 0.885, "Rear")):
            hx_surf = eval_hull_x(hz)
            make_box_game(f"DoorHandlePocket_{hname}_{tag}", (side * (hx_surf + 0.001), hy, hz), (0.005, 0.018, 0.068), mat_rubber_src, bevel_w=0.002)
            make_tube_along_game_points(f"DoorHandleBar_{hname}_{tag}", [
                (side * (hx_surf + 0.002), hy, hz + 0.085),
                (side * (hx_surf + 0.022), hy, hz + 0.050),
                (side * (hx_surf + 0.022), hy, hz - 0.050),
                (side * (hx_surf + 0.002), hy, hz - 0.085),
            ], radius=0.0105, scale_up=1.25, sides=10, mat=mat_paint_src)
            make_tube_along_game_points(f"DoorHandleChrome_{hname}_{tag}", [
                (side * (hx_surf + 0.025), hy + 0.004, hz + 0.048),
                (side * (hx_surf + 0.025), hy + 0.004, hz - 0.048),
            ], radius=0.004, scale_up=1.1, sides=8, mat=mat_chrome_src)

        # Modern Aerodynamic Door Mirror mounted on front door panel ("MIRRORS" on sheet.jpg)
        make_tube_along_game_points(f"MirrorStalk_{tag}", [
            (side * 0.758, 0.875, 0.520),
            (side * 0.825, 0.905, 0.485),
        ], radius=0.015, scale_up=0.85, sides=10, mat=mat_rubber_src)

        bm_mir = bmesh.new()
        bmesh.ops.create_uvsphere(bm_mir, u_segments=20, v_segments=12, radius=1.0)
        for v in bm_mir.verts:
            gx = side * 0.848 + v.co.x * 0.068
            gy = 0.928 + v.co.y * 0.044
            gz = 0.470 + v.co.z * 0.048
            v.co = gv(gx, gy, gz)
        bmesh.ops.recalc_face_normals(bm_mir, faces=bm_mir.faces)
        obj_mir = new_mesh_obj(f"MirrorCap_{tag}", bm_mir)
        shade_smooth_auto(obj_mir, 42.0)
        add_body_part(obj_mir, mat_paint_src)

        make_box_game(f"MirrorGlass_{tag}", (side * 0.848, 0.928, 0.422), (0.054, 0.034, 0.005), mat_chrome_src, bevel_w=0.002)
        make_box_game(f"MirrorLED_{tag}", (side * 0.892, 0.925, 0.485), (0.022, 0.004, 0.028), mat_lamps, bevel_w=0.001, is_lamp=True, uv_v=(0.38, 0.62))

    # Front & Rear Wipers
    for wx in (-0.28, 0.18):
        make_tube_along_game_points(f"FrontWiper_{wx:.2f}", [
            (wx, 0.905, 0.715),
            (wx + 0.18, 0.950, 0.665),
            (wx + 0.36, 0.995, 0.615),
        ], radius=0.005, sides=8, mat=mat_rubber_src)

    # Rear Window Wiper (sitting flush on the raked rear window at Z=-1.80!)
    make_tube_along_game_points("RearWiper", [
        (0.02, 1.000, -1.802),
        (0.16, 1.055, -1.745),
        (0.32, 1.115, -1.685),
    ], radius=0.0055, sides=8, mat=mat_rubber_src)
    make_box_game("RearWiperPivot", (0.0, 0.995, -1.805), (0.022, 0.018, 0.018), mat_rubber_src, bevel_w=0.004)

build_exterior_trims_and_fittings()

# ------------------------------------------------------------------------------
# 5F. FRONT & REAR FACES: CURVED-GRID FLUSH GRILLE, HEADLAMPS, INTAKES, TAILLIGHTS
# ------------------------------------------------------------------------------
def build_front_and_rear_clusters():
    # 1. FRONT UPPER HEXAGONAL GRILLE ("GRILLE" on sheet.jpg) conforming 100% to 3D nose!
    make_surface_patch(
        "FrontGrilleInsert",
        corners_xy=[(-0.44, 0.485), (0.44, 0.485), (0.48, 0.615), (-0.48, 0.615)],
        is_front=True, z_offset=0.008, thickness=0.018, mat=mat_rubber_src, nu=12, nv=5
    )

    # Chrome surround frame around upper grille
    gr_loop_xy = [
        (-0.44, 0.485), (-0.22, 0.476), (0.0, 0.472), (0.22, 0.476), (0.44, 0.485),
        (0.485, 0.555), (0.475, 0.618), (0.24, 0.628), (0.0, 0.632), (-0.24, 0.628),
        (-0.475, 0.618), (-0.485, 0.555), (-0.44, 0.485)
    ]
    gr_loop_pts = [(x, y, eval_front_face_z(x, y) + 0.011) for (x, y) in gr_loop_xy]
    make_tube_along_game_points("FrontGrilleFrame", gr_loop_pts, radius=0.0085, scale_up=1.2, sides=10, mat=mat_chrome_src)

    # Horizontal Chrome Wing Bar across the center of the grille + unbranded center housing
    wing_pts = [
        (x, 0.555 + 0.006 * abs(x)/0.46, eval_front_face_z(x, 0.555) + 0.014)
        for x in (-0.46, -0.30, -0.15, 0.0, 0.15, 0.30, 0.46)
    ]
    make_tube_along_game_points("FrontGrilleWingBar", wing_pts, radius=0.010, scale_up=1.1, sides=10, mat=mat_chrome_src)

    z0_f = eval_front_face_z(0.0, 0.555)
    make_box_game("FrontCenterMedallion", (0.0, 0.555, z0_f + 0.012), (0.048, 0.044, 0.008), mat_rubber_src, bevel_w=0.012)
    make_box_game("FrontCenterRing", (0.0, 0.555, z0_f + 0.016), (0.036, 0.033, 0.006), mat_chrome_src, bevel_w=0.010)

    # 2. LOWER TRAPEZOIDAL AIR DAM & RADAR SENSOR ("PLASTIC (BUMPERS)" on sheet.jpg)
    make_surface_patch(
        "LowerAirDamInsert",
        corners_xy=[(-0.52, 0.215), (0.52, 0.215), (0.45, 0.355), (-0.45, 0.355)],
        is_front=True, z_offset=0.007, thickness=0.016, mat=mat_rubber_src, nu=12, nv=4
    )
    # Horizontal lower intake louvers
    for ly in (0.255, 0.305):
        l_pts = [(x, ly, eval_front_face_z(x, ly) + 0.011) for x in (-0.46, -0.23, 0.0, 0.23, 0.46)]
        make_tube_along_game_points(f"LowerAirDamSlat_{ly:.2f}", l_pts, radius=0.0055, sides=8, mat=mat_rubber_src)

    z_rad = eval_front_face_z(0.0, 0.280)
    make_box_game("FrontRadarSensor", (0.0, 0.280, z_rad + 0.011), (0.068, 0.038, 0.006), mat_chrome_src, bevel_w=0.004)

    # Flush Front Lower Bumper Splitter Lip (following exact 3D curved nose at Y=0.188!)
    splitter_pts = [(x, 0.188, eval_front_face_z(x, 0.188) + 0.005) for x in (-0.54, -0.36, -0.18, 0.0, 0.18, 0.36, 0.54)]
    make_tube_along_game_points("FrontSplitterLip", splitter_pts, radius=0.011, scale_up=0.75, sides=10, mat=mat_rubber_src)

    # 3. ANGULAR SWEPT-BACK LED HEADLIGHTS ("HEADLIGHTS" on sheet.jpg) & SIDE FOG LIGHT PODS
    for side in (-1.0, 1.0):
        tag = "L" if side > 0 else "R"
        # Quad-grid headlamp housing conforming 100% to curved nose (zero hull poke-through!)
        hl_corners = [
            (side * 0.480, 0.548),
            (side * 0.720, 0.610),
            (side * 0.710, 0.690),
            (side * 0.470, 0.622),
        ]
        if side < 0:
            hl_corners = [hl_corners[1], hl_corners[0], hl_corners[3], hl_corners[2]]

        make_surface_patch(
            f"HeadlampHousing_{tag}",
            corners_xy=hl_corners,
            is_front=True, z_offset=0.008, thickness=0.020, mat=mat_rubber_src, nu=10, nv=5
        )

        # Flush Polycarbonate Headlamp Outer Lens Cover (Glass material)
        make_surface_patch(
            f"HeadlampCoverLens_{tag}",
            corners_xy=hl_corners,
            is_front=True, z_offset=0.018, thickness=0.002, mat=mat_glass, nu=10, nv=5
        )

        # Signature Angular Warm-White LED DRL Eyebrow & Hook inside headlamp
        drl_xy = [
            (side * 0.478, 0.612),
            (side * 0.585, 0.642),
            (side * 0.695, 0.675),
            (side * 0.702, 0.625),
        ]
        drl_pts = [(x, y, eval_front_face_z(x, y) + 0.013) for (x, y) in drl_xy]
        make_tube_along_game_points(f"HeadlampDRL_{tag}", drl_pts, radius=0.0080, scale_up=1.1, sides=8, mat=mat_lamps, is_lamp=True, uv_v=(0.05, 0.30))

        # Dual Chrome & Emissive LED Projector Lenses seated flush inside housing
        for px, py, pr in ((0.545, 0.582, 0.024), (0.645, 0.618, 0.021)):
            sx = side * px
            pz = eval_front_face_z(sx, py) + 0.012
            make_box_game(f"ProjectorBezel_{tag}_{px:.2f}", (sx, py, pz), (pr*1.15, pr*1.05, 0.006), mat_chrome_src, bevel_w=0.003, rot_y_deg=side * 25.0)
            make_box_game(f"ProjectorLens_{tag}_{px:.2f}", (sx, py, pz + 0.003), (pr*0.90, pr*0.80, 0.005), mat_lamps, bevel_w=0.003, rot_y_deg=side * 25.0, is_lamp=True, uv_v=(0.05, 0.30))

        # Vertical Side Bumper Intake Pocket + Round Fog Light (flush curved surface patch!)
        fog_corners = [
            (side * 0.585, 0.250),
            (side * 0.675, 0.265),
            (side * 0.685, 0.425),
            (side * 0.595, 0.410),
        ]
        if side < 0:
            fog_corners = [fog_corners[1], fog_corners[0], fog_corners[3], fog_corners[2]]
        make_surface_patch(
            f"SideAirCurtainPocket_{tag}",
            corners_xy=fog_corners,
            is_front=True, z_offset=0.007, thickness=0.014, mat=mat_rubber_src, nu=6, nv=4
        )
        fx_fog = side * 0.635
        fy_fog = 0.310
        fz_fog = eval_front_face_z(fx_fog, fy_fog) + 0.011
        make_box_game(f"FogLightBezel_{tag}", (fx_fog, fy_fog, fz_fog), (0.020, 0.020, 0.005), mat_chrome_src, bevel_w=0.005, rot_y_deg=side * 26.0)
        make_box_game(f"FogLightLens_{tag}", (fx_fog, fy_fog, fz_fog + 0.003), (0.015, 0.015, 0.004), mat_lamps, bevel_w=0.003, rot_y_deg=side * 26.0, is_lamp=True, uv_v=(0.05, 0.30))

    # 4. REAR TWO-PIECE WRAPAROUND LED TAILLIGHTS ("TAILLIGHTS" on sheet.jpg)
    for side in (-1.0, 1.0):
        tag = "L" if side > 0 else "R"
        tl_corners = [
            (side * 0.330, 0.815),
            (side * 0.695, 0.790),
            (side * 0.705, 0.892),
            (side * 0.320, 0.880),
        ]
        if side > 0:
            tl_corners = [tl_corners[1], tl_corners[0], tl_corners[3], tl_corners[2]]

        make_surface_patch(
            f"TailLampLens_{tag}",
            corners_xy=tl_corners,
            is_front=False, z_offset=0.008, thickness=0.018, mat=mat_lamps, nu=10, nv=5,
            is_lamp=True, uv_v=(0.68, 0.96)
        )

        # Glowing inner LED light ribbon loop inside tail lamp
        rib_xy = [
            (side * 0.350, 0.858),
            (side * 0.525, 0.872),
            (side * 0.675, 0.855),
            (side * 0.665, 0.815),
            (side * 0.355, 0.832),
        ]
        rib_pts = [(x, y, eval_rear_face_z(x, y) - 0.012) for (x, y) in rib_xy]
        make_tube_along_game_points(f"TailLampRibbon_{tag}", rib_pts, radius=0.0070, scale_up=1.1, sides=8, mat=mat_lamps, is_lamp=True, uv_v=(0.68, 0.96))

        # Slim horizontal red bumper reflector strip (flush curved patch on rear bumper!)
        ref_corners = [
            (side * 0.420, 0.440),
            (side * 0.620, 0.445),
            (side * 0.620, 0.465),
            (side * 0.420, 0.460),
        ]
        if side > 0:
            ref_corners = [ref_corners[1], ref_corners[0], ref_corners[3], ref_corners[2]]
        make_surface_patch(
            f"RearBumperReflector_{tag}",
            corners_xy=ref_corners,
            is_front=False, z_offset=0.006, thickness=0.008, mat=mat_lamps, nu=6, nv=2,
            is_lamp=True, uv_v=(0.68, 0.96)
        )

    # 5. REAR TAILGATE LICENSE PLATE RECESS, HIGH-MOUNT BRAKE LAMP & LOWER DIFFUSER VALANCE
    make_surface_patch(
        "RearPlateRecess",
        corners_xy=[(-0.28, 0.685), (0.28, 0.685), (0.26, 0.790), (-0.26, 0.790)],
        is_front=False, z_offset=0.005, thickness=0.012, mat=mat_rubber_src, nu=8, nv=3
    )
    make_box_game("RearHighBrakeLamp", (0.0, 1.422, -1.445), (0.185, 0.009, 0.012), mat_lamps, bevel_w=0.003, is_lamp=True, uv_v=(0.68, 0.96))

    # Rear lower black plastic bumper diffuser valance (flush curved surface patch!)
    make_surface_patch(
        "RearDiffuserValance",
        corners_xy=[(-0.52, 0.220), (0.52, 0.220), (0.48, 0.310), (-0.48, 0.310)],
        is_front=False, z_offset=0.006, thickness=0.016, mat=mat_rubber_src, nu=12, nv=3
    )
    chrome_diff_pts = [(x, 0.235, eval_rear_face_z(x, 0.235) - 0.010) for x in (-0.48, -0.24, 0.0, 0.24, 0.48)]
    make_tube_along_game_points("RearDiffuserChromeTrim", chrome_diff_pts, radius=0.0060, scale_up=1.1, sides=8, mat=mat_chrome_src)

build_front_and_rear_clusters()

# ------------------------------------------------------------------------------
# 5G. UNDERBODY CHASSIS PAN, SUSPENSION ARMS & EXHAUST SYSTEM
# ------------------------------------------------------------------------------
def build_underbody_and_exhaust():
    make_box_game("Underbody_FloorPan", (0.0, 0.175, 0.0), (0.64, 0.018, 1.45), mat_underbody_src, bevel_w=0.006)
    for az, tag in ((AXLE_Z_F, "Front"), (AXLE_Z_R, "Rear")):
        make_tube_along_game_points(f"SubframeCrossmember_{tag}", [
            (-0.68, WHEEL_R - 0.04, az),
            ( 0.68, WHEEL_R - 0.04, az),
        ], radius=0.024, sides=10, mat=mat_underbody_src)

    make_tube_along_game_points("ExhaustMuffler", [
        (0.38, 0.225, -1.45),
        (0.38, 0.225, -1.78),
    ], radius=0.046, scale_up=0.72, sides=14, mat=mat_underbody_src)

    make_tube_along_game_points("ExhaustTailpipe", [
        (0.38, 0.225, -1.75),
        (0.38, 0.220, -1.98),
    ], radius=0.020, sides=12, mat=mat_chrome_src)

build_underbody_and_exhaust()

# ------------------------------------------------------------------------------
# 5H. FURNISHED MODERN INTERIOR & SEPARATE STEERING WHEEL (VISIBLE THROUGH GLASS!)
# ------------------------------------------------------------------------------
def build_interior_and_steering():
    make_box_game("Interior_Floor", (0.0, 0.32, -0.25), (0.66, 0.025, 1.05), mat_interior_src, bevel_w=0.005)
    make_box_game("Interior_CenterConsole", (0.0, 0.46, 0.08), (0.105, 0.12, 0.55), mat_interior_src, bevel_w=0.015)

    for side in (-1.0, 1.0):
        make_box_game(f"Interior_DoorCard_{side:.0f}", (side * 0.705, 0.62, -0.25), (0.022, 0.26, 0.96), mat_interior_src, bevel_w=0.008)

    # Modern Dashboard + Wide Dual-Screen Infotainment Display
    make_box_game("Interior_Dashboard", (0.0, 0.835, 0.55), (0.66, 0.085, 0.18), mat_interior_src, bevel_w=0.024)
    make_box_game("Interior_WideScreenDisplay", (0.14, 0.935, 0.48), (0.28, 0.058, 0.018), mat_interior_src, bevel_w=0.008)
    make_box_game("Interior_ScreenGlass", (0.14, 0.935, 0.462), (0.265, 0.048, 0.004), mat_chrome_src, bevel_w=0.002)

    # Front Sport Contoured Seats + Headrests
    for sx, stag in ((0.31, "L"), (-0.31, "R")):
        make_box_game(f"SeatCushion_{stag}", (sx, 0.48, -0.02), (0.23, 0.075, 0.24), mat_interior_src, bevel_w=0.024)
        make_box_game(f"SeatBack_{stag}", (sx, 0.78, -0.22), (0.22, 0.28, 0.065), mat_interior_src, bevel_w=0.024, rot_x_deg=-15.0)
        make_box_game(f"SeatHeadrest_{stag}", (sx, 1.11, -0.30), (0.11, 0.085, 0.045), mat_interior_src, bevel_w=0.018, rot_x_deg=-10.0)

    # Rear 60/40 Bench Seat + 3 Headrests + Rear Parcel Shelf
    make_box_game("RearBenchCushion", (0.0, 0.50, -0.78), (0.62, 0.075, 0.22), mat_interior_src, bevel_w=0.022)
    make_box_game("RearBenchBack", (0.0, 0.79, -0.98), (0.60, 0.27, 0.065), mat_interior_src, bevel_w=0.022, rot_x_deg=-16.0)
    for rx in (-0.36, 0.0, 0.36):
        make_box_game(f"RearHeadrest_{rx:.2f}", (rx, 1.09, -1.06), (0.095, 0.065, 0.040), mat_interior_src, bevel_w=0.014)
    make_box_game("RearParcelShelf", (0.0, 0.94, -1.42), (0.56, 0.018, 0.26), mat_interior_src, bevel_w=0.006)

    # Interior Rearview Mirror
    make_tube_along_game_points("InteriorMirrorStalk", [(0.0, 1.385, 0.22), (0.0, 1.285, 0.25)], radius=0.006, sides=8, mat=mat_rubber_src)
    make_box_game("InteriorMirrorHead", (0.0, 1.275, 0.255), (0.115, 0.028, 0.010), mat_interior_src, bevel_w=0.004)

    # Steering column
    make_tube_along_game_points("SteeringColumn", [(0.31, 0.81, 0.48), (0.31, 0.905, 0.27)], radius=0.026, sides=12, mat=mat_interior_src)

    # SEPARATE MESH NODE: SteeringWheel (origin at its hub: game (0.31, 0.905, 0.27))
    sw_origin_bl = gv(0.31, 0.905, 0.27)
    bm_sw = bmesh.new()
    tilt_rad = math.radians(24.0)
    ct_tilt, st_tilt = math.cos(tilt_rad), math.sin(tilt_rad)
    def sw_local(gx, gy, gz):
        ry = gy * ct_tilt - gz * st_tilt
        rz = gy * st_tilt + gz * ct_tilt
        return gv(gx, ry, rz)

    n_maj, n_min = 36, 12
    R_maj, r_min = 0.182, 0.015
    rim_rings = []
    for i in range(n_maj):
        theta = 2.0 * math.pi * i / n_maj
        ct, st = math.cos(theta), math.sin(theta)
        r_v = []
        for j in range(n_min):
            phi = 2.0 * math.pi * j / n_min
            cp, sp = math.cos(phi), math.sin(phi)
            gx = ct * (R_maj + cp * r_min)
            gy = st * (R_maj + cp * r_min)
            gz = sp * r_min
            r_v.append(bm_sw.verts.new(sw_local(gx, gy, gz)))
        rim_rings.append(r_v)

    for i in range(n_maj):
        inext = (i + 1) % n_maj
        for j in range(n_min):
            jnext = (j + 1) % n_min
            bm_sw.faces.new((rim_rings[i][j], rim_rings[inext][j], rim_rings[inext][jnext], rim_rings[i][jnext]))

    bmesh.ops.create_uvsphere(bm_sw, u_segments=16, v_segments=10, radius=0.052)
    for sp_ang in (0.0, math.pi, -math.pi * 0.5):
        ca, sa = math.cos(sp_ang), math.sin(sp_ang)
        bmesh.ops.create_cube(
            bm_sw, size=1.0,
            matrix=Matrix.Translation(sw_local(ca * 0.095, sa * 0.095, 0.0)) @ Matrix.Diagonal((0.028 if abs(sa)>0.5 else 0.15, 0.016, 0.15 if abs(sa)>0.5 else 0.024, 1.0))
        )

    bmesh.ops.recalc_face_normals(bm_sw, faces=bm_sw.faces)
    obj_sw = new_mesh_obj("SteeringWheel", bm_sw)
    obj_sw.location = sw_origin_bl
    shade_smooth_auto(obj_sw, 40.0)
    assign_mat(obj_sw, mat_interior_src)
    return obj_sw

obj_steering_wheel = build_interior_and_steering()

# ------------------------------------------------------------------------------
# 5I. FOUR SEPARATE WHEEL NODES: Wheel_FL, Wheel_FR, Wheel_RL, Wheel_RR
#     (Tyre + Two-Tone Diamond-Cut 5-Twin-Spoke Alloy Rim + Brake Disc + Caliper)
# ------------------------------------------------------------------------------
def build_single_wheel(name, center_game, side_sign):
    center_bl = gv(*center_game)
    bm_tyre = bmesh.new()
    bm_rim_outer = bmesh.new()
    bm_disc = bmesh.new()

    n_rad = 48
    # Realistic 18-inch low-profile performance tyre (outer radius 0.320m, rim lip 0.240m -> 80mm sidewall!)
    tyre_prof = [
        (-0.095, 0.240),
        (-0.106, 0.268),
        (-0.108, 0.296),
        (-0.098, 0.314),
        (-0.080, 0.319),
        (-0.048, 0.320),
        (-0.043, 0.313),
        (-0.035, 0.313),
        (-0.030, 0.320),
        (-0.005, 0.320),
        ( 0.000, 0.313),
        ( 0.005, 0.320),
        ( 0.030, 0.320),
        ( 0.035, 0.313),
        ( 0.043, 0.313),
        ( 0.048, 0.320),
        ( 0.080, 0.319),
        ( 0.098, 0.314),
        ( 0.108, 0.296),
        ( 0.106, 0.268),
        ( 0.095, 0.240),
    ]

    t_rings = []
    for i in range(n_rad):
        ang = 2.0 * math.pi * i / n_rad
        ca, sa = math.cos(ang), math.sin(ang)
        ring = []
        for x_off, r in tyre_prof:
            ring.append(bm_tyre.verts.new(gv(side_sign * x_off, ca * r, sa * r)))
        t_rings.append(ring)

    np_t = len(tyre_prof)
    for i in range(n_rad):
        inext = (i + 1) % n_rad
        for j in range(np_t - 1):
            if side_sign > 0:
                bm_tyre.faces.new((t_rings[i][j], t_rings[inext][j], t_rings[inext][j+1], t_rings[i][j+1]))
            else:
                bm_tyre.faces.new((t_rings[inext][j], t_rings[i][j], t_rings[i][j+1], t_rings[inext][j+1]))

    bmesh.ops.recalc_face_normals(bm_tyre, faces=bm_tyre.faces)
    obj_t = new_mesh_obj(f"{name}_Tyre", bm_tyre)
    assign_mat(obj_t, mat_rubber_src)

    # Outer Diamond-Cut Alloy Rim Lip & Flange (r=0.222..0.242)
    rim_prof = [
        (-0.092, 0.240),
        (-0.075, 0.224),
        ( 0.062, 0.224),
        ( 0.095, 0.242),
        ( 0.099, 0.234),
        ( 0.086, 0.222),
    ]
    r_rings = []
    for i in range(n_rad):
        ang = 2.0 * math.pi * i / n_rad
        ca, sa = math.cos(ang), math.sin(ang)
        ring = []
        for x_off, r in rim_prof:
            ring.append(bm_rim_outer.verts.new(gv(side_sign * x_off, ca * r, sa * r)))
        r_rings.append(ring)

    npr = len(rim_prof)
    for i in range(n_rad):
        inext = (i + 1) % n_rad
        for j in range(npr - 1):
            if side_sign > 0:
                bm_rim_outer.faces.new((r_rings[i][j], r_rings[inext][j], r_rings[inext][j+1], r_rings[i][j+1]))
            else:
                bm_rim_outer.faces.new((r_rings[inext][j], r_rings[i][j], r_rings[i][j+1], r_rings[inext][j+1]))

    # 5-Twin-Spoke (10 spokes in 5 parallel twin pairs) Two-Tone Diamond-Cut Alloy Spokes (matching sheet.jpg!)
    for pair_i in range(5):
        base_ang = 2.0 * math.pi * pair_i / 5.0
        cb, sb = math.cos(base_ang), math.sin(base_ang)
        p_ang = base_ang + math.pi * 0.5
        cp, sp = math.cos(p_ang), math.sin(p_ang)

        # Dark gunmetal backing pocket between the twin spokes
        r_in_p, r_out_p = 0.056, 0.225
        y_ip, z_ip = cb * r_in_p, sb * r_in_p
        y_op, z_op = cb * r_out_p, sb * r_out_p
        wp_in, wp_out = 0.028, 0.032
        xp_f = side_sign * 0.082
        pv0 = bm_disc.verts.new(gv(xp_f, y_ip - cp*wp_in,  z_ip - sp*wp_in))
        pv1 = bm_disc.verts.new(gv(xp_f, y_ip + cp*wp_in,  z_ip + sp*wp_in))
        pv2 = bm_disc.verts.new(gv(xp_f, y_op + cp*wp_out, z_op + sp*wp_out))
        pv3 = bm_disc.verts.new(gv(xp_f, y_op - cp*wp_out, z_op - sp*wp_out))
        if side_sign > 0:
            bm_disc.faces.new((pv0, pv3, pv2, pv1))
        else:
            bm_disc.faces.new((pv1, pv2, pv3, pv0))

        # Two crisp diamond-cut silver bars per twin-spoke pair
        for side_bar in (-1.0, 1.0):
            offset_d = side_bar * 0.022
            w_bar_in, w_bar_out = 0.0085, 0.0095
            r_in, r_out = 0.055, 0.228
            y_in = cb * r_in + cp * offset_d * 0.85
            z_in = sb * r_in + sp * offset_d * 0.85
            y_out = cb * r_out + cp * offset_d * 1.10
            z_out = sb * r_out + sp * offset_d * 1.10

            x_f_in, x_f_out = side_sign * 0.090, side_sign * 0.096
            x_b_in, x_b_out = side_sign * 0.068, side_sign * 0.074

            v0 = bm_rim_outer.verts.new(gv(x_f_in,  y_in  - cp*w_bar_in,  z_in  - sp*w_bar_in))
            v1 = bm_rim_outer.verts.new(gv(x_f_in,  y_in  + cp*w_bar_in,  z_in  + sp*w_bar_in))
            v2 = bm_rim_outer.verts.new(gv(x_f_out, y_out + cp*w_bar_out, z_out + sp*w_bar_out))
            v3 = bm_rim_outer.verts.new(gv(x_f_out, y_out - cp*w_bar_out, z_out - sp*w_bar_out))

            b0 = bm_rim_outer.verts.new(gv(x_b_in,  y_in  - cp*w_bar_in*1.2,  z_in  - sp*w_bar_in*1.2))
            b1 = bm_rim_outer.verts.new(gv(x_b_in,  y_in  + cp*w_bar_in*1.2,  z_in  + sp*w_bar_in*1.2))
            b2 = bm_rim_outer.verts.new(gv(x_b_out, y_out + cp*w_bar_out*1.2, z_out + sp*w_bar_out*1.2))
            b3 = bm_rim_outer.verts.new(gv(x_b_out, y_out - cp*w_bar_out*1.2, z_out - sp*w_bar_out*1.2))

            bm_rim_outer.faces.new((v0, v1, v2, v3))
            bm_rim_outer.faces.new((b1, b0, b3, b2))
            bm_rim_outer.faces.new((v0, v3, b3, b0))
            bm_rim_outer.faces.new((v2, v1, b1, b2))

    # Center alloy hub ring + dark center cap
    bmesh.ops.create_cone(
        bm_rim_outer, cap_ends=True, cap_tris=False, segments=24,
        radius1=0.062, radius2=0.058, depth=0.028,
        matrix=Matrix.Translation(gv(side_sign * 0.078, 0.0, 0.0)) @ Matrix.Rotation(math.pi * 0.5, 4, 'Y')
    )
    bmesh.ops.create_cone(
        bm_disc, cap_ends=True, cap_tris=False, segments=20,
        radius1=0.028, radius2=0.028, depth=0.032,
        matrix=Matrix.Translation(gv(side_sign * 0.079, 0.0, 0.0)) @ Matrix.Rotation(math.pi * 0.5, 4, 'Y')
    )

    bmesh.ops.recalc_face_normals(bm_rim_outer, faces=bm_rim_outer.faces)
    obj_r = new_mesh_obj(f"{name}_Rim", bm_rim_outer)
    assign_mat(obj_r, mat_chrome_src)

    # Recessed Cast-Iron Brake Disc Rotor & Caliper
    bmesh.ops.create_cone(
        bm_disc, cap_ends=True, cap_tris=False, segments=28,
        radius1=0.172, radius2=0.172, depth=0.026,
        matrix=Matrix.Translation(gv(side_sign * 0.022, 0.0, 0.0)) @ Matrix.Rotation(math.pi * 0.5, 4, 'Y')
    )
    bmesh.ops.create_cube(
        bm_disc, size=1.0,
        matrix=Matrix.Translation(gv(side_sign * 0.032, 0.045, -0.125)) @ Matrix.Diagonal((0.055, 0.085, 0.135, 1.0))
    )
    bmesh.ops.recalc_face_normals(bm_disc, faces=bm_disc.faces)
    obj_d = new_mesh_obj(f"{name}_Disc", bm_disc)
    assign_mat(obj_d, mat_underbody_src)

    bpy.ops.object.select_all(action='DESELECT')
    for o in (obj_t, obj_r, obj_d):
        o.location = center_bl
        o.select_set(True)
    bpy.context.view_layer.objects.active = obj_t
    bpy.ops.object.join()
    obj_t.name = name
    obj_t.data.name = name
    shade_smooth_auto(obj_t, 38.0)
    return obj_t

wheel_fl = build_single_wheel("Wheel_FL", ( TRACK_HALF, WHEEL_R, AXLE_Z_F),  1.0)
wheel_fr = build_single_wheel("Wheel_FR", (-TRACK_HALF, WHEEL_R, AXLE_Z_F), -1.0)
wheel_rl = build_single_wheel("Wheel_RL", ( TRACK_HALF, WHEEL_R, AXLE_Z_R),  1.0)
wheel_rr = build_single_wheel("Wheel_RR", (-TRACK_HALF, WHEEL_R, AXLE_Z_R), -1.0)

# ==============================================================================
# 6. APPLY MODIFIERS, UV UNWRAP & JOIN BODY PARTS INTO "Body"
# ==============================================================================
bpy.ops.object.select_all(action='DESELECT')
for o in body_parts + lamp_parts:
    bpy.context.view_layer.objects.active = o
    for m in list(o.modifiers):
        bpy.ops.object.modifier_apply(modifier=m.name)

bpy.ops.object.select_all(action='DESELECT')
for o in body_parts:
    o.select_set(True)
bpy.context.view_layer.objects.active = body_parts[0]
bpy.ops.object.join()
body_obj = bpy.context.view_layer.objects.active
body_obj.name = "Body"
body_obj.data.name = "Body"

all_mesh_objs = [body_obj, wheel_fl, wheel_fr, wheel_rl, wheel_rr, obj_steering_wheel]
total_tris = sum(sum(len(p.vertices) - 2 for p in o.data.polygons) for o in all_mesh_objs + lamp_parts)
print(f"Initial total triangles: {total_tris}")
if total_tris < 52000:
    mod_sub = body_obj.modifiers.new("Subdiv", 'SUBSURF')
    mod_sub.levels = 1
    mod_sub.render_levels = 1
    bpy.context.view_layer.objects.active = body_obj
    bpy.ops.object.modifier_apply(modifier=mod_sub.name)

# Smart UV unwrap non-lamp meshes
for o in all_mesh_objs:
    bpy.ops.object.select_all(action='DESELECT')
    o.select_set(True)
    bpy.context.view_layer.objects.active = o
    bpy.ops.object.mode_set(mode='EDIT')
    bpy.ops.mesh.select_all(action='SELECT')
    bpy.ops.uv.smart_project(angle_limit=math.radians(66.0), island_margin=0.006)
    bpy.ops.object.mode_set(mode='OBJECT')

# Ensure lamp_parts have identical UV layer name as body_obj before joining
target_uv_name = body_obj.data.uv_layers[0].name if body_obj.data.uv_layers else "UVMap"
for lp in lamp_parts:
    if lp.data.uv_layers:
        lp.data.uv_layers[0].name = target_uv_name

# Join lamp_parts into Body (preserving their atlas UVs!)
bpy.ops.object.select_all(action='DESELECT')
body_obj.select_set(True)
for lp in lamp_parts:
    lp.select_set(True)
bpy.context.view_layer.objects.active = body_obj
bpy.ops.object.join()
shade_smooth_auto(body_obj, 38.0)

for o in all_mesh_objs:
    bpy.context.view_layer.objects.active = o
    mod_t = o.modifiers.new("Triangulate", 'TRIANGULATE')
    bpy.ops.object.modifier_apply(modifier=mod_t.name)

total_tris = sum(len(o.data.polygons) for o in all_mesh_objs)
if total_tris > 92000:
    bpy.context.view_layer.objects.active = body_obj
    mod_d = body_obj.modifiers.new("DecimateBudget", 'DECIMATE')
    mod_d.ratio = 82000.0 / total_tris
    bpy.ops.object.modifier_apply(modifier=mod_d.name)
    mod_t2 = body_obj.modifiers.new("Triangulate2", 'TRIANGULATE')
    bpy.ops.object.modifier_apply(modifier=mod_t2.name)
    total_tris = sum(len(o.data.polygons) for o in all_mesh_objs)

print(f"Final triangulated count: {total_tris}")

# ==============================================================================
# 7. EMPTY NODES CONTRACT (Forge Garage contract v2)
# ==============================================================================
EMPTY_NODES_GAME = {
    "Seat_Driver":  ( 0.31, 0.51, -0.05),
    "Door_L":       ( 0.89, 0.55,  0.12),
    "Door_R":       (-0.89, 0.55,  0.12),
    "Exhaust":      ( 0.38, 0.22, -2.00),
    "Light_Head_L": ( 0.63, 0.63,  1.88),
    "Light_Head_R": (-0.63, 0.63,  1.88),
    "Light_Tail_L": ( 0.56, 0.86, -1.96),
    "Light_Tail_R": (-0.56, 0.86, -1.96),
}

empty_objs = []
for ename, epos_g in EMPTY_NODES_GAME.items():
    emp = bpy.data.objects.new(ename, None)
    emp.empty_display_type = 'PLAIN_AXES'
    emp.empty_display_size = 0.15
    emp.location = gv(*epos_g)
    bpy.context.collection.objects.link(emp)
    empty_objs.append(emp)

# ==============================================================================
# 8. CYCLES TEXTURE BAKING (GAME WEIGHT BUDGET: <=1024px, ORM <=512px)
# ==============================================================================
DEFAULT_PAINT = [0.55, 0.03, 0.03] # Deep Red (contract required default factor)

def bake_material_set(mat_src, final_name, base_res=1024, orm_res=512, is_paint=False):
    print(f"Baking material {final_name}...")
    bpy.ops.mesh.primitive_plane_add(size=2.0, location=(0, 0, 0))
    plane = bpy.context.active_object
    assign_mat(plane, mat_src)

    nt = mat_src.node_tree
    bsdf = next(n for n in nt.nodes if n.type == 'BSDF_PRINCIPLED')
    out = next(n for n in nt.nodes if n.type == 'OUTPUT_MATERIAL')

    def do_emit_bake(socket_or_color, img_name, res, colorspace='sRGB'):
        img = bpy.data.images.new(img_name, width=res, height=res, alpha=False)
        img.colorspace_settings.name = colorspace
        img_node = nt.nodes.new('ShaderNodeTexImage')
        img_node.image = img
        for n in nt.nodes:
            n.select = False
        img_node.select = True
        nt.nodes.active = img_node

        emit = nt.nodes.new('ShaderNodeEmission')
        if isinstance(socket_or_color, tuple):
            emit.inputs['Color'].default_value = socket_or_color
        else:
            nt.links.new(socket_or_color, emit.inputs['Color'])
        nt.links.new(emit.outputs['Emission'], out.inputs['Surface'])

        bpy.ops.object.select_all(action='DESELECT')
        plane.select_set(True)
        bpy.context.view_layer.objects.active = plane
        scene.cycles.samples = 16
        scene.cycles.bake_type = 'EMIT'
        bpy.ops.object.bake(type='EMIT')

        fpath = os.path.join(TEX_DIR, f"{img_name}.png")
        img.filepath_raw = fpath
        img.file_format = 'PNG'
        img.save()

        nt.nodes.remove(emit)
        nt.nodes.remove(img_node)
        nt.links.new(bsdf.outputs['BSDF'], out.inputs['Surface'])
        return img

    base_sock = bsdf.inputs['Base Color'].links[0].from_socket if bsdf.inputs['Base Color'].is_linked else bsdf.inputs['Base Color'].default_value
    img_base = do_emit_bake(base_sock, f"{final_name}_base", base_res, 'sRGB')

    comb_orm = nt.nodes.new('ShaderNodeCombineColor')
    comb_orm.inputs['Red'].default_value = 0.92
    if bsdf.inputs['Roughness'].is_linked:
        nt.links.new(bsdf.inputs['Roughness'].links[0].from_socket, comb_orm.inputs['Green'])
    else:
        comb_orm.inputs['Green'].default_value = bsdf.inputs['Roughness'].default_value
    if bsdf.inputs['Metallic'].is_linked:
        nt.links.new(bsdf.inputs['Metallic'].links[0].from_socket, comb_orm.inputs['Blue'])
    else:
        comb_orm.inputs['Blue'].default_value = bsdf.inputs['Metallic'].default_value

    img_orm = do_emit_bake(comb_orm.outputs['Color'], f"{final_name}_orm", orm_res, 'Non-Color')
    nt.nodes.remove(comb_orm)

    img_nor = bpy.data.images.new(f"{final_name}_normal", width=orm_res, height=orm_res, alpha=False)
    img_nor.colorspace_settings.name = 'Non-Color'
    img_node = nt.nodes.new('ShaderNodeTexImage')
    img_node.image = img_nor
    for n in nt.nodes:
        n.select = False
    img_node.select = True
    nt.nodes.active = img_node

    bpy.ops.object.select_all(action='DESELECT')
    plane.select_set(True)
    bpy.context.view_layer.objects.active = plane
    scene.cycles.samples = 16
    scene.cycles.bake_type = 'NORMAL'
    bpy.ops.object.bake(type='NORMAL')
    fpath_n = os.path.join(TEX_DIR, f"{final_name}_normal.png")
    img_nor.filepath_raw = fpath_n
    img_nor.file_format = 'PNG'
    img_nor.save()
    nt.nodes.remove(img_node)

    bpy.data.objects.remove(plane, do_unlink=True)

    mat_final = bpy.data.materials.new(name=final_name)
    nt_f = mat_final.node_tree
    nt_f.nodes.clear()
    out_f = nt_f.nodes.new('ShaderNodeOutputMaterial')
    bsdf_f = nt_f.nodes.new('ShaderNodeBsdfPrincipled')
    nt_f.links.new(bsdf_f.outputs['BSDF'], out_f.inputs['Surface'])

    n_base = nt_f.nodes.new('ShaderNodeTexImage')
    n_base.image = img_base

    if is_paint:
        mix_f = nt_f.nodes.new('ShaderNodeMix')
        mix_f.name = "PaintFactor"
        mix_f.data_type = 'RGBA'
        mix_f.blend_type = 'MULTIPLY'
        mix_f.inputs['Factor'].default_value = 1.0
        mix_f.inputs['B'].default_value = (DEFAULT_PAINT[0], DEFAULT_PAINT[1], DEFAULT_PAINT[2], 1.0)
        nt_f.links.new(n_base.outputs['Color'], mix_f.inputs['A'])
        nt_f.links.new(mix_f.outputs['Result'], bsdf_f.inputs['Base Color'])

        if 'Coat Weight' in bsdf_f.inputs:
            bsdf_f.inputs['Coat Weight'].default_value = 1.0
            bsdf_f.inputs['Coat Roughness'].default_value = 0.05
    else:
        nt_f.links.new(n_base.outputs['Color'], bsdf_f.inputs['Base Color'])

    n_orm = nt_f.nodes.new('ShaderNodeTexImage')
    n_orm.image = img_orm
    sep_f = nt_f.nodes.new('ShaderNodeSeparateColor')
    nt_f.links.new(n_orm.outputs['Color'], sep_f.inputs['Color'])
    nt_f.links.new(sep_f.outputs['Green'], bsdf_f.inputs['Roughness'])
    nt_f.links.new(sep_f.outputs['Blue'], bsdf_f.inputs['Metallic'])

    n_nor = nt_f.nodes.new('ShaderNodeTexImage')
    n_nor.image = img_nor
    nor_f = nt_f.nodes.new('ShaderNodeNormalMap')
    nt_f.links.new(n_nor.outputs['Color'], nor_f.inputs['Color'])
    nt_f.links.new(nor_f.outputs['Normal'], bsdf_f.inputs['Normal'])

    return mat_final

mat_paint = bake_material_set(mat_paint_src, "Paint", base_res=1024, orm_res=512, is_paint=True)
mat_chrome = bake_material_set(mat_chrome_src, "Chrome", base_res=512, orm_res=512, is_paint=False)
mat_rubber = bake_material_set(mat_rubber_src, "Rubber", base_res=512, orm_res=512, is_paint=False)
mat_interior = bake_material_set(mat_interior_src, "Interior", base_res=512, orm_res=512, is_paint=False)
mat_underbody = bake_material_set(mat_underbody_src, "Underbody", base_res=512, orm_res=512, is_paint=False)

swap_map = {
    mat_paint_src: mat_paint,
    mat_chrome_src: mat_chrome,
    mat_rubber_src: mat_rubber,
    mat_interior_src: mat_interior,
    mat_underbody_src: mat_underbody,
}
for o in all_mesh_objs:
    for i, m in enumerate(o.data.materials):
        if m in swap_map:
            o.data.materials[i] = swap_map[m]

for m_src in swap_map.keys():
    bpy.data.materials.remove(m_src, do_unlink=True)

# ==============================================================================
# 9. EXPORT GLB & PATCH GLTF JSON FOR EXPLICIT PAINT CONTRACT & CLEARCOAT
# ==============================================================================
asset_objs = all_mesh_objs + empty_objs
bpy.ops.object.select_all(action='DESELECT')
for o in asset_objs:
    o.select_set(True)

glb_path = os.path.join(WORK_DIR, "euro-hatchback-g4.glb")
bpy.ops.export_scene.gltf(
    filepath=glb_path,
    export_format='GLB',
    use_selection=True,
    export_yup=True,
    export_apply=True,
    export_cameras=False,
    export_lights=False,
    export_texcoords=True,
    export_normals=True,
    export_materials='EXPORT',
    export_image_format='AUTO',
)

raw_glb = open(glb_path, "rb").read()
j_len = struct.unpack_from("<I", raw_glb, 12)[0]
gltf_json = json.loads(raw_glb[20:20 + j_len])
bin_chunk = raw_glb[28 + j_len:]

for mat_d in gltf_json.get("materials", []):
    if mat_d.get("name") == "Paint":
        pbr = mat_d.setdefault("pbrMetallicRoughness", {})
        pbr["baseColorFactor"] = [DEFAULT_PAINT[0], DEFAULT_PAINT[1], DEFAULT_PAINT[2], 1.0]
        ext = mat_d.setdefault("extensions", {})
        ext["KHR_materials_clearcoat"] = {"clearcoatFactor": 1.0, "clearcoatRoughnessFactor": 0.05}
    elif mat_d.get("name") == "Glass":
        mat_d["alphaMode"] = "BLEND"
        mat_d["doubleSided"] = True

ext_used = gltf_json.setdefault("extensionsUsed", [])
if "KHR_materials_clearcoat" not in ext_used:
    ext_used.append("KHR_materials_clearcoat")

new_jb = json.dumps(gltf_json, separators=(",", ":")).encode("utf-8")
new_jb += b" " * ((-len(new_jb)) % 4)
with open(glb_path, "wb") as f_out:
    total_len = 28 + len(new_jb) + len(bin_chunk)
    f_out.write(struct.pack("<4sII", b"glTF", 2, total_len))
    f_out.write(struct.pack("<I4s", len(new_jb), b"JSON"))
    f_out.write(new_jb)
    f_out.write(struct.pack("<I4s", len(bin_chunk), b"BIN\0"))
    f_out.write(bin_chunk)

glb_mb = os.path.getsize(glb_path) / (1024 * 1024)
print(f"Exported & patched {glb_path}: {glb_mb:.2f} MB")

# ==============================================================================
# 10. WRITE NODES.JSON & NOTES.MD
# ==============================================================================
def bl_to_game(v_bl):
    return [round(v_bl.x, 4), round(v_bl.z, 4), round(-v_bl.y, 4)]

all_game_verts = []
for o in all_mesh_objs:
    for v in o.data.vertices:
        wv = o.matrix_world @ v.co
        all_game_verts.append(bl_to_game(wv))

b_min = [min(p[i] for p in all_game_verts) for i in range(3)]
b_max = [max(p[i] for p in all_game_verts) for i in range(3)]
b_dim = [round(b_max[i] - b_min[i], 4) for i in range(3)]

PAINTS = [
    [0.55, 0.03, 0.03], # 1. Default Deep Red (contract required first)
    [0.32, 0.34, 0.36], # 2. Mountain Metallic Grey (sheet.jpg hero colour)
    [0.03, 0.16, 0.42], # 3. Denim Metallic Blue
    [0.82, 0.84, 0.86], # 4. Polar Silver White
    [0.68, 0.44, 0.03], # 5. Solarbeam Amber Gold
    [0.05, 0.06, 0.07], # 6. Cosmos Midnight Black
]

nodes_out = {
    "contract": "Forge Garage v2",
    "units": "metres",
    "front": "+Z",
    "up": "+Y",
    "left": "+X",
    "triangles": total_tris,
    "bounding_box": {
        "min": b_min,
        "max": b_max,
        "dimensions": b_dim,
    },
    "nodes": [
        {
            "name": o.name,
            "type": "mesh" if o.type == "MESH" else "empty",
            "worldPosition": bl_to_game(o.matrix_world.translation),
        }
        for o in asset_objs
    ],
    "paints": PAINTS,
}

with open(os.path.join(WORK_DIR, "nodes.json"), "w") as f:
    json.dump(nodes_out, f, indent=2)

notes_lines = [
    "Modern European compact five-door hatchback (4.10m x 1.78m x 1.47m, 2.62m wheelbase) with panoramic glass roof panel, 5-twin-spoke alloys, furnished interior, and Forge Garage v2 axes.",
    f"{total_tris:,} triangles across 6 mesh nodes (Body, 4 Wheels, SteeringWheel) and 8 contract empty nodes ({glb_mb:.2f} MB GLB).",
    "7 materials: Paint (neutral grime-baked base + clearcoat + baseColorFactor), Chrome, Rubber, Interior, Underbody, Glass (BLEND), Lamps (emissive LED/fluted atlas).",
    "Cycles GPU-baked textures within Game Weight budget: Paint base 1024px, other bases/normals/ORM 512px, Lamps atlas 256px.",
    "Poly Haven CC0 ids used: metal_plate, rusty_metal_02, rubber_tiles, fabric_leather_02 (https://polyhaven.com/license).",
    "Differences from reference: default GLB factor is contract deep red [0.55,0.03,0.03] (reference metallic grey is #2 in paints.png); unbranded front grille emblem.",
]
with open(os.path.join(WORK_DIR, "NOTES.md"), "w") as f:
    f.write("\n".join(notes_lines) + "\n")

# ==============================================================================
# 11. STUDIO STAGE, LIGHTING & RENDERS (clay.png, final.png, side.png, paints.png)
# ==============================================================================
bpy.ops.mesh.primitive_plane_add(size=250.0, location=(0.0, 0.0, 0.0))
stage = bpy.context.active_object
stage.name = "Stage"
mat_stage = bpy.data.materials.new("StageMat")
bsdf_st = mat_stage.node_tree.nodes.get("Principled BSDF")
bsdf_st.inputs['Base Color'].default_value = (0.82, 0.83, 0.84, 1.0)
bsdf_st.inputs['Roughness'].default_value = 0.25
bsdf_st.inputs['Specular IOR Level'].default_value = 0.38
assign_mat(stage, mat_stage)

world = bpy.data.worlds.new("StudioWorld")
scene.world = world
bg = world.node_tree.nodes.get("Background")
bg.inputs['Color'].default_value = (0.78, 0.80, 0.82, 1.0)
bg.inputs['Strength'].default_value = 0.24

light_specs = [
    ("KeyLight",  ( 4.8, -5.5, 5.8), 1350.0, 5.2),
    ("FillLight", (-4.8, -4.2, 3.6),  650.0, 4.5),
    ("RimLight",  (-2.8,  5.5, 4.8),  900.0, 4.2),
    ("TopBank",   ( 0.0, -0.5, 6.8),  750.0, 5.5),
]
for lname, lloc, lpow, lsize in light_specs:
    ldata = bpy.data.lights.new(lname, 'AREA')
    ldata.energy = lpow
    ldata.shape = 'DISK'
    ldata.size = lsize
    lobj = bpy.data.objects.new(lname, ldata)
    bpy.context.collection.objects.link(lobj)
    lobj.location = lloc
    lobj.rotation_euler = (Vector((0.0, 0.0, 0.70)) - Vector(lloc)).to_track_quat('-Z', 'Y').to_euler()

cam_data = bpy.data.cameras.new("RenderCam")
cam_obj = bpy.data.objects.new("RenderCam", cam_data)
bpy.context.collection.objects.link(cam_obj)
scene.camera = cam_obj

def set_camera_view(loc, target=(0.0, 0.0, 0.70), persp=True, lens=50.0, ortho_scale=4.65):
    cam_obj.location = loc
    cam_obj.rotation_euler = (Vector(target) - Vector(loc)).to_track_quat('-Z', 'Y').to_euler()
    if persp:
        cam_data.type = 'PERSP'
        cam_data.lens = lens
    else:
        cam_data.type = 'ORTHO'
        cam_data.ortho_scale = ortho_scale

def render_image(filename, w=1920, h=1080, samples=48):
    scene.render.resolution_x = w
    scene.render.resolution_y = h
    scene.render.resolution_percentage = 100
    scene.cycles.samples = samples
    scene.render.filepath = os.path.join(WORK_DIR, filename)
    bpy.ops.render.render(write_still=True)
    print(f"Rendered {filename}")

# 1. 3/4 Front Hero View (matches sheet.jpg bottom-right 3/4 front view!)
set_camera_view((3.95, -5.05, 1.50), target=(0.0, -0.05, 0.70), persp=True, lens=50.0)

# Render clay.png first
mat_clay = bpy.data.materials.new("Clay")
bsdf_c = mat_clay.node_tree.nodes.get("Principled BSDF")
bsdf_c.inputs['Base Color'].default_value = (0.50, 0.50, 0.52, 1.0)
bsdf_c.inputs['Roughness'].default_value = 0.55
scene.view_layers[0].material_override = mat_clay
render_image("clay.png", 1920, 1080, samples=36)
scene.view_layers[0].material_override = None

# Render final.png (in default Deep Red [0.55, 0.03, 0.03])
render_image("final.png", 1920, 1080, samples=64)

# 2. Orthographic Left Side View (nose pointing Left, rear pointing Right, matching sheet.jpg top profile!)
set_camera_view((6.0, 0.0, 0.73), target=(0.0, 0.0, 0.73), persp=False, ortho_scale=4.65)
render_image("side.png", 1920, 1080, samples=48)

# 3. Render paints.png (3x2 grid of 3/4 front view across the 6 paints in nodes.json)
set_camera_view((3.95, -5.05, 1.50), target=(0.0, -0.05, 0.70), persp=True, lens=50.0)
paint_factor_node = mat_paint.node_tree.nodes.get("PaintFactor")
tiles = []
for idx, rgb in enumerate(PAINTS):
    paint_factor_node.inputs['B'].default_value = (rgb[0], rgb[1], rgb[2], 1.0)
    t_name = f"_tile_{idx}.png"
    render_image(t_name, 640, 360, samples=28)
    im = bpy.data.images.load(os.path.join(WORK_DIR, t_name), check_existing=False)
    arr = np.array(im.pixels[:], dtype=np.float32).reshape((360, 640, 4))
    tiles.append(arr)
    bpy.data.images.remove(im)
    os.remove(os.path.join(WORK_DIR, t_name))

grid = np.zeros((720, 1920, 4), dtype=np.float32)
for idx, arr in enumerate(tiles):
    row = 1 - (idx // 3)
    col = idx % 3
    grid[row*360:(row+1)*360, col*640:(col+1)*640, :] = arr

img_grid = bpy.data.images.new("PaintsGrid", width=1920, height=720, alpha=True)
img_grid.pixels.foreach_set(grid.ravel())
img_grid.filepath_raw = os.path.join(WORK_DIR, "paints.png")
img_grid.file_format = 'PNG'
img_grid.save()
print("Saved paints.png (1920x720 3x2 grid)")

paint_factor_node.inputs['B'].default_value = (DEFAULT_PAINT[0], DEFAULT_PAINT[1], DEFAULT_PAINT[2], 1.0)
print("ALL DONE!")
