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:
#   +X = Left side, -X = Right side
#   +Y = Up, 0 = Ground
#   +Z = Front, -Z = Rear
# 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=36.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.09
        math_var.inputs[2].default_value = 0.88
        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.74, 0.73, 0.71, 1.0)

        math_f = nt.nodes.new('ShaderNodeMath')
        math_f.operation = 'MULTIPLY'
        math_f.inputs[1].default_value = 0.28
        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.14
        mix_rough.inputs['B'].default_value = 0.30
        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.06

        nor_map = nt.nodes.new('ShaderNodeNormalMap')
        nor_map.inputs['Strength'].default_value = 0.05
        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.91, 0.92, 0.94, 1.0)
        mix_col.inputs['B'].default_value = (0.64, 0.65, 0.67, 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.98
        math_r = nt.nodes.new('ShaderNodeMath')
        math_r.operation = 'MULTIPLY'
        math_r.inputs[1].default_value = 0.12
        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.82
        nt.links.new(img_diff.outputs['Color'], mix_col.inputs['A'])
        mix_col.inputs['B'].default_value = (0.025, 0.026, 0.028, 1.0)
        nt.links.new(mix_col.outputs['Result'], bsdf.inputs['Base Color'])

        bsdf.inputs['Metallic'].default_value = 0.0
        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.58
        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.55
        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.72
        nt.links.new(img_diff.outputs['Color'], mix_col.inputs['A'])
        mix_col.inputs['B'].default_value = (0.09, 0.08, 0.075, 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.48
        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.55
        nt.links.new(img_nor.outputs['Color'], nor_map.inputs['Color'])
        nt.links.new(nor_map.outputs['Normal'], bsdf.inputs['Normal'])

    elif is_underbody:
        # Dark charcoal underbody chassis coating with subtle metallic road grime (no bright orange)
        mix_col = nt.nodes.new('ShaderNodeMix')
        mix_col.data_type = 'RGBA'
        mix_col.inputs['Factor'].default_value = 0.85
        nt.links.new(img_diff.outputs['Color'], mix_col.inputs['A'])
        mix_col.inputs['B'].default_value = (0.038, 0.038, 0.040, 1.0)
        nt.links.new(mix_col.outputs['Result'], bsdf.inputs['Base Color'])

        bsdf.inputs['Metallic'].default_value = 0.35
        math_r = nt.nodes.new('ShaderNodeMath')
        math_r.operation = 'MULTIPLY_ADD'
        math_r.inputs[1].default_value = 0.30
        math_r.inputs[2].default_value = 0.55
        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.60
        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.55, 0.66, 0.70, 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.24
if 'IOR' in bsdf_g.inputs:
    bsdf_g.inputs['IOR'].default_value = 1.48

# Lamps material with a textured 256px fluted lens atlas
# (Bottom third row=0..85 -> v=0..0.33: Warm white fluted headlamp; Middle row=85..170 -> v=0.33..0.66: Amber indicator; Top row=170..256 -> v=0.66..1.0: Red tail light!)
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.76 + 0.24 * np.sin(x_grid * 2.0 * np.pi * 28.0) # vertical lens flutes
for row in range(256):
    if row < 90:
        # Bottom (v = 0.0 .. 0.35): Warm white/silver fluted glass headlamp!
        y_rel = (row - 45.0) / 45.0
        c = np.array([0.92, 0.94, 0.90, 1.0])
    elif row < 170:
        # Middle (v = 0.35 .. 0.66): Amber indicator
        y_rel = (row - 127.0) / 42.0
        c = np.array([0.96, 0.52, 0.08, 1.0])
    else:
        # Top (v = 0.66 .. 1.0): Deep ruby red tail light
        y_rel = (row - 212.0) / 42.0
        c = np.array([0.86, 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.84 + 0.16 * math.cos(r_dist * math.pi * 6.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.65
bsdf_l.inputs['Roughness'].default_value = 0.04
bsdf_l.inputs['Metallic'].default_value = 0.30
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.00m x 1.58m x 1.42m, Wheelbase 2.40m)
# ==============================================================================
WHEEL_R = 0.285
TRACK_HALF = 0.655
AXLE_Z_F = 1.20
AXLE_Z_R = -1.20

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

# ------------------------------------------------------------------------------
# 5A. MAIN PAINTED BODY SHELL WITH HOLLOW SEE-THROUGH CABIN GREENHOUSE!
# ------------------------------------------------------------------------------
def eval_lower_body_section(z):
    zn = z / 1.91
    plan_taper = 1.0 - 0.095 * (zn ** 2) - 0.02 * (zn ** 4)
    if z > 1.70:
        t_front = (z - 1.70) / 0.21
        plan_taper *= (1.0 - 0.08 * (t_front ** 2))
    elif z < -1.72:
        t_rear = (-1.72 - z) / 0.19
        plan_taper *= (1.0 - 0.07 * (t_rear ** 2))

    arch_f = math.exp(-((z - AXLE_Z_F) / 0.34) ** 2)
    arch_r = math.exp(-((z - AXLE_Z_R) / 0.34) ** 2)
    arch_flare = 0.022 * 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.350:
            h_arc = math.sqrt(max(0.0, 0.350**2 - dz**2))
            cut_y = max(cut_y, 0.285 + h_arc * 0.98)

    y_bot = 0.230
    if z > 1.45:
        y_bot += 0.095 * ((z - 1.45) / 0.46) ** 1.6
    elif z < -1.40:
        y_bot += 0.105 * ((-1.40 - z) / 0.51) ** 1.6

    y_sill = max(y_bot + 0.035, cut_y)
    y_flank_low = max(0.47, y_sill + 0.045)

    y_belt = 0.848 - 0.012 * zn
    if z > 1.65:
        y_belt -= 0.028 * ((z - 1.65) / 0.26) ** 1.5
    elif z < -1.65:
        y_belt -= 0.022 * ((-1.65 - z) / 0.26) ** 1.5
    y_waist = y_belt + 0.032

    fender_pod = 0.0
    if z > 1.10:
        fender_pod = 0.024 * math.sin(min(1.0, (z - 1.10) / 0.81) * math.pi * 0.65)

    x_shoulder = 0.758 * plan_taper + arch_flare * 0.35
    x_flank_low = 0.748 * plan_taper + arch_flare
    x_sill = (0.685 if cut_y < 0.29 else 0.738) * plan_taper + arch_flare * 0.9
    x_rocker_in = 0.58 * plan_taper
    x_waist = 0.712 * plan_taper
    # Add a dedicated inner door sill top lip at x_sill_in = 0.655 so the painted door top is thick & level before dropping into the cabin!
    x_sill_in = 0.655 * plan_taper
    x_deck_in = 0.540 * plan_taper

    if z >= 0.58:
        t_hood = min(1.0, (z - 0.58) / (1.82 - 0.58))
        y_center = 0.882 - 0.088 * (t_hood ** 1.25)
        if z > 1.80:
            t_nose = (z - 1.80) / (1.91 - 1.80)
            y_center -= 0.085 * (t_nose ** 1.7)
        y_sill_in = y_waist + fender_pod * 0.6
        y_inner = y_center - 0.004
    elif z <= -1.04:
        t_trunk = min(1.0, (-1.04 - z) / (1.82 - 1.04))
        y_center = 0.878 - 0.068 * (t_trunk ** 1.3)
        if z < -1.80:
            t_tail = (-1.80 - z) / (1.91 - 1.80)
            y_center -= 0.075 * (t_tail ** 1.7)
        y_sill_in = y_waist
        y_inner = y_center - 0.004
    else:
        # Open cabin zone (-1.04 < z < +0.58): keep y_sill_in at y_waist so the door top edge is 100% solid painted metal!
        d_edge = min(z - (-1.04), 0.58 - z)
        blend = min(1.0, d_edge / 0.08)
        y_cabin_well = 0.40
        y_sill_in = y_waist
        y_inner = (1.0 - blend) * 0.875 + blend * (y_cabin_well + 0.02)
        y_center = (1.0 - blend) * 0.880 + 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 + fender_pod),
        (x_sill_in, y_sill_in),
        (x_deck_in, y_inner),
        (0.0, y_center),
    ]

def build_lower_body_and_greenhouse():
    bm = bmesh.new()
    N_Z = 104
    z_list = [-1.91 + 3.82 * (i / N_Z) for i in range(N_Z + 1)]

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

    bm.verts.ensure_lookup_table()
    ring_len = len(rings[0])

    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]))

    for end_idx, z_cap, flip in ((0, -1.915, True), (-1, 1.915, False)):
        ring = rings[end_idx]
        avg_y = sum(v.co.z for v in ring) / len(ring)
        c_v = bm.verts.new(gv(0.0, avg_y, z_cap))
        for j in range(ring_len):
            jn = (j + 1) % ring_len
            if flip:
                bm.faces.new((ring[jn], ring[j], c_v))
            else:
                bm.faces.new((ring[j], ring[jn], c_v))

    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 Roof Panel (Z from -0.755 to +0.275, crowning at Y = 1.420m)
    bm_roof = bmesh.new()
    nz_r = 24
    nx_r = 20
    top_grid = []
    bot_grid = []
    for iz in range(nz_r + 1):
        v = iz / nz_r
        z_base = -0.755 + 1.030 * v
        arch_z = 4.0 * v * (1.0 - v)
        half_w = 0.525 - 0.015 * v + 0.015 * arch_z
        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.028 * (u ** 4)
            y_top = 1.372 + 0.028 * arch_x + 0.020 * arch_z - edge_drop
            z_curv = z_base + (0.020 if v > 0.5 else -0.016) * (1.0 - u**2) * (abs(2*v - 1)**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.020, 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)

build_lower_body_and_greenhouse()

# ------------------------------------------------------------------------------
# 5B. THIN A-PILLARS, B-PILLARS, AND SCULPTED C-PILLARS
# ------------------------------------------------------------------------------
def make_sculpted_pillar(name, spine_pts, radii_x, radii_z, mat=mat_paint_src):
    bm = bmesh.new()
    n = len(spine_pts)
    sides = 12
    rings = []
    for i in range(n):
        p = gv(*spine_pts[i])
        rx = radii_x[i]
        rz = radii_z[i]
        ring = []
        for s in range(sides):
            ang = 2.0 * math.pi * s / sides
            ox = math.cos(ang) * rx
            oz = math.sin(ang) * rz
            ring.append(bm.verts.new(p + gv(ox, 0.0, oz)))
        rings.append(ring)

    for i in range(n - 1):
        r0, r1 = rings[i], 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(gv(*spine_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))

    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)

def build_cabin_pillars():
    for side in (-1.0, 1.0):
        tag = "L" if side > 0 else "R"

        # 1. Thin A-Pillar
        a_pts = [
            (side * 0.675, 0.855, 0.565),
            (side * 0.635, 1.020, 0.468),
            (side * 0.585, 1.195, 0.368),
            (side * 0.530, 1.372, 0.268),
        ]
        make_sculpted_pillar(f"APillar_{tag}", a_pts, [0.024, 0.018, 0.017, 0.020], [0.032, 0.024, 0.022, 0.026], mat_paint_src)

        # 2. Thin B-Pillar
        b_pts = [
            (side * 0.695, 0.855, -0.185),
            (side * 0.648, 1.030, -0.185),
            (side * 0.596, 1.205, -0.185),
            (side * 0.542, 1.378, -0.185),
        ]
        make_sculpted_pillar(f"BPillar_{tag}", b_pts, [0.020, 0.016, 0.015, 0.018], [0.024, 0.020, 0.019, 0.022], mat_paint_src)

        # 3. Classic C-Pillar
        c_pts = [
            (side * 0.665, 0.855, -1.005),
            (side * 0.625, 1.025, -0.915),
            (side * 0.578, 1.200, -0.825),
            (side * 0.528, 1.370, -0.745),
        ]
        make_sculpted_pillar(f"CPillar_{tag}", c_pts, [0.028, 0.022, 0.020, 0.022], [0.068, 0.052, 0.040, 0.034], mat_paint_src)

build_cabin_pillars()

# ------------------------------------------------------------------------------
# 5C. WHEEL ARCH FENDER LIPS & DEEP RECESSED WHEEL WELLS
# ------------------------------------------------------------------------------
def build_wheel_arches_and_wells():
    bm_lip = bmesh.new()
    bm_well = bmesh.new()

    for side in (-1.0, 1.0):
        for z_ax in (AXLE_Z_F, AXLE_Z_R):
            n_seg = 28
            r_lip_in = 0.318
            r_lip_out = 0.342
            x_outer = side * 0.750
            x_lip_in = side * 0.705
            # Keep wheel well strictly recessed inside x = +/- 0.650 and r = 0.295 so it never pokes outside the fender!
            x_well_out = side * 0.650
            x_well_deep = side * 0.480

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

            for i in range(n_seg + 1):
                ang = math.radians(5.0 + 170.0 * (i / n_seg))
                cz = z_ax + math.cos(ang) * r_lip_in
                cy = 0.285 + math.sin(ang) * r_lip_in
                cz_o = z_ax + math.cos(ang) * r_lip_out
                cy_o = 0.285 + math.sin(ang) * r_lip_out

                lip_ring_0.append(bm_lip.verts.new(gv(x_lip_in, cy, cz)))
                lip_ring_1.append(bm_lip.verts.new(gv(x_outer + side * 0.008, cy + 0.006 * math.sin(ang), cz)))
                lip_ring_2.append(bm_lip.verts.new(gv(x_outer - side * 0.004, cy_o, cz_o)))

                cz_w = z_ax + math.cos(ang) * 0.295
                cy_w = 0.285 + math.sin(ang) * 0.295
                well_ring_out.append(bm_well.verts.new(gv(x_well_out, cy_w, cz_w)))
                well_ring_in.append(bm_well.verts.new(gv(x_well_deep, cy_w, cz_w)))

            for i in range(n_seg):
                bm_lip.faces.new((lip_ring_0[i], lip_ring_1[i], lip_ring_1[i+1], lip_ring_0[i+1]))
                bm_lip.faces.new((lip_ring_1[i], lip_ring_2[i], lip_ring_2[i+1], lip_ring_1[i+1]))
                bm_well.faces.new((well_ring_in[i], well_ring_out[i], well_ring_out[i+1], well_ring_in[i+1]))

            c_well = bm_well.verts.new(gv(x_well_deep, 0.285, z_ax))
            for i in range(n_seg):
                bm_well.faces.new((well_ring_in[i+1], well_ring_in[i], 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 & SMOOTH CHROME WINDOW SURROUND FRAMES
# ------------------------------------------------------------------------------
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.012):
    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]))

    # Extract smooth perimeter points with rounded corners for the chrome frame tube!
    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)

    # Smooth the perimeter loop (Chaikin/laplacian corner rounding) so corners have zero self-intersections
    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, mat_chrome_src)

# Front Windshield (seated cleanly at cowl Y=0.878)
build_window_with_frame(
    "Windshield",
    corners_game=[
        (0.650, 0.876, 0.562),
        (-0.650, 0.876, 0.562),
        (-0.518, 1.370, 0.270),
        (0.518, 1.370, 0.270),
    ],
    normal_game=(0.0, 0.58, 0.81),
    frame_radius=0.010, glass_inset=0.003, subdiv_u=14, subdiv_v=8, bulge=0.024
)

# Rear Backlight Window
build_window_with_frame(
    "RearWindow",
    corners_game=[
        (-0.635, 0.876, -1.010),
        (0.635, 0.876, -1.010),
        (0.508, 1.365, -0.745),
        (-0.508, 1.365, -0.745),
    ],
    normal_game=(0.0, 0.56, -0.83),
    frame_radius=0.010, glass_inset=0.003, subdiv_u=14, subdiv_v=8, bulge=0.022
)

# Side Windows (seated flush on the painted door sill at Y=0.872!)
for side_sign, side_tag in ((1.0, "L"), (-1.0, "R")):
    c_fd = [
        (side_sign * 0.678, 0.872, 0.542),
        (side_sign * 0.695, 0.872, -0.168),
        (side_sign * 0.542, 1.368, -0.168),
        (side_sign * 0.530, 1.366, 0.252),
    ]
    if side_sign < 0:
        c_fd = [c_fd[1], c_fd[0], c_fd[3], c_fd[2]]
    build_window_with_frame(
        f"SideWin_Front_{side_tag}",
        corners_game=c_fd,
        normal_game=(side_sign * 0.96, 0.28, 0.0),
        frame_radius=0.0085, glass_inset=0.003, subdiv_u=12, subdiv_v=6, bulge=0.007
    )

    c_rq = [
        (side_sign * 0.695, 0.872, -0.202),
        (side_sign * 0.662, 0.872, -0.958),
        (side_sign * 0.530, 1.364, -0.732),
        (side_sign * 0.542, 1.368, -0.202),
    ]
    if side_sign < 0:
        c_rq = [c_rq[1], c_rq[0], c_rq[3], c_rq[2]]
    build_window_with_frame(
        f"SideWin_Rear_{side_tag}",
        corners_game=c_rq,
        normal_game=(side_sign * 0.96, 0.28, 0.0),
        frame_radius=0.0085, glass_inset=0.003, subdiv_u=12, subdiv_v=6, bulge=0.007
    )

# ------------------------------------------------------------------------------
# 5E. TRIMS, RAILS, MIRRORS, HANDLES, WIPERS, SHUTLINES
# ------------------------------------------------------------------------------
def make_tube_along_game_points(name, pts_game, radius=0.008, scale_up=1.0, sides=10, mat=mat_chrome_src):
    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))

    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)

def make_box_game(name, center_game, half_extents_game, mat, bevel_w=0.004, rot_x_deg=0.0):
    bm = bmesh.new()
    bmesh.ops.create_cube(bm, size=2.0)
    hx, hy, hz = half_extents_game
    for v in bm.verts:
        gx = v.co.x * hx
        gy = v.co.y * hy
        gz = v.co.z * hz
        if abs(rot_x_deg) > 1e-4:
            rad = math.radians(rot_x_deg)
            ny = gy * math.cos(rad) - gz * math.sin(rad)
            nz = gy * math.sin(rad) + gz * math.cos(rad)
            gy, gz = ny, nz
        v.co = gv(center_game[0] + gx, center_game[1] + gy, center_game[2] + gz)
    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)

def build_exterior_trims_and_fittings():
    for side in (-1.0, 1.0):
        tag = "L" if side > 0 else "R"

        # Vent window vertical chrome bar inside front door window (at Z = +0.34)
        make_tube_along_game_points(f"VentBar_{tag}", [
            (side * 0.680, 0.875, 0.340),
            (side * 0.612, 1.120, 0.310),
            (side * 0.535, 1.365, 0.250),
        ], radius=0.0055, scale_up=1.2, sides=8, mat=mat_chrome_src)

        # Roof rain gutter / drip rail
        make_tube_along_game_points(f"DripRail_{tag}", [
            (side * 0.675, 0.885, 0.555),
            (side * 0.605, 1.140, 0.405),
            (side * 0.535, 1.375, 0.265),
            (side * 0.545, 1.382, -0.185),
            (side * 0.532, 1.372, -0.740),
            (side * 0.605, 1.135, -0.870),
            (side * 0.668, 0.885, -0.985),
        ], radius=0.006, scale_up=0.9, sides=8, mat=mat_chrome_src)

        # Rocker sill chrome strip
        make_tube_along_game_points(f"SillTrim_{tag}", [
            (side * 0.725, 0.265, -0.82),
            (side * 0.732, 0.262, 0.0),
            (side * 0.725, 0.265, 0.82),
        ], radius=0.007, scale_up=1.3, sides=8, mat=mat_chrome_src)

        # Door shutline seam
        make_tube_along_game_points(f"DoorSeam_{tag}", [
            (side * 0.685, 0.875, 0.530),
            (side * 0.752, 0.835, 0.530),
            (side * 0.748, 0.540, 0.530),
            (side * 0.728, 0.285, 0.530),
            (side * 0.732, 0.282, -0.185),
            (side * 0.754, 0.540, -0.185),
            (side * 0.758, 0.835, -0.185),
            (side * 0.695, 0.875, -0.185),
        ], radius=0.0032, scale_up=1.0, sides=6, mat=mat_rubber_src)

        # Chrome Door Handle
        make_tube_along_game_points(f"DoorHandle_{tag}", [
            (side * 0.752, 0.815, -0.015),
            (side * 0.782, 0.815, -0.035),
            (side * 0.784, 0.815, -0.125),
            (side * 0.752, 0.815, -0.145),
        ], radius=0.0075, scale_up=0.85, sides=10, mat=mat_chrome_src)

        # Side repeater amber lamp on front fender
        bm_rep = bmesh.new()
        bmesh.ops.create_uvsphere(bm_rep, u_segments=16, v_segments=10, radius=0.016)
        for v in bm_rep.verts:
            v.co = gv(side * 0.742 + v.co.x * 0.6, 0.755 + v.co.y, 1.48 + v.co.z)
        set_uv_region(bm_rep, 0.35, 0.65) # Amber
        bmesh.ops.recalc_face_normals(bm_rep, faces=bm_rep.faces)
        obj_rep = new_mesh_obj(f"SideRepeater_{tag}", bm_rep)
        shade_smooth_auto(obj_rep, 45.0)
        add_body_part(obj_rep, mat_lamps, is_lamp=True)

        # C-pillar louvre plastic trim ("PLASTIC TRIM" on sheet.jpg)
        make_box_game(f"CPillarVent_{tag}", (side * 0.665, 0.872, -0.965), (0.014, 0.010, 0.080), mat_rubber_src, bevel_w=0.003)

        # Classic Chrome Wing Mirror (outer edge at X = +/- 0.7900m so total width is 1.5800m!)
        make_tube_along_game_points(f"MirrorStalk_{tag}", [
            (side * 0.695, 0.885, 0.445),
            (side * 0.742, 0.935, 0.435),
            (side * 0.758, 0.955, 0.420),
        ], radius=0.0055, sides=8, mat=mat_chrome_src)

        bm_mir = bmesh.new()
        bmesh.ops.create_uvsphere(bm_mir, u_segments=20, v_segments=12, radius=0.035)
        for v in bm_mir.verts:
            v.co = gv(side * 0.758 + v.co.x * 0.9, 0.962 + v.co.y * 0.80, 0.415 + v.co.z * 0.35)
        bmesh.ops.recalc_face_normals(bm_mir, faces=bm_mir.faces)
        obj_mir = new_mesh_obj(f"MirrorHead_{tag}", bm_mir)
        shade_smooth_auto(obj_mir, 45.0)
        add_body_part(obj_mir, mat_chrome_src)

    # Hood & Trunk Shutlines
    make_tube_along_game_points("HoodSeam", [
        (0.58, 0.878, 0.58),
        (0.56, 0.848, 1.25),
        (0.52, 0.798, 1.78),
        (0.0,  0.802, 1.80),
        (-0.52, 0.798, 1.78),
        (-0.56, 0.848, 1.25),
        (-0.58, 0.878, 0.58),
    ], radius=0.003, sides=6, mat=mat_rubber_src)

    make_tube_along_game_points("TrunkSeam", [
        (0.56, 0.874, -1.04),
        (0.54, 0.842, -1.50),
        (0.50, 0.812, -1.81),
        (0.0,  0.815, -1.82),
        (-0.50, 0.812, -1.81),
        (-0.54, 0.842, -1.50),
        (-0.56, 0.874, -1.04),
    ], radius=0.003, sides=6, mat=mat_rubber_src)

    for wx in (-0.22, 0.16):
        make_tube_along_game_points(f"Wiper_{wx:.2f}", [
            (wx, 0.880, 0.565),
            (wx + 0.14, 0.945, 0.510),
            (wx + 0.28, 1.005, 0.460),
        ], radius=0.0045, sides=8, mat=mat_chrome_src)

build_exterior_trims_and_fittings()

# ------------------------------------------------------------------------------
# 5F. FRONT & REAR FACES: HEADLAMPS (WITH SILVER REFLECTOR BOWL), TRIM, TAIL LAMPS
# ------------------------------------------------------------------------------
def build_front_and_rear_clusters():
    for side in (-1.0, 1.0):
        tag = "L" if side > 0 else "R"
        hx = side * 0.545
        hy = 0.675
        hz = 1.885

        # Chrome Headlamp Bezel Ring + Painted Pod Housing extending into front fender
        bm_bz = bmesh.new()
        n_seg = 36
        r_in = 0.098
        r_out = 0.125
        ring_a, ring_b, ring_c, ring_d = [], [], [], []
        for i in range(n_seg):
            ang = 2.0 * math.pi * i / n_seg
            ca, sa = math.cos(ang), math.sin(ang)
            ring_a.append(bm_bz.verts.new(gv(hx + ca*r_out, hy + sa*r_out, hz - 0.045)))
            ring_b.append(bm_bz.verts.new(gv(hx + ca*r_out, hy + sa*r_out, hz + 0.024)))
            ring_c.append(bm_bz.verts.new(gv(hx + ca*r_in, hy + sa*r_in, hz + 0.036)))
            ring_d.append(bm_bz.verts.new(gv(hx + ca*r_in, hy + sa*r_in, hz - 0.025)))

        for i in range(n_seg):
            inext = (i + 1) % n_seg
            bm_bz.faces.new((ring_a[i], ring_a[inext], ring_b[inext], ring_b[i]))
            bm_bz.faces.new((ring_b[i], ring_b[inext], ring_c[inext], ring_c[i]))
            bm_bz.faces.new((ring_c[i], ring_c[inext], ring_d[inext], ring_d[i]))

        # Inner silver parabolic reflector bowl + center bulb cap
        c_bowl = bm_bz.verts.new(gv(hx, hy, hz - 0.040))
        for i in range(n_seg):
            inext = (i + 1) % n_seg
            bm_bz.faces.new((ring_d[i], ring_d[inext], c_bowl))

        bmesh.ops.recalc_face_normals(bm_bz, faces=bm_bz.faces)
        obj_bz = new_mesh_obj(f"HeadlampBezel_{tag}", bm_bz)
        shade_smooth_auto(obj_bz, 45.0)
        add_body_part(obj_bz, mat_chrome_src)

        # Curved Headlight Lens (warm white fluted glass, v in [0.04, 0.30])
        bm_hl = bmesh.new()
        bmesh.ops.create_uvsphere(bm_hl, u_segments=24, v_segments=12, radius=0.099)
        for v in list(bm_hl.verts):
            if v.co.z < -0.01:
                bm_hl.verts.remove(v)
            else:
                v.co = gv(hx + v.co.x, hy + v.co.y, hz + 0.014 + v.co.z * 0.34)
        set_uv_region(bm_hl, 0.04, 0.30) # Bottom third = warm white/silver fluted headlamp!
        bmesh.ops.recalc_face_normals(bm_hl, faces=bm_hl.faces)
        obj_hl = new_mesh_obj(f"HeadlampLens_{tag}", bm_hl)
        shade_smooth_auto(obj_hl, 45.0)
        add_body_part(obj_hl, mat_lamps, is_lamp=True)

        # Chrome Bezel + Front Indicator below headlamp
        make_box_game(f"FrontIndBezel_{tag}", (hx, 0.515, 1.902), (0.058, 0.023, 0.016), mat_chrome_src, bevel_w=0.004)
        bm_ind = bmesh.new()
        bmesh.ops.create_cube(bm_ind, size=2.0)
        for v in bm_ind.verts:
            v.co = gv(hx + v.co.x * 0.050, 0.515 + v.co.y * 0.017, 1.908 + v.co.z * 0.014)
        set_uv_region(bm_ind, 0.36, 0.64) # Middle third = Amber indicator!
        bmesh.ops.recalc_face_normals(bm_ind, faces=bm_ind.faces)
        obj_ind = new_mesh_obj(f"FrontIndicator_{tag}", bm_ind)
        add_bevel(obj_ind, 0.004, 2)
        shade_smooth_auto(obj_ind, 40.0)
        add_body_part(obj_ind, mat_lamps, is_lamp=True)

    # Front Whisker Chrome Bar & Center Crest
    make_tube_along_game_points("FrontWhiskerBar", [
        (-0.36, 0.655, 1.915),
        (-0.18, 0.658, 1.922),
        (0.0, 0.660, 1.925),
        (0.18, 0.658, 1.922),
        (0.36, 0.655, 1.915),
    ], radius=0.009, scale_up=1.1, sides=10, mat=mat_chrome_src)

    make_box_game("FrontCrest", (0.0, 0.660, 1.924), (0.024, 0.034, 0.012), mat_chrome_src, bevel_w=0.005)
    # Recessed dark front cooling slot
    make_box_game("FrontCoolingSlot", (0.0, 0.465, 1.885), (0.34, 0.020, 0.010), mat_rubber_src, bevel_w=0.003)

    # Rear Tail Lights + Chrome Bezel
    for side in (-1.0, 1.0):
        tag = "L" if side > 0 else "R"
        tx = side * 0.565
        ty = 0.675
        tz = -1.895

        bm_tb = bmesh.new()
        n_seg = 28
        r_in, r_out = 0.052, 0.068
        ra, rb, rc = [], [], []
        for i in range(n_seg):
            ang = 2.0 * math.pi * i / n_seg
            ca, sa = math.cos(ang), math.sin(ang)
            ra.append(bm_tb.verts.new(gv(tx + ca*r_out, ty + sa*r_out, tz + 0.015)))
            rb.append(bm_tb.verts.new(gv(tx + ca*r_out, ty + sa*r_out, tz - 0.020)))
            rc.append(bm_tb.verts.new(gv(tx + ca*r_in,  ty + sa*r_in,  tz - 0.025)))
        for i in range(n_seg):
            inext = (i + 1) % n_seg
            bm_tb.faces.new((ra[i], ra[inext], rb[inext], rb[i]))
            bm_tb.faces.new((rb[i], rb[inext], rc[inext], rc[i]))
        bmesh.ops.recalc_face_normals(bm_tb, faces=bm_tb.faces)
        obj_tb = new_mesh_obj(f"TailBezel_{tag}", bm_tb)
        shade_smooth_auto(obj_tb, 45.0)
        add_body_part(obj_tb, mat_chrome_src)

        bm_tl = bmesh.new()
        bmesh.ops.create_uvsphere(bm_tl, u_segments=24, v_segments=12, radius=0.054)
        for v in list(bm_tl.verts):
            if v.co.z < -0.01:
                bm_tl.verts.remove(v)
            else:
                v.co = gv(tx + v.co.x, ty + v.co.y, tz - 0.015 - v.co.z * 0.45)
        set_uv_region(bm_tl, 0.68, 0.96) # Top third = Red tail lamp!
        bmesh.ops.recalc_face_normals(bm_tl, faces=bm_tl.faces)
        obj_tl = new_mesh_obj(f"TailLens_{tag}", bm_tl)
        shade_smooth_auto(obj_tl, 45.0)
        add_body_part(obj_tl, mat_lamps, is_lamp=True)

    make_tube_along_game_points("RearTrunkHandle", [
        (-0.22, 0.645, -1.912),
        (0.0, 0.648, -1.916),
        (0.22, 0.645, -1.912),
    ], radius=0.008, scale_up=1.1, sides=10, mat=mat_chrome_src)

build_front_and_rear_clusters()

# ------------------------------------------------------------------------------
# 5G. WRAPAROUND CHROME BUMPERS WITH OVERRIDERS (Z = +/- 2.000 m)
# ------------------------------------------------------------------------------
def build_chrome_bumpers():
    for is_front in (True, False):
        z_sign = 1.0 if is_front else -1.0
        tag = "Front" if is_front else "Rear"

        blade_pts = []
        n_pts = 28
        for i in range(n_pts + 1):
            u = -1.0 + 2.0 * (i / n_pts)
            if abs(u) < 0.78:
                x = u * (0.735 / 0.78)
                z = z_sign * (1.965 - 0.045 * (u / 0.78)**2)
            else:
                w = (abs(u) - 0.78) / 0.22
                ang = w * (math.pi * 0.48)
                x_sign = 1.0 if u > 0 else -1.0
                x = x_sign * (0.715 + 0.055 * math.sin(ang))
                z = z_sign * (1.920 - 0.22 * (1.0 - math.cos(ang)))
            blade_pts.append((x, 0.415, z))

        make_tube_along_game_points(f"BumperBlade_{tag}", blade_pts, radius=0.026, scale_up=1.35, sides=14, mat=mat_chrome_src)

        for sx in (-1.0, 1.0):
            ox = sx * 0.41
            oz = z_sign * 1.968
            make_box_game(f"Overrider_{tag}_{sx:.0f}", (ox, 0.425, oz), (0.026, 0.066, 0.028), mat_chrome_src, bevel_w=0.007)
            make_box_game(f"OverriderPad_{tag}_{sx:.0f}", (ox, 0.425, z_sign * 1.990), (0.020, 0.054, 0.010), mat_rubber_src, bevel_w=0.003)
            make_tube_along_game_points(f"BumperIron_{tag}_{sx:.0f}", [
                (ox, 0.405, z_sign * 1.84),
                (ox, 0.410, z_sign * 1.95),
            ], radius=0.012, sides=8, mat=mat_underbody_src)

build_chrome_bumpers()

# ------------------------------------------------------------------------------
# 5H. UNDERBODY CHASSIS RAILS, AXLES & EXHAUST
# ------------------------------------------------------------------------------
def build_underbody_and_exhaust():
    for sx in (-0.36, 0.36):
        make_tube_along_game_points(f"ChassisRail_{sx:.2f}", [
            (sx, 0.235, -1.65),
            (sx, 0.232, 0.0),
            (sx, 0.235, 1.65),
        ], radius=0.024, scale_up=0.75, sides=8, mat=mat_underbody_src)

    for az, tag in ((AXLE_Z_F, "Front"), (AXLE_Z_R, "Rear")):
        make_tube_along_game_points(f"AxleBeam_{tag}", [
            (-0.62, WHEEL_R, az),
            (0.62, WHEEL_R, az),
        ], radius=0.022, sides=10, mat=mat_underbody_src)

    make_tube_along_game_points("ExhaustMuffler", [
        (0.28, 0.240, -1.30),
        (0.28, 0.240, -1.65),
    ], radius=0.045, scale_up=0.75, sides=14, mat=mat_underbody_src)

    make_tube_along_game_points("ExhaustTailpipe", [
        (0.28, 0.238, -1.62),
        (0.28, 0.235, -1.95),
    ], radius=0.017, sides=12, mat=mat_chrome_src)

build_underbody_and_exhaust()

# ------------------------------------------------------------------------------
# 5I. FURNISHED INTERIOR & SEPARATE STEERING WHEEL (VISIBLE THROUGH WINDOWS!)
# ------------------------------------------------------------------------------
def build_interior_and_steering():
    make_box_game("Interior_Floor", (0.0, 0.34, -0.22), (0.62, 0.025, 0.80), mat_interior_src, bevel_w=0.005)
    for side in (-1.0, 1.0):
        make_box_game(f"Interior_DoorCard_{side:.0f}", (side * 0.655, 0.60, -0.22), (0.018, 0.25, 0.74), mat_interior_src, bevel_w=0.006)

    # Dashboard & Instrument Binnacle (prominently visible through windshield)
    make_box_game("Interior_Dashboard", (0.0, 0.81, 0.44), (0.61, 0.075, 0.11), mat_interior_src, bevel_w=0.022)
    make_box_game("Interior_Binnacle", (0.28, 0.885, 0.41), (0.125, 0.055, 0.09), mat_interior_src, bevel_w=0.015)

    # Front Bucket Seats (tall backrests reaching Y = 1.08m so they silhouette clearly in side & front views!)
    for sx, stag in ((0.27, "L"), (-0.27, "R")):
        make_box_game(f"SeatCushion_{stag}", (sx, 0.48, -0.02), (0.21, 0.075, 0.22), mat_interior_src, bevel_w=0.022)
        make_box_game(f"SeatBack_{stag}", (sx, 0.79, -0.20), (0.20, 0.28, 0.055), mat_interior_src, bevel_w=0.022, rot_x_deg=-15.0)

    # Rear Bench Seat (reaching Y = 1.06m in rear window)
    make_box_game("RearBenchCushion", (0.0, 0.50, -0.64), (0.57, 0.075, 0.20), mat_interior_src, bevel_w=0.022)
    make_box_game("RearBenchBack", (0.0, 0.79, -0.84), (0.55, 0.26, 0.06), mat_interior_src, bevel_w=0.022, rot_x_deg=-17.0)

    # Interior Rearview Mirror hanging from windshield header
    make_tube_along_game_points("InteriorMirrorStalk", [(0.0, 1.355, 0.22), (0.0, 1.255, 0.24)], radius=0.005, sides=8, mat=mat_chrome_src)
    make_box_game("InteriorMirrorHead", (0.0, 1.248, 0.245), (0.105, 0.026, 0.008), mat_interior_src, bevel_w=0.004)

    # Steering column
    make_tube_along_game_points("SteeringColumn", [(0.28, 0.78, 0.43), (0.28, 0.895, 0.23)], radius=0.022, sides=12, mat=mat_interior_src)

    # SEPARATE MESH NODE: SteeringWheel (origin at its hub: game (0.28, 0.895, 0.23) so the upper rim reaches Y=1.06m, clearly visible above cowl!)
    sw_origin_bl = gv(0.28, 0.895, 0.23)
    bm_sw = bmesh.new()
    tilt_rad = math.radians(26.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.190, 0.012
    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]))

    # Two classic spokes & center hub
    bmesh.ops.create_uvsphere(bm_sw, u_segments=16, v_segments=10, radius=0.038)
    for sp_sign in (-1.0, 1.0):
        bmesh.ops.create_cube(bm_sw, size=1.0, matrix=Matrix.Translation(sw_local(sp_sign * 0.095, -0.02, 0.0)) @ Matrix.Diagonal((0.18, 0.014, 0.022, 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()

# ------------------------------------------------------------------------------
# 5J. FOUR SEPARATE WHEEL NODES: Wheel_FL, Wheel_FR, Wheel_RL, Wheel_RR
# ------------------------------------------------------------------------------
def build_single_wheel(name, center_game, side_sign):
    center_bl = gv(*center_game)
    bm_tyre = bmesh.new()
    bm_rim = bmesh.new()
    bm_disc = bmesh.new()

    n_rad = 48
    tyre_prof = [
        (-0.056, 0.172),
        (-0.072, 0.205),
        (-0.077, 0.242),
        (-0.068, 0.274),
        (-0.054, 0.284),
        (-0.036, 0.285),
        (-0.032, 0.279),
        (-0.025, 0.279),
        (-0.021, 0.285),
        (-0.004, 0.285),
        ( 0.000, 0.279),
        ( 0.004, 0.285),
        ( 0.021, 0.285),
        ( 0.025, 0.279),
        ( 0.032, 0.279),
        ( 0.036, 0.285),
        ( 0.054, 0.284),
        ( 0.068, 0.274),
        ( 0.077, 0.242),
        ( 0.072, 0.205),
        ( 0.056, 0.172),
    ]

    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):
            bm_tyre.faces.new((t_rings[i][j], t_rings[inext][j], t_rings[inext][j+1], t_rings[i][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)

    rim_prof = [
        (-0.056, 0.174),
        (-0.045, 0.160),
        ( 0.038, 0.160),
        ( 0.058, 0.174),
        ( 0.062, 0.168),
        ( 0.050, 0.155),
        ( 0.044, 0.132),
        ( 0.054, 0.125),
        ( 0.056, 0.112),
        ( 0.048, 0.102),
        ( 0.058, 0.088),
        ( 0.065, 0.055),
        ( 0.068, 0.022),
        ( 0.069, 0.002),
    ]
    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.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):
            bm_rim.faces.new((r_rings[i][j], r_rings[inext][j], r_rings[inext][j+1], r_rings[i][j+1]))

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

    bmesh.ops.create_cone(bm_disc, cap_ends=True, cap_tris=False, segments=24, radius1=0.135, radius2=0.135, depth=0.036)
    bmesh.ops.rotate(bm_disc, verts=bm_disc.verts, matrix=Matrix.Rotation(math.pi * 0.5, 3, 'Y'))
    bmesh.ops.translate(bm_disc, verts=bm_disc.verts, vec=gv(-side_sign * 0.025, 0.0, 0.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 fluted 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.27, 0.52, -0.05),
    "Door_L":       ( 0.82, 0.55,  0.15),
    "Door_R":       (-0.82, 0.55,  0.15),
    "Exhaust":      ( 0.28, 0.235, -1.95),
    "Light_Head_L": ( 0.545, 0.675,  1.92),
    "Light_Head_R": (-0.545, 0.675,  1.92),
    "Light_Tail_L": ( 0.565, 0.675, -1.92),
    "Light_Tail_R": (-0.565, 0.675, -1.92),
}

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)
# ==============================================================================
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 = (0.45, 0.62, 0.52, 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, "classic-saloon-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"] = [0.45, 0.62, 0.52, 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.45, 0.62, 0.52], # 1. Default Pale Mint Green (contract required first)
    [0.24, 0.39, 0.51], # 2. Classic Powder Blue (sheet.jpg hero colour)
    [0.76, 0.71, 0.56], # 3. 1960s Ivory Cream
    [0.34, 0.04, 0.05], # 4. Vintage Burgundy Red
    [0.08, 0.16, 0.28], # 5. Midnight French Blue
    [0.18, 0.20, 0.21], # 6. Anthracite Slate Grey
]

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 = [
    "1960s European two-door compact saloon (4.00m x 1.58m x 1.42m, 2.40m wheelbase) with see-through greenhouse, 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 fluted atlas).",
    "Cycles GPU-baked textures within Game Weight budget: Paint base 1024px, other bases/normals/ORM 512px, Lamps fluted 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 pale mint green [0.45,0.62,0.52] (reference powder blue is #2 in paints.png); unbranded front medallion.",
]
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)
# ==============================================================================
# Create large studio backdrop plane (250m) so no horizon line cuts across the car
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.80, 0.81, 0.82, 1.0)
bsdf_st.inputs['Roughness'].default_value = 0.24
bsdf_st.inputs['Specular IOR Level'].default_value = 0.40
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.82, 0.84, 0.86, 1.0)
bg.inputs['Strength'].default_value = 0.70

light_specs = [
    ("KeyLight",  ( 4.8, -5.5, 5.8), 950.0, 5.5),
    ("FillLight", (-4.8, -4.2, 3.6), 450.0, 4.8),
    ("RimLight",  (-2.8,  5.5, 4.8), 700.0, 4.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.68), persp=True, lens=50.0, ortho_scale=4.55):
    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 (backed up slightly so entire front bumper & rear bumper fit with breathing room!)
set_camera_view((3.95, -4.95, 1.48), target=(0.0, -0.05, 0.68), 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 Pale Mint Green [0.45, 0.62, 0.52])
render_image("final.png", 1920, 1080, samples=64)

# 2. Orthographic Left Side View (nose pointing Left, rear pointing Right, matching sheet.jpg!)
set_camera_view((6.0, 0.0, 0.72), target=(0.0, 0.0, 0.72), persp=False, ortho_scale=4.55)
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, -4.95, 1.48), target=(0.0, -0.05, 0.68), 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 = (0.45, 0.62, 0.52, 1.0)
print("ALL DONE!")
