#!/usr/bin/env python3
"""
Build script for Modern European Four-Door Executive Saloon / Estate City Taxi (French Riviera Nice/Cannes style).
Forge Garage Contract v2 compliant:
- Real dimensions: 4.82 m L (+Z front), 1.85 m W (+X left), 1.56 m H (+Y up), 2.80 m wheelbase.
- Origin (0,0,0) on ground plane midway between front (+1.40 m) and rear (-1.40 m) axles.
- Separate mesh nodes: Body, Wheel_FL, Wheel_FR, Wheel_RL, Wheel_RR, SteeringWheel, Door_FL, Door_FR, Door_RL, Door_RR.
- Empty nodes: Seat_Driver, Door_L, Door_R, Exhaust, Light_Head_L, Light_Head_R, Light_Tail_L, Light_Tail_R.
- Materials: Paint (baseColorFactor=[0.04, 0.04, 0.05], neutral white/grey detail texture, clearcoat 1.0),
  Trim (chrome, dark rubber, grill, tires, rims, interior), Livery (chequered taxi door band & plates),
  Glass (alpha BLEND, tinted), Lamps (emissive headlights, taillights, amber indicators, roof TAXI sign face).
- Poly Haven CC0 textures: metal_plate, rubber_tiles, fabric_leather_02, asphalt_02.
"""

import os
import sys
import json
import math
import urllib.request
import numpy as np
from PIL import Image, ImageDraw, ImageFilter
import bpy
import bmesh
from mathutils import Vector, Matrix

WORK_DIR = os.path.dirname(os.path.abspath(__file__))
TEX_DIR = os.path.join(WORK_DIR, "textures")
BAKE_DIR = os.path.join(WORK_DIR, "baked")
os.makedirs(TEX_DIR, exist_ok=True)
os.makedirs(BAKE_DIR, exist_ok=True)

# ---------------------------------------------------------------------------
# 1. DOWNLOAD POLY HAVEN CC0 TEXTURES
# ---------------------------------------------------------------------------
POLYHAVEN_IDS = ["metal_plate", "rubber_tiles", "fabric_leather_02", "asphalt_02"]

def download_textures():
    for ph_id in POLYHAVEN_IDS:
        for map_type in ["diff", "nor_gl", "arm"]:
            fname = f"{ph_id}_{map_type}_1k.jpg"
            fpath = os.path.join(TEX_DIR, fname)
            if not os.path.exists(fpath) or os.path.getsize(fpath) < 1000:
                url = f"https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{ph_id}/{fname}"
                print(f"Downloading {url} -> {fpath}")
                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())

download_textures()

# ---------------------------------------------------------------------------
# 2. SCENE RESET & GPU SETUP
# ---------------------------------------------------------------------------
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

def setup_cycles_gpu():
    scene.render.engine = 'CYCLES'
    prefs = bpy.context.preferences
    cprefs = prefs.addons['cycles'].preferences
    cprefs.refresh_devices()
    for dev_type in ('OPTIX', 'CUDA'):
        try:
            cprefs.compute_device_type = dev_type
            for d in cprefs.devices:
                d.use = True
            scene.cycles.device = 'GPU'
            print(f"Using Cycles GPU device type: {dev_type}")
            break
        except Exception:
            pass
    scene.cycles.samples = 64
    scene.cycles.use_denoising = True

setup_cycles_gpu()

# ---------------------------------------------------------------------------
# 3. PREPARE SOURCE / COMPOSITE TEXTURES FOR CYCLES BAKING
# ---------------------------------------------------------------------------
def load_ph_np(ph_id, map_type, size=(1024, 1024)):
    fpath = os.path.join(TEX_DIR, f"{ph_id}_{map_type}_1k.jpg")
    img = Image.open(fpath).convert("RGB").resize(size, Image.Resampling.LANCZOS)
    return np.asarray(img, dtype=np.float32) / 255.0

def save_np_jpg(arr, fpath, quality=84):
    arr_u8 = np.clip(arr * 255.0, 0, 255).astype(np.uint8)
    Image.fromarray(arr_u8, "RGB").save(fpath, quality=quality, optimize=True)

def build_source_maps():
    mp_diff = load_ph_np("metal_plate", "diff", (1024, 1024))
    mp_nor  = load_ph_np("metal_plate", "nor_gl", (1024, 1024))
    mp_arm  = load_ph_np("metal_plate", "arm", (1024, 1024))

    rt_diff = load_ph_np("rubber_tiles", "diff", (1024, 1024))
    rt_nor  = load_ph_np("rubber_tiles", "nor_gl", (1024, 1024))
    rt_arm  = load_ph_np("rubber_tiles", "arm", (1024, 1024))

    fl_diff = load_ph_np("fabric_leather_02", "diff", (1024, 1024))
    fl_nor  = load_ph_np("fabric_leather_02", "nor_gl", (1024, 1024))
    fl_arm  = load_ph_np("fabric_leather_02", "arm", (1024, 1024))

    as_diff = load_ph_np("asphalt_02", "diff", (1024, 1024))
    as_nor  = load_ph_np("asphalt_02", "nor_gl", (1024, 1024))
    as_arm  = load_ph_np("asphalt_02", "arm", (1024, 1024))

    # --- 1. Paint source textures (neutral white/light-grey with subtle grime, panel seams, road dust) ---
    yy, xx = np.mgrid[0:1024, 0:1024] / 1024.0
    mp_lum = np.mean(mp_diff, axis=2, keepdims=True)
    as_lum = np.mean(as_diff, axis=2, keepdims=True)
    grime_grad = np.clip((1.0 - yy[:, :, None]) ** 2.4 * 0.12, 0.0, 0.12)
    paint_val = 0.95 - grime_grad * (0.6 + 0.7 * (1.0 - as_lum)) - 0.04 * (1.0 - mp_lum)
    paint_base = np.repeat(np.clip(paint_val, 0.80, 0.98), 3, axis=2)
    save_np_jpg(paint_base, os.path.join(TEX_DIR, "src_paint_base.jpg"), quality=85)

    flat_nor = np.zeros((1024, 1024, 3), dtype=np.float32)
    flat_nor[:, :, 0] = 0.5
    flat_nor[:, :, 1] = 0.5
    flat_nor[:, :, 2] = 1.0
    paint_nor = flat_nor * 0.92 + mp_nor * 0.05 + as_nor * 0.03
    save_np_jpg(paint_nor, os.path.join(TEX_DIR, "src_paint_nor.jpg"), quality=85)

    paint_ao = 0.93 + 0.07 * mp_arm[:, :, 0] - grime_grad[:, :, 0] * 0.15
    paint_rough = 0.13 + 0.06 * mp_arm[:, :, 1] + grime_grad[:, :, 0] * 0.40
    paint_metal = np.full((1024, 1024), 0.36, dtype=np.float32) - grime_grad[:, :, 0] * 0.15
    paint_orm = np.stack([paint_ao, paint_rough, paint_metal], axis=2)
    paint_orm_512 = np.asarray(Image.fromarray(np.clip(paint_orm * 255, 0, 255).astype(np.uint8)).resize((512, 512), Image.Resampling.LANCZOS)) / 255.0
    save_np_jpg(paint_orm_512, os.path.join(TEX_DIR, "src_paint_orm.jpg"), quality=84)

    # --- 2. Trim Atlas (4 Quadrants in 1024x1024) ---
    trim_base = np.zeros((1024, 1024, 3), dtype=np.float32)
    trim_nor  = np.zeros((1024, 1024, 3), dtype=np.float32)
    trim_orm  = np.zeros((1024, 1024, 3), dtype=np.float32)

    def r512(a):
        return np.asarray(Image.fromarray(np.clip(a * 255, 0, 255).astype(np.uint8)).resize((512, 512), Image.Resampling.LANCZOS), dtype=np.float32) / 255.0

    # Q0 (Top-Left): Chrome & Bright Machined Alloy (from metal_plate)
    mp_d5 = r512(mp_diff)
    mp_n5 = r512(mp_nor)
    mp_a5 = r512(mp_arm)
    chrome_col = 0.75 + 0.20 * mp_d5
    trim_base[0:512, 0:512] = chrome_col
    trim_nor[0:512, 0:512]  = flat_nor[0:512, 0:512] * 0.80 + mp_n5 * 0.20
    trim_orm[0:512, 0:512, 0] = 0.90 + 0.10 * mp_a5[:, :, 0]
    trim_orm[0:512, 0:512, 1] = 0.12 + 0.10 * mp_a5[:, :, 1]
    trim_orm[0:512, 0:512, 2] = 0.95

    # Q1 (Top-Right): Tyre Rubber & Sidewall Tread (from rubber_tiles + asphalt_02)
    rt_d5 = r512(rt_diff)
    rt_n5 = r512(rt_nor)
    rt_a5 = r512(rt_arm)
    as_d5 = r512(as_diff)
    as_n5 = r512(as_nor)
    ty, tx = np.mgrid[0:512, 0:512] / 512.0
    tread_ribs = 0.5 + 0.5 * np.cos(tx * 2.0 * math.pi * 12.0)
    tread_sipes = 0.5 + 0.5 * np.sin((ty * 32.0 + tx * 4.0) * 2.0 * math.pi)
    tread_mask = np.clip(tread_ribs * 0.7 + tread_sipes * 0.3, 0.0, 1.0)[:, :, None]
    rubber_col = (0.042 + 0.045 * rt_d5 + 0.025 * as_d5) * (0.78 + 0.22 * tread_mask)
    trim_base[0:512, 512:1024] = rubber_col
    trim_nor[0:512, 512:1024]  = rt_n5 * 0.6 + as_n5 * 0.4
    trim_orm[0:512, 512:1024, 0] = 0.68 + 0.28 * rt_a5[:, :, 0] * tread_mask[:, :, 0]
    trim_orm[0:512, 512:1024, 1] = 0.70 + 0.15 * rt_a5[:, :, 1]
    trim_orm[0:512, 512:1024, 2] = 0.02

    # Q2 (Bottom-Left): Tan/Cognac Perforated Executive Leather (from fabric_leather_02)
    fl_d5 = r512(fl_diff)
    fl_n5 = r512(fl_nor)
    fl_a5 = r512(fl_arm)
    fl_lum = np.mean(fl_d5, axis=2, keepdims=True)
    cognac = np.zeros((512, 512, 3), dtype=np.float32)
    cognac[:, :, 0] = 0.46 * (0.68 + 0.52 * fl_lum[:, :, 0])
    cognac[:, :, 1] = 0.26 * (0.68 + 0.52 * fl_lum[:, :, 0])
    cognac[:, :, 2] = 0.13 * (0.68 + 0.52 * fl_lum[:, :, 0])
    trim_base[512:1024, 0:512] = np.clip(cognac, 0.05, 0.88)
    trim_nor[512:1024, 0:512]  = fl_n5
    trim_orm[512:1024, 0:512, 0] = 0.82 + 0.18 * fl_a5[:, :, 0]
    trim_orm[512:1024, 0:512, 1] = 0.46 + 0.18 * fl_a5[:, :, 1]
    trim_orm[512:1024, 0:512, 2] = 0.01

    # Q3 (Bottom-Right): Dark Anthracite Grille Mesh / Dashboard Charcoal / Glossy Pillar Trim
    gy, gx = np.mgrid[0:512, 0:512] / 512.0
    d1 = np.abs(np.sin((gx * 30.0 + gy * 18.0) * math.pi))
    d2 = np.abs(np.sin((gx * 30.0 - gy * 18.0) * math.pi))
    hex_bars = np.clip(1.0 - np.minimum(d1, d2) * 3.5, 0.0, 1.0)[:, :, None]
    dark_trim = 0.032 + 0.04 * mp_d5 + 0.045 * hex_bars
    trim_base[512:1024, 512:1024] = dark_trim
    q3_nor = mp_n5.copy()
    q3_nor[:, :, 0] = np.clip(q3_nor[:, :, 0] + (hex_bars[:, :, 0] - 0.5) * 0.22, 0.0, 1.0)
    q3_nor[:, :, 1] = np.clip(q3_nor[:, :, 1] + (hex_bars[:, :, 0] - 0.5) * 0.22, 0.0, 1.0)
    trim_nor[512:1024, 512:1024] = q3_nor
    trim_orm[512:1024, 512:1024, 0] = 0.48 + 0.48 * hex_bars[:, :, 0]
    trim_orm[512:1024, 512:1024, 1] = 0.36 + 0.22 * (1.0 - hex_bars[:, :, 0])
    trim_orm[512:1024, 512:1024, 2] = 0.25 * hex_bars[:, :, 0]

    save_np_jpg(trim_base, os.path.join(TEX_DIR, "src_trim_base.jpg"), quality=85)
    save_np_jpg(trim_nor,  os.path.join(TEX_DIR, "src_trim_nor.jpg"), quality=85)
    trim_orm_512 = np.asarray(Image.fromarray(np.clip(trim_orm * 255, 0, 255).astype(np.uint8)).resize((512, 512), Image.Resampling.LANCZOS)) / 255.0
    save_np_jpg(trim_orm_512, os.path.join(TEX_DIR, "src_trim_orm.jpg"), quality=84)

    # --- 3. Livery Texture (512x512): Chequered Taxi Band + EU License Plates ---
    liv_img = Image.new("RGB", (512, 512), (28, 34, 44))
    draw = ImageDraw.Draw(liv_img)

    # Draw 3-row chequered pattern across y=0..256 (3 rows of squares, 32 columns across 512px -> 16px per square)
    sq_w = 16
    sq_h = 26
    draw.rectangle([0, 0, 512, 256], fill=(24, 30, 40))
    for row in range(3):
        y0 = 88 + row * sq_h
        y1 = y0 + sq_h
        for col in range(32):
            x0 = col * sq_w
            x1 = x0 + sq_w
            if (row + col) % 2 == 0:
                draw.rectangle([x0, y0, x1 - 1, y1 - 1], fill=(142, 152, 165))
            else:
                draw.rectangle([x0, y0, x1 - 1, y1 - 1], fill=(22, 28, 38))
    draw.rectangle([0, 82, 512, 85], fill=(165, 175, 188))
    draw.rectangle([0, 169, 512, 172], fill=(165, 175, 188))

    # Bottom half (y=256..512): Clean silver-white EU license plate + subtle console taximeter screen
    draw.rectangle([0, 256, 512, 512], fill=(20, 22, 26))
    draw.rectangle([16, 280, 496, 420], fill=(18, 20, 24), outline=(145, 150, 158), width=3)
    draw.rectangle([24, 288, 488, 412], fill=(226, 230, 236))
    draw.rectangle([24, 288, 488, 294], fill=(198, 204, 212))

    # Taximeter digital display strip at y=435..500
    draw.rectangle([24, 435, 260, 500], fill=(14, 16, 20), outline=(80, 85, 95), width=2)
    draw.rectangle([38, 452, 138, 482], fill=(215, 45, 32))
    draw.rectangle([156, 452, 242, 482], fill=(42, 195, 88))

    liv_np = np.asarray(liv_img, dtype=np.float32) / 255.0
    mp_512 = r512(mp_diff)
    liv_np = np.clip(liv_np * (0.92 + 0.15 * mp_512), 0.0, 1.0)
    save_np_jpg(liv_np, os.path.join(TEX_DIR, "src_livery_base.jpg"), quality=85)

    liv_orm = np.zeros((512, 512, 3), dtype=np.float32)
    liv_orm[:, :, 0] = 0.92
    liv_orm[:, :, 1] = 0.22 + 0.10 * r512(mp_arm)[:, :, 1]
    liv_orm[:, :, 2] = 0.35
    save_np_jpg(liv_orm, os.path.join(TEX_DIR, "src_livery_orm.jpg"), quality=84)

    # --- 4. Lamps Texture (512x512): Emissive & Base Colour ---
    lamp_img = Image.new("RGB", (512, 512), (20, 20, 24))
    ldraw = ImageDraw.Draw(lamp_img)

    # Top half (y=0..256): Rich warm amber-yellow TAXI roof sign face (matching sheet.jpg!)
    for y in range(256):
        dist = abs(y - 128) / 128.0
        r = int(255 - dist * 12)
        g = int(192 - dist * 42)
        b = int(18 - dist * 12)
        ldraw.line([(0, y), (512, y)], fill=(r, g, b))

    # Draw bold, crisp "TAXI" lettering centered in (x=0..512, y=0..256)
    ink = (20, 15, 12)
    # 'T': x=72..156
    ldraw.rectangle([72, 56, 156, 82], fill=ink)
    ldraw.rectangle([101, 56, 127, 200], fill=ink)
    # 'A': x=172..260
    ldraw.polygon([(203, 56), (229, 56), (264, 200), (236, 200), (216, 90), (196, 200), (168, 200)], fill=ink)
    ldraw.rectangle([186, 142, 246, 166], fill=ink)
    # 'X': x=276..366
    ldraw.polygon([(276, 56), (305, 56), (366, 200), (337, 200)], fill=ink)
    ldraw.polygon([(337, 56), (366, 56), (305, 200), (276, 200)], fill=ink)
    # 'I': x=386..414
    ldraw.rectangle([386, 56, 414, 200], fill=ink)

    # Bottom-Left (x=0..256, y=256..512): Headlight internal cluster (dark chrome bezel + bright warm-white LED DRL eyebrow & projectors)
    ldraw.rectangle([0, 256, 256, 512], fill=(24, 28, 34))
    # Upper sweeping LED DRL eyebrow strip + inner diagonal light guide (matching sheet.jpg!)
    ldraw.polygon([(14, 305), (238, 272), (242, 302), (18, 335)], fill=(255, 250, 240))
    ldraw.polygon([(24, 365), (145, 315), (152, 336), (30, 386)], fill=(245, 245, 255))
    # Dual LED projector lenses
    ldraw.ellipse([36, 342, 126, 432], fill=(240, 246, 255), outline=(150, 165, 185), width=6)
    ldraw.ellipse([142, 346, 226, 430], fill=(248, 245, 235), outline=(150, 165, 185), width=5)
    # Lower subtle amber turn signal strip
    ldraw.rectangle([28, 456, 228, 482], fill=(255, 160, 20))

    # Bottom-Right (x=256..512, y=256..512): Taillight cluster (ruby red LED bars + amber/reverse strip)
    ldraw.rectangle([256, 256, 512, 512], fill=(85, 10, 14))
    ldraw.rectangle([270, 284, 498, 334], fill=(248, 22, 26))
    ldraw.rectangle([270, 354, 498, 396], fill=(238, 18, 22))
    ldraw.rectangle([286, 412, 482, 442], fill=(255, 180, 60))
    ldraw.rectangle([270, 456, 498, 492], fill=(215, 16, 20))

    lamp_blur = lamp_img.filter(ImageFilter.GaussianBlur(radius=1.0))
    lamp_np = np.asarray(lamp_img, dtype=np.float32) * 0.78 / 255.0 + np.asarray(lamp_blur, dtype=np.float32) * 0.22 / 255.0
    save_np_jpg(lamp_np, os.path.join(TEX_DIR, "src_lamps_base.jpg"), quality=86)

build_source_maps()
print("Source maps built successfully.")

# ---------------------------------------------------------------------------
# 4. CYCLES GPU BAKING OF MATERIALS TO FINAL GLB TEXTURES
# ---------------------------------------------------------------------------
def load_img(path, colorspace='sRGB'):
    img = bpy.data.images.load(path, check_existing=True)
    img.colorspace_settings.name = colorspace
    return img

def bake_all_materials():
    bpy.ops.mesh.primitive_plane_add(size=2.0, location=(0, 0, -10.0))
    bake_obj = bpy.context.active_object
    bake_obj.name = "_BakePlane"

    bake_jobs = [
        ("paint",  "src_paint_base.jpg",  "src_paint_nor.jpg",  "src_paint_orm.jpg",  512, 512, 0.03),
        ("trim",   "src_trim_base.jpg",   "src_trim_nor.jpg",   "src_trim_orm.jpg",   512, 512, 0.07),
        ("livery", "src_livery_base.jpg", None,                 "src_livery_orm.jpg", 512, 256, 0.00),
        ("lamps",  "src_lamps_base.jpg",  None,                 None,                 512, 256, 0.00),
    ]

    orig_samples = scene.cycles.samples
    scene.cycles.samples = 16
    scene.render.bake.margin = 4
    scene.render.image_settings.file_format = 'JPEG'
    scene.render.image_settings.quality = 82

    for prefix, s_base, s_nor, s_orm, res_b, res_o, bump_str in bake_jobs:
        mat = bpy.data.materials.new(name=f"_BakeMat_{prefix}")
        mat.use_nodes = True
        nodes = mat.node_tree.nodes
        links = mat.node_tree.links
        nodes.clear()

        out_node = nodes.new('ShaderNodeOutputMaterial')
        emit_node = nodes.new('ShaderNodeEmission')
        bsdf_node = nodes.new('ShaderNodeBsdfPrincipled')

        uv_node = nodes.new('ShaderNodeTexCoord')
        noise_node = nodes.new('ShaderNodeTexNoise')
        noise_node.inputs['Scale'].default_value = 35.0
        noise_node.inputs['Detail'].default_value = 6.0
        noise_node.inputs['Roughness'].default_value = 0.65
        links.new(uv_node.outputs['UV'], noise_node.inputs['Vector'])

        tex_base = nodes.new('ShaderNodeTexImage')
        tex_base.image = load_img(os.path.join(TEX_DIR, s_base), 'sRGB')
        links.new(uv_node.outputs['UV'], tex_base.inputs['Vector'])

        mix_col = nodes.new('ShaderNodeMix')
        mix_col.data_type = 'RGBA'
        mix_col.blend_type = 'MULTIPLY'
        mix_col.inputs['Factor'].default_value = 0.03 if prefix != 'lamps' else 0.0
        links.new(tex_base.outputs['Color'], mix_col.inputs['A'])
        links.new(noise_node.outputs['Color'], mix_col.inputs['B'])

        if bake_obj.data.materials:
            bake_obj.data.materials[0] = mat
        else:
            bake_obj.data.materials.append(mat)

        bpy.ops.object.select_all(action='DESELECT')
        bake_obj.select_set(True)
        bpy.context.view_layer.objects.active = bake_obj

        # 1) Bake Base Colour
        links.new(mix_col.outputs['Result'], emit_node.inputs['Color'])
        links.new(emit_node.outputs['Emission'], out_node.inputs['Surface'])

        target_base = bpy.data.images.new(f"baked_{prefix}_base", width=res_b, height=res_b, alpha=False)
        target_base.colorspace_settings.name = 'sRGB'
        bake_tex = nodes.new('ShaderNodeTexImage')
        bake_tex.image = target_base
        for n in nodes:
            n.select = False
        bake_tex.select = True
        nodes.active = bake_tex

        bpy.ops.object.bake(type='EMIT')
        out_base_path = os.path.join(BAKE_DIR, f"{prefix}_base.jpg")
        target_base.filepath_raw = out_base_path
        target_base.file_format = 'JPEG'
        target_base.save()

        # 2) Bake Normal Map
        if s_nor:
            tex_nor = nodes.new('ShaderNodeTexImage')
            tex_nor.image = load_img(os.path.join(TEX_DIR, s_nor), 'Non-Color')
            links.new(uv_node.outputs['UV'], tex_nor.inputs['Vector'])

            nmap_node = nodes.new('ShaderNodeNormalMap')
            nmap_node.inputs['Strength'].default_value = 1.0
            links.new(tex_nor.outputs['Color'], nmap_node.inputs['Color'])

            bump_node = nodes.new('ShaderNodeBump')
            bump_node.inputs['Strength'].default_value = bump_str
            bump_node.inputs['Distance'].default_value = 0.002
            links.new(noise_node.outputs['Fac'], bump_node.inputs['Height'])
            links.new(nmap_node.outputs['Normal'], bump_node.inputs['Normal'])
            links.new(bump_node.outputs['Normal'], bsdf_node.inputs['Normal'])
            links.new(bsdf_node.outputs['BSDF'], out_node.inputs['Surface'])

            target_nor = bpy.data.images.new(f"baked_{prefix}_nor", width=res_b, height=res_b, alpha=False)
            target_nor.colorspace_settings.name = 'Non-Color'
            bake_tex.image = target_nor
            for n in nodes:
                n.select = False
            bake_tex.select = True
            nodes.active = bake_tex

            bpy.ops.object.bake(type='NORMAL')
            out_nor_path = os.path.join(BAKE_DIR, f"{prefix}_nor.jpg")
            target_nor.filepath_raw = out_nor_path
            target_nor.file_format = 'JPEG'
            target_nor.save()

        # 3) Bake ORM Map
        if s_orm:
            tex_orm = nodes.new('ShaderNodeTexImage')
            tex_orm.image = load_img(os.path.join(TEX_DIR, s_orm), 'Non-Color')
            links.new(uv_node.outputs['UV'], tex_orm.inputs['Vector'])

            links.new(tex_orm.outputs['Color'], emit_node.inputs['Color'])
            links.new(emit_node.outputs['Emission'], out_node.inputs['Surface'])

            target_orm = bpy.data.images.new(f"baked_{prefix}_orm", width=res_o, height=res_o, alpha=False)
            target_orm.colorspace_settings.name = 'Non-Color'
            bake_tex.image = target_orm
            for n in nodes:
                n.select = False
            bake_tex.select = True
            nodes.active = bake_tex

            bpy.ops.object.bake(type='EMIT')
            out_orm_path = os.path.join(BAKE_DIR, f"{prefix}_orm.jpg")
            target_orm.filepath_raw = out_orm_path
            target_orm.file_format = 'JPEG'
            target_orm.save()

    scene.cycles.samples = orig_samples
    bpy.data.objects.remove(bake_obj, do_unlink=True)
    print("Cycles GPU baking finished.")

bake_all_materials()

# ---------------------------------------------------------------------------
# 5. CREATE FINAL GLTF-READY PBR MATERIALS (EXACTLY 5 MATERIALS)
# ---------------------------------------------------------------------------
def build_gltf_materials():
    mats = {}

    # --- 1. Paint ---
    mat_paint = bpy.data.materials.new(name="Paint")
    mat_paint.use_nodes = True
    nt = mat_paint.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_p_base = nt.nodes.new('ShaderNodeTexImage')
    tex_p_base.image = load_img(os.path.join(BAKE_DIR, "paint_base.jpg"), 'sRGB')
    mix_p = nt.nodes.new('ShaderNodeMix')
    mix_p.data_type = 'RGBA'
    mix_p.blend_type = 'MULTIPLY'
    mix_p.inputs['Factor'].default_value = 1.0
    mix_p.inputs['B'].default_value = (0.04, 0.04, 0.05, 1.0)
    nt.links.new(tex_p_base.outputs['Color'], mix_p.inputs['A'])
    nt.links.new(mix_p.outputs['Result'], bsdf.inputs['Base Color'])

    tex_p_orm = nt.nodes.new('ShaderNodeTexImage')
    tex_p_orm.image = load_img(os.path.join(BAKE_DIR, "paint_orm.jpg"), 'Non-Color')
    sep_p = nt.nodes.new('ShaderNodeSeparateColor')
    nt.links.new(tex_p_orm.outputs['Color'], sep_p.inputs['Color'])
    nt.links.new(sep_p.outputs['Green'], bsdf.inputs['Roughness'])
    nt.links.new(sep_p.outputs['Blue'], bsdf.inputs['Metallic'])

    gltf_occ_group = bpy.data.node_groups.new("glTF Settings", "ShaderNodeTree")
    gltf_occ_group.interface.new_socket(name="Occlusion", in_out='INPUT', socket_type='NodeSocketFloat')
    occ_node_p = nt.nodes.new('ShaderNodeGroup')
    occ_node_p.node_tree = gltf_occ_group
    nt.links.new(sep_p.outputs['Red'], occ_node_p.inputs['Occlusion'])

    tex_p_nor = nt.nodes.new('ShaderNodeTexImage')
    tex_p_nor.image = load_img(os.path.join(BAKE_DIR, "paint_nor.jpg"), 'Non-Color')
    nmap_p = nt.nodes.new('ShaderNodeNormalMap')
    nmap_p.inputs['Strength'].default_value = 0.28
    nt.links.new(tex_p_nor.outputs['Color'], nmap_p.inputs['Color'])
    nt.links.new(nmap_p.outputs['Normal'], bsdf.inputs['Normal'])

    if 'Coat Weight' in bsdf.inputs:
        bsdf.inputs['Coat Weight'].default_value = 1.0
        bsdf.inputs['Coat Roughness'].default_value = 0.05
    elif 'Clearcoat' in bsdf.inputs:
        bsdf.inputs['Clearcoat'].default_value = 1.0
        bsdf.inputs['Clearcoat Roughness'].default_value = 0.05
    mats["Paint"] = mat_paint

    # --- 2. Trim ---
    mat_trim = bpy.data.materials.new(name="Trim")
    mat_trim.use_nodes = True
    nt = mat_trim.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_t_base = nt.nodes.new('ShaderNodeTexImage')
    tex_t_base.image = load_img(os.path.join(BAKE_DIR, "trim_base.jpg"), 'sRGB')
    nt.links.new(tex_t_base.outputs['Color'], bsdf.inputs['Base Color'])

    tex_t_orm = nt.nodes.new('ShaderNodeTexImage')
    tex_t_orm.image = load_img(os.path.join(BAKE_DIR, "trim_orm.jpg"), 'Non-Color')
    sep_t = nt.nodes.new('ShaderNodeSeparateColor')
    nt.links.new(tex_t_orm.outputs['Color'], sep_t.inputs['Color'])
    nt.links.new(sep_t.outputs['Green'], bsdf.inputs['Roughness'])
    nt.links.new(sep_t.outputs['Blue'], bsdf.inputs['Metallic'])
    occ_node_t = nt.nodes.new('ShaderNodeGroup')
    occ_node_t.node_tree = gltf_occ_group
    nt.links.new(sep_t.outputs['Red'], occ_node_t.inputs['Occlusion'])

    tex_t_nor = nt.nodes.new('ShaderNodeTexImage')
    tex_t_nor.image = load_img(os.path.join(BAKE_DIR, "trim_nor.jpg"), 'Non-Color')
    nmap_t = nt.nodes.new('ShaderNodeNormalMap')
    nmap_t.inputs['Strength'].default_value = 0.75
    nt.links.new(tex_t_nor.outputs['Color'], nmap_t.inputs['Color'])
    nt.links.new(nmap_t.outputs['Normal'], bsdf.inputs['Normal'])
    mats["Trim"] = mat_trim

    # --- 3. Livery ---
    mat_liv = bpy.data.materials.new(name="Livery")
    mat_liv.use_nodes = True
    nt = mat_liv.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_l_base = nt.nodes.new('ShaderNodeTexImage')
    tex_l_base.image = load_img(os.path.join(BAKE_DIR, "livery_base.jpg"), 'sRGB')
    nt.links.new(tex_l_base.outputs['Color'], bsdf.inputs['Base Color'])

    tex_l_orm = nt.nodes.new('ShaderNodeTexImage')
    tex_l_orm.image = load_img(os.path.join(BAKE_DIR, "livery_orm.jpg"), 'Non-Color')
    sep_l = nt.nodes.new('ShaderNodeSeparateColor')
    nt.links.new(tex_l_orm.outputs['Color'], sep_l.inputs['Color'])
    nt.links.new(sep_l.outputs['Green'], bsdf.inputs['Roughness'])
    nt.links.new(sep_l.outputs['Blue'], bsdf.inputs['Metallic'])
    if 'Coat Weight' in bsdf.inputs:
        bsdf.inputs['Coat Weight'].default_value = 0.8
        bsdf.inputs['Coat Roughness'].default_value = 0.06
    mats["Livery"] = mat_liv

    # --- 4. Glass ---
    mat_glass = bpy.data.materials.new(name="Glass")
    mat_glass.use_nodes = True
    if hasattr(mat_glass, "surface_render_method"):
        mat_glass.surface_render_method = 'BLENDED'
    elif hasattr(mat_glass, "blend_method"):
        mat_glass.blend_method = 'BLEND'
    nt = mat_glass.node_tree
    nt.nodes.clear()
    out = nt.nodes.new('ShaderNodeOutputMaterial')
    bsdf = nt.nodes.new('ShaderNodeBsdfPrincipled')
    nt.links.new(bsdf.outputs['BSDF'], out.inputs['Surface'])
    bsdf.inputs['Base Color'].default_value = (0.04, 0.06, 0.08, 1.0)
    bsdf.inputs['Roughness'].default_value = 0.03
    bsdf.inputs['Metallic'].default_value = 0.08
    bsdf.inputs['IOR'].default_value = 1.48
    bsdf.inputs['Alpha'].default_value = 0.42
    if 'Coat Weight' in bsdf.inputs:
        bsdf.inputs['Coat Weight'].default_value = 1.0
        bsdf.inputs['Coat Roughness'].default_value = 0.02
    mats["Glass"] = mat_glass

    # --- 5. Lamps ---
    mat_lamps = bpy.data.materials.new(name="Lamps")
    mat_lamps.use_nodes = True
    nt = mat_lamps.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_e_base = nt.nodes.new('ShaderNodeTexImage')
    tex_e_base.image = load_img(os.path.join(BAKE_DIR, "lamps_base.jpg"), 'sRGB')
    nt.links.new(tex_e_base.outputs['Color'], bsdf.inputs['Base Color'])
    nt.links.new(tex_e_base.outputs['Color'], bsdf.inputs['Emission Color'])
    bsdf.inputs['Emission Strength'].default_value = 1.6
    bsdf.inputs['Roughness'].default_value = 0.06
    if 'Coat Weight' in bsdf.inputs:
        bsdf.inputs['Coat Weight'].default_value = 1.0
        bsdf.inputs['Coat Roughness'].default_value = 0.03
    mats["Lamps"] = mat_lamps

    return mats

MATS = build_gltf_materials()

# ---------------------------------------------------------------------------
# 6. GEOMETRY HELPERS & UV MAPPING
# ---------------------------------------------------------------------------
def g2b(x, y, z):
    return Vector((float(x), -float(z), float(y)))

def b2g_vec(v):
    return (round(v.x, 4), round(v.z, 4), round(-v.y, 4))

UV_REGIONS = {
    "paint":        (0.05, 0.95, 0.05, 0.95),
    "trim_chrome":  (0.04, 0.46, 0.54, 0.96),
    "trim_rubber":  (0.54, 0.96, 0.54, 0.96),
    "trim_leather": (0.04, 0.46, 0.04, 0.46),
    "trim_dark":    (0.54, 0.96, 0.04, 0.46),
    "livery_check": (0.00, 1.00, 0.66, 0.84),
    "livery_plate": (0.05, 0.95, 0.19, 0.44),
    "livery_meter": (0.05, 0.50, 0.03, 0.14),
    "lamps_taxi":   (0.05, 0.95, 0.54, 0.96),
    "lamps_head":   (0.04, 0.46, 0.04, 0.46),
    "lamps_tail":   (0.54, 0.96, 0.04, 0.46),
    "glass":        (0.10, 0.90, 0.10, 0.90),
}

def ensure_mat_slot(obj, mat_name):
    mat = MATS[mat_name]
    for idx, m in enumerate(obj.data.materials):
        if m == mat:
            return idx
    obj.data.materials.append(mat)
    return len(obj.data.materials) - 1

def assign_faces_mat_and_box_uv(bm, faces, mat_idx, uv_layer, uv_region, scale_uv=0.4):
    u0, u1, v0, v1 = uv_region
    du = u1 - u0
    dv = v1 - v0
    for f in faces:
        f.material_index = mat_idx
        f.smooth = True
        n = f.normal
        ax, ay, az = abs(n.x), abs(n.y), abs(n.z)
        for loop in f.loops:
            co = loop.vert.co
            if ax >= ay and ax >= az:
                raw_u = (-co.y) * scale_uv + 0.5
                raw_v = co.z * scale_uv
            elif ay >= ax and ay >= az:
                raw_u = co.x * scale_uv + 0.5
                raw_v = co.z * scale_uv
            else:
                raw_u = co.x * scale_uv + 0.5
                raw_v = (-co.y) * scale_uv + 0.5
            u = u0 + (raw_u % 1.0) * du
            v = v0 + (raw_v % 1.0) * dv
            loop[uv_layer].uv = (u, v)

def assign_faces_planar_uv(bm, faces, mat_idx, uv_layer, uv_region, axis='Z', flip_u=False):
    u0, u1, v0, v1 = uv_region
    xs, ys = [], []
    for f in faces:
        for v in f.verts:
            co = v.co
            if axis == 'Z':
                xs.append(co.x); ys.append(co.z)
            elif axis == 'X':
                xs.append(-co.y); ys.append(co.z)
            else:
                xs.append(co.x); ys.append(-co.y)
    min_x, max_x = min(xs), max(xs)
    min_y, max_y = min(ys), max(ys)
    dx = max(max_x - min_x, 1e-5)
    dy = max(max_y - min_y, 1e-5)
    for f in faces:
        f.material_index = mat_idx
        f.smooth = True
        for loop in f.loops:
            co = loop.vert.co
            if axis == 'Z':
                rx = (co.x - min_x) / dx; ry = (co.z - min_y) / dy
            elif axis == 'X':
                rx = (-co.y - min_x) / dx; ry = (co.z - min_y) / dy
            else:
                rx = (co.x - min_x) / dx; ry = (-co.y - min_y) / dy
            if flip_u:
                rx = 1.0 - rx
            loop[uv_layer].uv = (u0 + rx * (u1 - u0), v0 + ry * (v1 - v0))

def add_box_part(bm, x0, x1, y0, y1, z0, z1, mat_idx, uv_layer, uv_region, bevel_r=0.008, segs=2):
    cx = 0.5 * (x0 + x1); cy = 0.5 * (y0 + y1); cz = 0.5 * (z0 + z1)
    sx = abs(x1 - x0); sy = abs(y1 - y0); sz = abs(z1 - z0)
    ret = bmesh.ops.create_cube(bm, size=1.0)
    verts = ret['verts']
    bmesh.ops.scale(bm, vec=Vector((sx, sz, sy)), verts=verts)
    bmesh.ops.translate(bm, vec=g2b(cx, cy, cz), verts=verts)
    vset = set(verts)
    f_list = [f for f in bm.faces if all(v in vset for v in f.verts)]
    e_list = [e for e in bm.edges if all(v in vset for v in e.verts)]
    if bevel_r > 0 and e_list:
        b_ret = bmesh.ops.bevel(bm, geom=e_list, offset=bevel_r, segments=segs, profile=0.5, affect='EDGES')
        vset.update(b_ret['verts'])
        f_list = [f for f in bm.faces if all(v in vset for v in f.verts)]
    assign_faces_mat_and_box_uv(bm, f_list, mat_idx, uv_layer, uv_region)
    return f_list

def add_cyl_part(bm, center_gltf, radius, length, axis, mat_idx, uv_layer, uv_region, segs=24, bevel_r=0.004):
    ret = bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=segs, radius1=radius, radius2=radius, depth=length)
    verts = ret['verts']
    if axis == 'X':
        bmesh.ops.rotate(bm, matrix=Matrix.Rotation(math.pi / 2.0, 3, 'Y'), verts=verts)
    elif axis == 'Z':
        bmesh.ops.rotate(bm, matrix=Matrix.Rotation(math.pi / 2.0, 3, 'X'), verts=verts)
    bmesh.ops.translate(bm, vec=g2b(*center_gltf), verts=verts)
    vset = set(verts)
    e_list = [e for e in bm.edges if all(v in vset for v in e.verts) and len(e.link_faces) == 2]
    cap_edges = [e for e in e_list if abs(e.link_faces[0].normal.dot(e.link_faces[1].normal)) < 0.5]
    if bevel_r > 0 and cap_edges:
        b_ret = bmesh.ops.bevel(bm, geom=cap_edges, offset=bevel_r, segments=2, profile=0.5, affect='EDGES')
        vset.update(b_ret['verts'])
    f_list = [f for f in bm.faces if all(v in vset for v in f.verts)]
    assign_faces_mat_and_box_uv(bm, f_list, mat_idx, uv_layer, uv_region)
    return f_list

# ---------------------------------------------------------------------------
# 7. BUILD MULTI-SPOKE ALLOY WHEELS (Wheel_FL, Wheel_FR, Wheel_RL, Wheel_RR)
# ---------------------------------------------------------------------------
# Reference sheet.jpg:
# - Wheelbase = 2.80 m -> Front axle z = +1.40 m, Rear axle z = -1.40 m.
# - Tyre outer radius = 0.345 m, hub center y = 0.345 m (so bottom of tyre touches y = 0.000 m!).
# - Track width: outer tyre face at x = ±0.885 m (flush inside ±0.925 m fenders),
#   tyre width = 0.230 m -> wheel hub center at x = ±0.770 m.
# - Design: Sculpted 5-twin-spoke (10 arms) wide machined alloy rim matching sheet.jpg,
#   central hub cap with chrome emblem ring, 5 lug bolts in recessed wells, dark iron brake disc +
#   silver brake caliper inside the rim, bevelled tyre shoulders with tread ribs.

def build_wheel(name, x_hub, y_hub, z_hub, is_left=True):
    mesh = bpy.data.meshes.new(name)
    obj = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(obj)
    obj.location = g2b(x_hub, y_hub, z_hub)

    bm = bmesh.new()
    uv_layer = bm.loops.layers.uv.new("UVMap")

    idx_trim = ensure_mat_slot(obj, "Trim")
    sign = 1.0 if is_left else -1.0

    r_outer = 0.345
    r_rim   = 0.244
    w_half  = 0.115
    profile = [
        (-w_half + 0.018, r_rim,           True),
        (-w_half + 0.004, r_rim + 0.032,   True),
        (-w_half,         r_rim + 0.068,   True),
        (-w_half + 0.010, r_outer - 0.012, True),
        (-w_half * 0.60,  r_outer,         True),
        (-w_half * 0.20,  r_outer - 0.003, True),
        (0.0,             r_outer,         True),
        (w_half * 0.20,   r_outer - 0.003, True),
        (w_half * 0.60,   r_outer,         True),
        (w_half - 0.010,  r_outer - 0.012, True),
        (w_half,          r_rim + 0.068,   True),
        (w_half - 0.004,  r_rim + 0.032,   True),
        (w_half - 0.018,  r_rim,           True),
        (w_half - 0.010,  r_rim + 0.005,   False),
        (w_half - 0.024,  r_rim - 0.014,   False),
        (-w_half + 0.025, r_rim - 0.020,   False),
        (-w_half + 0.018, r_rim,           False),
    ]

    n_seg = 48
    rings = []
    for px, pr, _ in profile[:-1]:
        ring_verts = []
        for i in range(n_seg):
            ang = 2.0 * math.pi * i / n_seg
            ly = pr * math.cos(ang)
            lz = pr * math.sin(ang)
            lx = px * sign
            ring_verts.append(bm.verts.new(g2b(lx, ly, lz)))
        rings.append(ring_verts)

    tyre_faces = []
    rim_faces = []
    n_rings = len(rings)
    for r_i in range(n_rings):
        r_next = (r_i + 1) % n_rings
        is_rub = profile[r_i][2]
        for i in range(n_seg):
            i_next = (i + 1) % n_seg
            v1 = rings[r_i][i]
            v2 = rings[r_i][i_next]
            v3 = rings[r_next][i_next]
            v4 = rings[r_next][i]
            f = bm.faces.new((v1, v2, v3, v4) if is_left else (v1, v4, v3, v2))
            if is_rub:
                tyre_faces.append(f)
            else:
                rim_faces.append(f)

    assign_faces_mat_and_box_uv(bm, tyre_faces, idx_trim, uv_layer, UV_REGIONS["trim_rubber"], scale_uv=1.5)
    assign_faces_mat_and_box_uv(bm, rim_faces,  idx_trim, uv_layer, UV_REGIONS["trim_chrome"], scale_uv=1.2)

    # Central Alloy Hub Bowl & Cap
    add_cyl_part(bm, (sign * (w_half - 0.038), 0.0, 0.0), 0.076, 0.040, 'X', idx_trim, uv_layer, UV_REGIONS["trim_chrome"], segs=24, bevel_r=0.004)
    add_cyl_part(bm, (sign * (w_half - 0.016), 0.0, 0.0), 0.035, 0.016, 'X', idx_trim, uv_layer, UV_REGIONS["trim_dark"],   segs=20, bevel_r=0.003)
    for b_i in range(5):
        ang = 2.0 * math.pi * b_i / 5.0
        by = 0.054 * math.cos(ang)
        bz = 0.054 * math.sin(ang)
        add_cyl_part(bm, (sign * (w_half - 0.018), by, bz), 0.009, 0.016, 'X', idx_trim, uv_layer, UV_REGIONS["trim_dark"], segs=10, bevel_r=0.0015)

    # 5 Sculpted Twin V-Spokes (10 wide machined alloy spokes matching sheet.jpg!)
    for s_i in range(5):
        base_ang = 2.0 * math.pi * s_i / 5.0
        for side_s in (-1.0, 1.0):
            a0 = base_ang + side_s * 0.13
            a1 = base_ang + side_s * 0.22
            r0 = 0.062
            r1 = 0.234
            spk_verts = []
            for rad, ang_s, x_out, w_s, thick_s in [
                (r0,               a0,               w_half - 0.024, 0.022, 0.024),
                (0.5 * (r0 + r1),  0.5 * (a0 + a1),  w_half - 0.012, 0.019, 0.020),
                (r1,               a1,               w_half - 0.016, 0.024, 0.022),
            ]:
                cy = rad * math.cos(ang_s)
                cz = rad * math.sin(ang_s)
                ty = -math.sin(ang_s)
                tz =  math.cos(ang_s)
                ring_v = [
                    bm.verts.new(g2b(sign * x_out,             cy - ty * w_s,        cz - tz * w_s)),
                    bm.verts.new(g2b(sign * x_out,             cy + ty * w_s,        cz + tz * w_s)),
                    bm.verts.new(g2b(sign * (x_out - thick_s), cy + ty * w_s * 0.72, cz + tz * w_s * 0.72)),
                    bm.verts.new(g2b(sign * (x_out - thick_s), cy - ty * w_s * 0.72, cz - tz * w_s * 0.72)),
                ]
                spk_verts.append(ring_v)
            spk_faces = []
            for k in range(2):
                for j in range(4):
                    jn = (j + 1) % 4
                    v1, v2, v3, v4 = spk_verts[k][j], spk_verts[k][jn], spk_verts[k+1][jn], spk_verts[k+1][j]
                    f = bm.faces.new((v1, v2, v3, v4) if is_left else (v1, v4, v3, v2))
                    spk_faces.append(f)
            assign_faces_mat_and_box_uv(bm, spk_faces, idx_trim, uv_layer, UV_REGIONS["trim_chrome"], scale_uv=2.0)

    # Internal Brake Disc Rotor & Brake Caliper
    add_cyl_part(bm, (sign * (w_half - 0.065), 0.0, 0.0), 0.178, 0.020, 'X', idx_trim, uv_layer, UV_REGIONS["trim_dark"], segs=32, bevel_r=0.002)
    cal_z = -0.115 if z_hub > 0 else 0.115
    add_box_part(
        bm,
        min(sign * (w_half - 0.085), sign * (w_half - 0.040)),
        max(sign * (w_half - 0.085), sign * (w_half - 0.040)),
        -0.055, 0.085,
        cal_z - 0.045, cal_z + 0.045,
        idx_trim, uv_layer, UV_REGIONS["trim_chrome"], bevel_r=0.006, segs=2
    )

    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    bm.to_mesh(mesh)
    bm.free()
    return obj

WHEEL_OBJS = [
    build_wheel("Wheel_FL", +0.770, 0.345, +1.400, is_left=True),
    build_wheel("Wheel_FR", -0.770, 0.345, +1.400, is_left=False),
    build_wheel("Wheel_RL", +0.770, 0.345, -1.400, is_left=True),
    build_wheel("Wheel_RR", -0.770, 0.345, -1.400, is_left=False),
]
print("Built 4 multi-spoke alloy wheels.")

# ---------------------------------------------------------------------------
# 8. BUILD STEERING WHEEL (SteeringWheel)
# ---------------------------------------------------------------------------
def build_steering_wheel():
    mesh = bpy.data.meshes.new("SteeringWheel")
    obj = bpy.data.objects.new("SteeringWheel", mesh)
    bpy.context.collection.objects.link(obj)
    obj.location = g2b(+0.380, 0.865, +0.460)

    bm = bmesh.new()
    uv_layer = bm.loops.layers.uv.new("UVMap")
    idx_trim = ensure_mat_slot(obj, "Trim")

    r_major = 0.172
    r_minor = 0.017
    n_u = 32
    n_v = 12
    rim_rings = []
    for i in range(n_u):
        u_ang = 2.0 * math.pi * i / n_u
        cu, su = math.cos(u_ang), math.sin(u_ang)
        ring = []
        for j in range(n_v):
            v_ang = 2.0 * math.pi * j / n_v
            cv, sv = math.cos(v_ang), math.sin(v_ang)
            rx = (r_major + r_minor * cv) * cu
            ry = (r_major + r_minor * cv) * su
            rz = r_minor * sv
            ring.append(bm.verts.new(g2b(rx, ry, rz)))
        rim_rings.append(ring)

    rim_faces = []
    for i in range(n_u):
        inext = (i + 1) % n_u
        for j in range(n_v):
            jnext = (j + 1) % n_v
            f = bm.faces.new((rim_rings[i][j], rim_rings[inext][j], rim_rings[inext][jnext], rim_rings[i][jnext]))
            rim_faces.append(f)
    assign_faces_mat_and_box_uv(bm, rim_faces, idx_trim, uv_layer, UV_REGIONS["trim_leather"], scale_uv=2.5)

    add_cyl_part(bm, (0.0, 0.0, 0.0),  0.060, 0.040, 'Z', idx_trim, uv_layer, UV_REGIONS["trim_dark"],   segs=20, bevel_r=0.005)
    add_cyl_part(bm, (0.0, 0.0, 0.08), 0.030, 0.150, 'Z', idx_trim, uv_layer, UV_REGIONS["trim_dark"],   segs=16, bevel_r=0.003)
    add_box_part(bm, -0.160, -0.050, -0.014, +0.014, -0.009, +0.009, idx_trim, uv_layer, UV_REGIONS["trim_chrome"], bevel_r=0.003, segs=1)
    add_box_part(bm, +0.050, +0.160, -0.014, +0.014, -0.009, +0.009, idx_trim, uv_layer, UV_REGIONS["trim_chrome"], bevel_r=0.003, segs=1)
    add_box_part(bm, -0.016, +0.016, -0.160, -0.050, -0.009, +0.009, idx_trim, uv_layer, UV_REGIONS["trim_chrome"], bevel_r=0.003, segs=1)

    bmesh.ops.rotate(bm, matrix=Matrix.Rotation(math.radians(-24.0), 3, 'X'), verts=bm.verts)
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    bm.to_mesh(mesh)
    bm.free()
    return obj

STEERING_OBJ = build_steering_wheel()
print("Built SteeringWheel.")

# ---------------------------------------------------------------------------
# 9. BUILD FOUR HINGED DOORS (Door_FL, Door_FR, Door_RL, Door_RR)
# ---------------------------------------------------------------------------
# Notice how in our previous run, separate disconnected strips got rounded into ovals by
# Catmull-Clark subsurf! Here we build continuous, connected vertex grids for the door
# skin, chequered band, beltline chrome, window glass, and upper chrome frame, and use
# SIMPLE subdivision so all panel gaps and window frames remain razor-crisp!

def get_side_profile_x(y_up, z_front):
    """Returns outer body half-width X at height y_up and longitudinal position z_front."""
    if y_up <= 0.22:
        t = max(0.0, (0.22 - y_up) / 0.10)
        w = 0.875 - 0.030 * t
    elif y_up <= 0.56:
        t = (y_up - 0.22) / (0.56 - 0.22)
        w = 0.875 + 0.032 * math.sin(t * math.pi * 0.65)
    elif y_up <= 0.94:
        t = (y_up - 0.56) / (0.94 - 0.56)
        w = 0.902 - 0.024 * (t ** 1.4)
    else:
        t = min(1.0, (y_up - 0.94) / (1.44 - 0.94))
        w = 0.878 - 0.220 * (t ** 1.18)
    return w

def get_roofline_y(z_front):
    """Returns estate roof / window top height Y at longitudinal position z_front."""
    if z_front >= 0.18:
        t = min(1.0, max(0.0, (0.86 - z_front) / (0.86 - 0.18)))
        return 0.94 + (1.445 - 0.94) * (1.0 - (1.0 - t) ** 2.1)
    elif z_front >= -1.78:
        t = (0.18 - z_front) / (0.18 - (-1.78))
        return 1.445 - 0.060 * (t ** 1.5)
    else:
        t = min(1.0, (-1.78 - z_front) / 0.50)
        return 1.385 - 0.42 * (t ** 1.4)

def build_door(name, is_front=True, is_left=True):
    sign = 1.0 if is_left else -1.0
    if is_front:
        z_hinge = +0.840
        z_rear  = -0.075
    else:
        z_hinge = -0.082
        z_rear  = -0.960

    x_hinge = sign * 0.885
    y_hinge = 0.580

    mesh = bpy.data.meshes.new(name)
    obj = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(obj)
    obj.location = g2b(x_hinge, y_hinge, z_hinge)

    bm = bmesh.new()
    uv_layer = bm.loops.layers.uv.new("UVMap")

    idx_paint  = ensure_mat_slot(obj, "Paint")
    idx_trim   = ensure_mat_slot(obj, "Trim")
    idx_livery = ensure_mat_slot(obj, "Livery")
    idx_glass  = ensure_mat_slot(obj, "Glass")
    idx_lamps  = ensure_mat_slot(obj, "Lamps")

    def w2l(x, y, z):
        return g2b(x - x_hinge, y - y_hinge, z - z_hinge)

    # 1. Lower Painted Door Skin + Integrated Livery Chequered Band Row + Beltline Chrome Row
    n_z = 16
    # y_levels:
    #   0: 0.235 (bottom of door)
    #   1: 0.290 (lower door crease)
    #   2: 0.440
    #   3: 0.600
    #   4: 0.740
    #   5: 0.818 (bottom of chequered band)
    #   6: 0.882 (top of chequered band -> row 5..6 is Livery chequered band!)
    #   7: 0.940 (top of painted door -> row 6..7 is Paint!)
    #   8: 0.962 (top of beltline chrome -> row 7..8 is Chrome Trim!)
    y_levels = [0.235, 0.290, 0.440, 0.600, 0.740, 0.818, 0.882, 0.940, 0.962]
    z_vals = [z_hinge - (z_hinge - z_rear) * (i / n_z) for i in range(n_z + 1)]

    outer_grid = []
    inner_grid = []
    for z in z_vals:
        col_out = []
        col_in  = []
        for j, y in enumerate(y_levels):
            y_adj = y
            if not is_front and z < -0.82 and y < 0.52:
                dz = z - (-1.40)
                if abs(dz) < 0.43:
                    arch_y = 0.345 + math.sqrt(max(0.0, 0.43**2 - dz**2))
                    y_adj = max(y, arch_y)
            x_out = sign * get_side_profile_x(y_adj, z)
            if j in (5, 6):
                x_out += sign * 0.0015  # 1.5mm proud chequered band
            elif j == 8:
                x_out += sign * 0.003   # 3mm proud chrome beltline moulding
            x_in  = sign * (abs(x_out) - 0.045)
            col_out.append(bm.verts.new(w2l(x_out, y_adj, z)))
            col_in.append(bm.verts.new(w2l(x_in,  y_adj, z)))
        outer_grid.append(col_out)
        inner_grid.append(col_in)

    paint_faces = []
    check_faces = []
    chrome_belt_faces = []
    inner_faces = []
    for i in range(n_z):
        for j in range(len(y_levels) - 1):
            v1 = outer_grid[i][j]
            v2 = outer_grid[i+1][j]
            v3 = outer_grid[i+1][j+1]
            v4 = outer_grid[i][j+1]
            f_out = bm.faces.new((v1, v2, v3, v4) if is_left else (v1, v4, v3, v2))
            if j == 5:
                check_faces.append(f_out)
            elif j == 7:
                chrome_belt_faces.append(f_out)
            else:
                paint_faces.append(f_out)

            vi1 = inner_grid[i][j]
            vi2 = inner_grid[i+1][j]
            vi3 = inner_grid[i+1][j+1]
            vi4 = inner_grid[i][j+1]
            f_in = bm.faces.new((vi1, vi4, vi3, vi2) if is_left else (vi1, vi2, vi3, vi4))
            inner_faces.append((f_in, j))

    edge_faces = []
    for i in range(n_z):
        f_bot = bm.faces.new((outer_grid[i][0], inner_grid[i][0], inner_grid[i+1][0], outer_grid[i+1][0]) if is_left else (outer_grid[i][0], outer_grid[i+1][0], inner_grid[i+1][0], inner_grid[i][0]))
        top_j = len(y_levels) - 1
        f_top = bm.faces.new((outer_grid[i][top_j], outer_grid[i+1][top_j], inner_grid[i+1][top_j], inner_grid[i][top_j]) if is_left else (outer_grid[i][top_j], inner_grid[i][top_j], inner_grid[i+1][top_j], outer_grid[i+1][top_j]))
        edge_faces.extend([f_bot, f_top])
    for j in range(len(y_levels) - 1):
        f_fr = bm.faces.new((outer_grid[0][j], outer_grid[0][j+1], inner_grid[0][j+1], inner_grid[0][j]) if is_left else (outer_grid[0][j], inner_grid[0][j], inner_grid[0][j+1], outer_grid[0][j+1]))
        f_rr = bm.faces.new((outer_grid[n_z][j], inner_grid[n_z][j], inner_grid[n_z][j+1], outer_grid[n_z][j+1]) if is_left else (outer_grid[n_z][j], outer_grid[n_z][j+1], inner_grid[n_z][j+1], inner_grid[n_z][j]))
        edge_faces.extend([f_fr, f_rr])

    assign_faces_mat_and_box_uv(bm, paint_faces,       idx_paint, uv_layer, UV_REGIONS["paint"], scale_uv=0.45)
    assign_faces_planar_uv(bm,      check_faces,       idx_livery, uv_layer, UV_REGIONS["livery_check"], axis='X')
    assign_faces_mat_and_box_uv(bm, chrome_belt_faces, idx_trim,  uv_layer, UV_REGIONS["trim_chrome"], scale_uv=1.0)
    assign_faces_mat_and_box_uv(bm, edge_faces,        idx_trim,  uv_layer, UV_REGIONS["trim_dark"], scale_uv=0.8)
    for f_in, j_idx in inner_faces:
        reg = UV_REGIONS["trim_leather"] if 2 <= j_idx <= 5 else UV_REGIONS["trim_dark"]
        assign_faces_mat_and_box_uv(bm, [f_in], idx_trim, uv_layer, reg, scale_uv=0.8)

    # 2. Continuous Connected Upper Window Grid (Glass + Upper Chrome Frame + Glossy B-Pillar Trim)
    # Using shared vertices across i=0..n_z so subdivision never tears the window into vertical slats!
    win_bot_v = []
    win_top_v = []
    frm_top_v = []
    for z in z_vals:
        yb = 0.960
        yt = max(yb + 0.025, get_roofline_y(z) - 0.032)
        yf = yt + 0.026
        xb = sign * (get_side_profile_x(yb, z) - 0.005)
        xt = sign * (get_side_profile_x(yt, z) - 0.005)
        xf = sign * (get_side_profile_x(yf, z) + 0.002)
        win_bot_v.append(bm.verts.new(w2l(xb, yb, z)))
        win_top_v.append(bm.verts.new(w2l(xt, yt, z)))
        frm_top_v.append(bm.verts.new(w2l(xf, yf, z)))

    win_faces = []
    frame_faces = []
    pillar_faces = []
    for i in range(n_z):
        fg = bm.faces.new((win_bot_v[i], win_bot_v[i+1], win_top_v[i+1], win_top_v[i]) if is_left else (win_bot_v[i], win_top_v[i], win_top_v[i+1], win_bot_v[i+1]))
        if i == n_z - 1:
            # Rearmost column of the door window is the vertical glossy black B-pillar / C-pillar garnish!
            pillar_faces.append(fg)
        else:
            win_faces.append(fg)
        ff = bm.faces.new((win_top_v[i], win_top_v[i+1], frm_top_v[i+1], frm_top_v[i]) if is_left else (win_top_v[i], frm_top_v[i], frm_top_v[i+1], win_top_v[i+1]))
        frame_faces.append(ff)

    assign_faces_mat_and_box_uv(bm, win_faces,    idx_glass, uv_layer, UV_REGIONS["glass"], scale_uv=0.5)
    assign_faces_mat_and_box_uv(bm, frame_faces,  idx_trim,  uv_layer, UV_REGIONS["trim_chrome"], scale_uv=1.0)
    assign_faces_mat_and_box_uv(bm, pillar_faces, idx_trim,  uv_layer, UV_REGIONS["trim_dark"], scale_uv=1.0)

    # 3. Sculpted Exterior Door Pull Handle (Painted grip + Chrome top insert)
    z_hdl_c = z_rear + 0.165
    y_hdl_c = 0.850
    x_hdl_base = sign * get_side_profile_x(y_hdl_c, z_hdl_c)
    add_box_part(
        bm,
        min(x_hdl_base - x_hinge, x_hdl_base + sign * 0.024 - x_hinge),
        max(x_hdl_base - x_hinge, x_hdl_base + sign * 0.024 - x_hinge),
        y_hdl_c - 0.015 - y_hinge, y_hdl_c + 0.015 - y_hinge,
        z_hdl_c - 0.092 - z_hinge, z_hdl_c + 0.092 - z_hinge,
        idx_paint, uv_layer, UV_REGIONS["paint"], bevel_r=0.005, segs=2
    )
    add_box_part(
        bm,
        min(x_hdl_base + sign * 0.018 - x_hinge, x_hdl_base + sign * 0.027 - x_hinge),
        max(x_hdl_base + sign * 0.018 - x_hinge, x_hdl_base + sign * 0.027 - x_hinge),
        y_hdl_c + 0.004 - y_hinge, y_hdl_c + 0.017 - y_hinge,
        z_hdl_c - 0.082 - z_hinge, z_hdl_c + 0.082 - z_hinge,
        idx_trim, uv_layer, UV_REGIONS["trim_chrome"], bevel_r=0.002, segs=1
    )

    # 4. Interior Door Armrest (Cognac Leather)
    add_box_part(
        bm,
        min(sign * 0.765 - x_hinge, sign * 0.845 - x_hinge),
        max(sign * 0.765 - x_hinge, sign * 0.845 - x_hinge),
        0.710 - y_hinge, 0.765 - y_hinge,
        z_rear + 0.12 - z_hinge, z_hinge - 0.14 - z_hinge,
        idx_trim, uv_layer, UV_REGIONS["trim_leather"], bevel_r=0.010, segs=2
    )

    # 5. Front Doors: Sculpted Wing Mirror on Door Shoulder (staying within ±0.925 m overall width!)
    if is_front:
        z_mir = z_hinge - 0.165
        y_mir = 0.965
        x_mir_in  = sign * 0.845
        x_mir_out = sign * 0.924
        add_box_part(
            bm,
            min(x_mir_in - x_hinge, sign * 0.895 - x_hinge),
            max(x_mir_in - x_hinge, sign * 0.895 - x_hinge),
            y_mir - 0.032 - y_hinge, y_mir + 0.005 - y_hinge,
            z_mir - 0.030 - z_hinge, z_mir + 0.040 - z_hinge,
            idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.005, segs=2
        )
        add_box_part(
            bm,
            min(sign * 0.865 - x_hinge, x_mir_out - x_hinge),
            max(sign * 0.865 - x_hinge, x_mir_out - x_hinge),
            y_mir - 0.010 - y_hinge, y_mir + 0.078 - y_hinge,
            z_mir - 0.060 - z_hinge, z_mir + 0.042 - z_hinge,
            idx_paint, uv_layer, UV_REGIONS["paint"], bevel_r=0.015, segs=2
        )
        add_box_part(
            bm,
            min(sign * 0.875 - x_hinge, x_mir_out - sign * 0.004 - x_hinge),
            max(sign * 0.875 - x_hinge, x_mir_out - sign * 0.004 - x_hinge),
            y_mir + 0.022 - y_hinge, y_mir + 0.036 - y_hinge,
            z_mir + 0.036 - z_hinge, z_mir + 0.045 - z_hinge,
            idx_lamps, uv_layer, UV_REGIONS["lamps_head"], bevel_r=0.002, segs=1
        )
        add_box_part(
            bm,
            min(sign * 0.870 - x_hinge, x_mir_out - sign * 0.006 - x_hinge),
            max(sign * 0.870 - x_hinge, x_mir_out - sign * 0.006 - x_hinge),
            y_mir - 0.004 - y_hinge, y_mir + 0.070 - y_hinge,
            z_mir - 0.062 - z_hinge, z_mir - 0.054 - z_hinge,
            idx_trim, uv_layer, UV_REGIONS["trim_chrome"], bevel_r=0.002, segs=1
        )

    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    bm.to_mesh(mesh)
    bm.free()
    return obj

DOOR_OBJS = [
    build_door("Door_FL", is_front=True,  is_left=True),
    build_door("Door_FR", is_front=True,  is_left=False),
    build_door("Door_RL", is_front=False, is_left=True),
    build_door("Door_RR", is_front=False, is_left=False),
]
print("Built 4 hinged doors with continuous connected window & frame grids.")

# ---------------------------------------------------------------------------
# 10. BUILD MAIN EXECUTIVE ESTATE BODY SHELL, ROOF TAXI SIGN & INTERIOR (Body)
# ---------------------------------------------------------------------------
def build_body():
    mesh = bpy.data.meshes.new("Body")
    obj = bpy.data.objects.new("Body", mesh)
    bpy.context.collection.objects.link(obj)
    obj.location = g2b(0.0, 0.0, 0.0)

    bm = bmesh.new()
    uv_layer = bm.loops.layers.uv.new("UVMap")

    idx_paint  = ensure_mat_slot(obj, "Paint")
    idx_trim   = ensure_mat_slot(obj, "Trim")
    idx_livery = ensure_mat_slot(obj, "Livery")
    idx_glass  = ensure_mat_slot(obj, "Glass")
    idx_lamps  = ensure_mat_slot(obj, "Lamps")

    def arch_cut_y(z, y_base):
        for z_ax in (+1.400, -1.400):
            dz = z - z_ax
            r_arch = 0.392
            if abs(dz) < r_arch:
                y_circle = 0.345 + math.sqrt(max(0.0, r_arch * r_arch - dz * dz))
                if y_circle > y_base:
                    return y_circle
        return y_base

    def arch_flare_x(z, y):
        for z_ax in (+1.400, -1.400):
            dz = z - z_ax
            dy = y - 0.345
            rad = math.sqrt(dz * dz + dy * dy)
            if 0.36 <= rad <= 0.46 and dy > -0.10:
                return 0.022 * math.sin((rad - 0.36) / 0.10 * math.pi)
        return 0.0

    # 1. Seamless Front Clip, Bonnet, Front Fenders & Curved Front Bumper Fascia (z from +0.840 to +2.410)
    z_front_stations = [
        +0.840, +0.930, +1.030, +1.130, +1.230, +1.315, +1.400, +1.485,
        +1.570, +1.670, +1.760, +1.850, +1.950, +2.050, +2.140, +2.220,
        +2.285, +2.335, +2.365
    ]

    def get_hood_center_y(z):
        if z <= 2.15:
            t = (z - 0.840) / (2.150 - 0.840)
            return 0.962 - 0.210 * (t ** 1.45)
        elif z <= 2.32:
            t = (z - 2.150) / (2.320 - 2.150)
            return 0.752 - 0.088 * (t ** 1.35)
        else:
            t = (z - 2.320) / max(0.01, 2.365 - 2.320)
            return 0.664 - 0.032 * t

    def get_front_plan_x(z):
        if z <= 1.76:
            return 1.0
        t = (z - 1.76) / (2.365 - 1.76)
        return 1.0 - 0.31 * (t ** 1.75)

    for sign in (+1.0, -1.0):
        fender_grid = []
        hood_grid   = []
        for z in z_front_stations:
            plan_scale = get_front_plan_x(z)
            y_bot = arch_cut_y(z, 0.165 if z < 2.05 else 0.182)
            y_top = get_hood_center_y(z) - 0.022
            col = []
            for frac in [0.0, 0.22, 0.45, 0.68, 0.86, 1.0]:
                yy = y_bot + frac * max(0.06, y_top - y_bot)
                xx_base = get_side_profile_x(min(yy, 0.92), z) * plan_scale
                if z < 1.92:
                    xx_base += arch_flare_x(z, yy)
                # Curve the front corner profile in side view near z > 2.25
                z_local = z
                if z > 2.25:
                    t_c = (z - 2.25) / (2.365 - 2.25)
                    z_local = z - 0.035 * t_c * ((frac - 0.48) ** 2) * 3.5
                col.append(bm.verts.new(g2b(sign * xx_base, yy, z_local)))
            fender_grid.append(col)

            x_fend = abs(col[-1].co.x)
            y_fend = col[-1].co.z
            z_fend = -col[-1].co.y
            y_cent = get_hood_center_y(z)
            h_col = [col[-1]]
            for h_frac in [0.76, 0.50, 0.25, 0.0]:
                hx = sign * x_fend * h_frac
                dome = 0.014 * math.sin((1.0 - h_frac) * math.pi) if z < 2.22 else 0.0
                hy = y_fend + (y_cent - y_fend) * (1.0 - h_frac ** 1.6) + dome
                hz = z_fend + (0.025 * (1.0 - h_frac ** 1.8) if z > 2.20 else 0.0)
                h_col.append(bm.verts.new(g2b(hx, hy, hz)))
            hood_grid.append(h_col)

        f_fender = []
        f_hl_base = []
        for i in range(len(z_front_stations) - 1):
            for j in range(len(fender_grid[0]) - 1):
                v1, v2, v3, v4 = fender_grid[i][j], fender_grid[i+1][j], fender_grid[i+1][j+1], fender_grid[i][j+1]
                f = bm.faces.new((v1, v2, v3, v4) if sign > 0 else (v1, v4, v3, v2))
                if j == 4 and i >= 14:
                    f_hl_base.append(f)
                else:
                    f_fender.append(f)
            for j in range(len(hood_grid[0]) - 1):
                v1, v2, v3, v4 = hood_grid[i][j], hood_grid[i+1][j], hood_grid[i+1][j+1], hood_grid[i][j+1]
                f_fender.append(bm.faces.new((v1, v2, v3, v4) if sign > 0 else (v1, v4, v3, v2)))
        assign_faces_mat_and_box_uv(bm, f_fender, idx_paint, uv_layer, UV_REGIONS["paint"], scale_uv=0.45)

        # Seamless Curved Front Bumper Fascia closing directly from fender_grid[-1] inward to x=0!
        front_edge_col = fender_grid[-1]
        nose_grid = []
        # Profile bulges forward to +2.410 at grille/bumper beam (j=2,3) and tucks back at lower chin (j=0) and upper hood edge (j=5)
        z_center_profile = [+2.360, +2.392, +2.410, +2.408, +2.398, +2.382]
        for j, v_corner in enumerate(front_edge_col):
            cx_corner = abs(v_corner.co.x)
            cy_corner = v_corner.co.z
            cz_corner = -v_corner.co.y
            zc = z_center_profile[j]
            row = [v_corner]
            for x_frac in [0.76, 0.50, 0.25, 0.0]:
                nx = sign * cx_corner * x_frac
                nz = cz_corner + (zc - cz_corner) * (1.0 - x_frac ** 1.7)
                row.append(bm.verts.new(g2b(nx, cy_corner, nz)))
            nose_grid.append(row)
        f_nose = []
        f_intake_side = []
        for j in range(len(nose_grid) - 1):
            for k in range(len(nose_grid[0]) - 1):
                v1, v2, v3, v4 = nose_grid[j][k], nose_grid[j][k+1], nose_grid[j+1][k+1], nose_grid[j+1][k]
                f = bm.faces.new((v1, v4, v3, v2) if sign > 0 else (v1, v2, v3, v4))
                # Outer lower cells (j=0..1, k=0) form the sporty dark honeycomb side air intake pockets!
                if j in (0, 1) and k == 0:
                    f_intake_side.append(f)
                elif j == 4 and k == 0:
                    f_hl_base.append(f)
                else:
                    f_nose.append(f)
        assign_faces_mat_and_box_uv(bm, f_nose,        idx_paint, uv_layer, UV_REGIONS["paint"], scale_uv=0.45)
        assign_faces_mat_and_box_uv(bm, f_intake_side, idx_trim,  uv_layer, UV_REGIONS["trim_dark"], scale_uv=1.2)
        assign_faces_planar_uv(bm,      f_hl_base,     idx_lamps, uv_layer, UV_REGIONS["lamps_head"], axis='Z', flip_u=(sign > 0))

        # Proud Contoured Swept-Back LED Headlight Lens & Chrome Bezel following exact nose_grid + fender_grid stations!
        hl_station_pairs = [
            (nose_grid[4][1],   nose_grid[5][1]),
            (nose_grid[4][0],   nose_grid[5][0]),
            (fender_grid[17][4], fender_grid[17][5]),
            (fender_grid[16][4], fender_grid[16][5]),
            (fender_grid[15][4], fender_grid[15][5]),
            (fender_grid[14][4], fender_grid[14][5]),
        ]
        hl_front_v = []
        for vb_src, vt_src in hl_station_pairs:
            bx, by, bz = vb_src.co.x, vb_src.co.z, -vb_src.co.y
            tx, ty, tz = vt_src.co.x, vt_src.co.z, -vt_src.co.y
            hl_front_v.append((
                bm.verts.new(g2b(bx + sign * 0.005, by, bz + 0.005)),
                bm.verts.new(g2b(tx + sign * 0.005, ty, tz + 0.005)),
            ))
        hl_faces = []
        hl_trim_faces = []
        for k in range(len(hl_front_v) - 1):
            vb0, vt0 = hl_front_v[k]
            vb1, vt1 = hl_front_v[k+1]
            vbb0, vbt0 = hl_station_pairs[k]
            vbb1, vbt1 = hl_station_pairs[k+1]
            hl_faces.append(bm.faces.new((vb0, vb1, vt1, vt0) if sign < 0 else (vb0, vt0, vt1, vb1)))
            hl_trim_faces.append(bm.faces.new((vt0, vt1, vbt1, vbt0) if sign < 0 else (vt0, vbt0, vbt1, vt1)))
            hl_trim_faces.append(bm.faces.new((vbb0, vbb1, vb1, vb0) if sign < 0 else (vbb0, vb0, vb1, vbb1)))
        assign_faces_planar_uv(bm,      hl_faces,      idx_lamps, uv_layer, UV_REGIONS["lamps_head"], axis='Z', flip_u=(sign > 0))
        assign_faces_mat_and_box_uv(bm, hl_trim_faces, idx_trim,  uv_layer, UV_REGIONS["trim_chrome"], scale_uv=1.0)

    # 2. Rear Clip, Rear Fenders, Integrated Chequered Band, Continuous Quarter Window & Curved Tailgate
    z_rear_stations = [
        -0.960, -1.060, -1.160, -1.260, -1.330, -1.400, -1.470, -1.550,
        -1.650, -1.750, -1.850, -1.950, -2.050, -2.140, -2.220, -2.290,
        -2.340, -2.370
    ]

    def get_rear_plan_x(z):
        if z >= -1.82:
            return 1.0
        t = (-1.82 - z) / (2.370 - 1.82)
        return 1.0 - 0.24 * (t ** 1.65)

    for sign in (+1.0, -1.0):
        rf_grid = []
        for i, z in enumerate(z_rear_stations):
            ps = get_rear_plan_x(z)
            y_bot = arch_cut_y(z, 0.175 if z > -2.05 else 0.215)
            y_belt = 0.945 - max(0.0, (-1.95 - z) * 0.10)
            y0 = y_bot
            y1 = y_bot + 0.28 * (0.740 - y_bot)
            y2 = y_bot + 0.62 * (0.740 - y_bot)
            y3 = max(y2 + 0.03, 0.745)
            y4 = 0.818
            y5 = 0.882
            y6 = max(0.910, y_belt)
            col = []
            for j, yy in enumerate([y0, y1, y2, y3, y4, y5, y6]):
                xx = get_side_profile_x(min(yy, 0.94), z) * ps
                if z > -1.90:
                    xx += arch_flare_x(z, yy)
                if j in (4, 5) and i <= 13:
                    xx += 0.0015
                # Curve the rear bumper & tailgate side silhouette near z < -2.20 so it isn't a vertical cliff!
                z_loc = z
                if z < -2.20:
                    t_r = (-2.20 - z) / (2.370 - 2.20)
                    # Bulge out at bumper beam (j=1,2), tuck in at lower diffuser (j=0) and upper tailgate (j=4,5,6)
                    profile_offsets = [+0.035, -0.015, -0.020, +0.010, +0.045, +0.065, +0.095]
                    z_loc = z + t_r * profile_offsets[j]
                col.append(bm.verts.new(g2b(sign * xx, yy, z_loc)))
            rf_grid.append(col)

        f_rf = []
        f_rq_band = []
        f_tl_base = []
        for i in range(len(z_rear_stations) - 1):
            for j in range(len(rf_grid[0]) - 1):
                v1, v2, v3, v4 = rf_grid[i][j], rf_grid[i+1][j], rf_grid[i+1][j+1], rf_grid[i][j+1]
                f = bm.faces.new((v1, v2, v3, v4) if sign > 0 else (v1, v4, v3, v2))
                if j == 4 and i < 13:
                    f_rq_band.append(f)
                elif j == 4 and i >= 13:
                    f_tl_base.append(f)
                else:
                    f_rf.append(f)
        assign_faces_mat_and_box_uv(bm, f_rf,      idx_paint,  uv_layer, UV_REGIONS["paint"], scale_uv=0.45)
        assign_faces_planar_uv(bm,      f_rq_band, idx_livery, uv_layer, UV_REGIONS["livery_check"], axis='X')

        # Seamless Curved Lower Rear Tailgate & Rear Bumper closing from rf_grid[-1] inward to x=0!
        rear_edge_col = rf_grid[-1]
        z_rear_center = [-2.355, -2.405, -2.410, -2.365, -2.310, -2.285, -2.255]
        tg_grid = []
        for j, v_rc in enumerate(rear_edge_col):
            cx_rc = abs(v_rc.co.x)
            cy_rc = v_rc.co.z
            cz_rc = -v_rc.co.y
            zrc = z_rear_center[j]
            row = [v_rc]
            for x_frac in [0.75, 0.50, 0.25, 0.0]:
                rx = sign * cx_rc * x_frac
                rz = cz_rc + (zrc - cz_rc) * (1.0 - x_frac ** 1.6)
                row.append(bm.verts.new(g2b(rx, cy_rc, max(-2.410, rz))))
            tg_grid.append(row)
        f_tg = []
        for j in range(len(tg_grid) - 1):
            for k in range(len(tg_grid[0]) - 1):
                v1, v2, v3, v4 = tg_grid[j][k], tg_grid[j][k+1], tg_grid[j+1][k+1], tg_grid[j+1][k]
                f = bm.faces.new((v1, v2, v3, v4) if sign > 0 else (v1, v4, v3, v2))
                if j == 4 and k in (0, 1):
                    f_tl_base.append(f)
                else:
                    f_tg.append(f)
        assign_faces_mat_and_box_uv(bm, f_tg,      idx_paint, uv_layer, UV_REGIONS["paint"], scale_uv=0.45)
        assign_faces_planar_uv(bm,      f_tl_base, idx_lamps, uv_layer, UV_REGIONS["lamps_tail"], axis='Z', flip_u=(sign < 0))

        # Proud Contoured Wrap-Around Ruby-Red LED Taillight Lens following exact tg_grid + rf_grid stations!
        tl_station_pairs = [
            (tg_grid[4][2],  tg_grid[5][2]),
            (tg_grid[4][1],  tg_grid[5][1]),
            (tg_grid[4][0],  tg_grid[5][0]),
            (rf_grid[16][4], rf_grid[16][5]),
            (rf_grid[15][4], rf_grid[15][5]),
            (rf_grid[14][4], rf_grid[14][5]),
            (rf_grid[13][4], rf_grid[13][5]),
        ]
        tl_rear_v = []
        for vb_src, vt_src in tl_station_pairs:
            bx, by, bz = vb_src.co.x, vb_src.co.z, -vb_src.co.y
            tx, ty, tz = vt_src.co.x, vt_src.co.z, -vt_src.co.y
            tl_rear_v.append((
                bm.verts.new(g2b(bx + sign * 0.005, by, bz - 0.005)),
                bm.verts.new(g2b(tx + sign * 0.005, ty, tz - 0.005)),
            ))
        tl_faces = []
        tl_trim_faces = []
        for k in range(len(tl_rear_v) - 1):
            vb0, vt0 = tl_rear_v[k]
            vb1, vt1 = tl_rear_v[k+1]
            vbb0, vbt0 = tl_station_pairs[k]
            vbb1, vbt1 = tl_station_pairs[k+1]
            tl_faces.append(bm.faces.new((vb0, vt0, vt1, vb1) if sign < 0 else (vb0, vb1, vt1, vt0)))
            tl_trim_faces.append(bm.faces.new((vt0, vbt0, vbt1, vt1) if sign < 0 else (vt0, vt1, vbt1, vbt0)))
            tl_trim_faces.append(bm.faces.new((vbb0, vb0, vb1, vbb1) if sign < 0 else (vbb0, vbb1, vb1, vb0)))
        assign_faces_planar_uv(bm,      tl_faces,      idx_lamps, uv_layer, UV_REGIONS["lamps_tail"], axis='Z', flip_u=(sign < 0))
        assign_faces_mat_and_box_uv(bm, tl_trim_faces, idx_trim,  uv_layer, UV_REGIONS["trim_dark"], scale_uv=1.0)

        # Continuous Connected Estate Rear Quarter Window + Chrome DLO Surround + Sleek Raked D-Pillar!
        z_rq_bot = [-0.960, -1.140, -1.320, -1.500, -1.680, -1.840, -2.040, -2.265]
        z_rq_top = [-0.960, -1.120, -1.280, -1.440, -1.580, -1.700, -1.800, -1.920]
        rq_c_bot, rq_w_bot, rq_w_top, rq_c_top = [], [], [], []
        for kb in range(len(z_rq_bot)):
            zb = z_rq_bot[kb]
            zt = z_rq_top[kb]
            ps_b = get_rear_plan_x(zb)
            ps_t = get_rear_plan_x(zt)
            yb = 0.942 - max(0.0, (-1.85 - zb) * 0.08)
            yt = get_roofline_y(zt) - 0.036
            yc_b = yb + 0.020
            yc_t = yt + 0.024
            rq_c_bot.append(bm.verts.new(g2b(sign * (get_side_profile_x(yb,   zb) * ps_b + 0.002), yb,   zb)))
            rq_w_bot.append(bm.verts.new(g2b(sign * (get_side_profile_x(yc_b, zb) * ps_b - 0.004), yc_b, zb)))
            rq_w_top.append(bm.verts.new(g2b(sign * (get_side_profile_x(yt,   zt) * ps_t - 0.004), yt,   zt)))
            rq_c_top.append(bm.verts.new(g2b(sign * (get_side_profile_x(yc_t, zt) * ps_t + 0.002), yc_t, zt)))

        rq_win_faces, rq_chr_faces, dp_faces = [], [], []
        for i in range(len(z_rq_bot) - 1):
            f_cb = bm.faces.new((rq_c_bot[i], rq_c_bot[i+1], rq_w_bot[i+1], rq_w_bot[i]) if sign > 0 else (rq_c_bot[i], rq_w_bot[i], rq_w_bot[i+1], rq_c_bot[i+1]))
            f_w  = bm.faces.new((rq_w_bot[i], rq_w_bot[i+1], rq_w_top[i+1], rq_w_top[i]) if sign > 0 else (rq_w_bot[i], rq_w_top[i], rq_w_top[i+1], rq_w_bot[i+1]))
            f_ct = bm.faces.new((rq_w_top[i], rq_w_top[i+1], rq_c_top[i+1], rq_c_top[i]) if sign > 0 else (rq_w_top[i], rq_c_top[i], rq_c_top[i+1], rq_w_top[i+1]))
            if i == len(z_rq_bot) - 2:
                dp_faces.extend([f_cb, f_w, f_ct])
            else:
                rq_chr_faces.extend([f_cb, f_ct])
                rq_win_faces.append(f_w)
        assign_faces_mat_and_box_uv(bm, rq_win_faces, idx_glass, uv_layer, UV_REGIONS["glass"], scale_uv=0.5)
        assign_faces_mat_and_box_uv(bm, rq_chr_faces, idx_trim,  uv_layer, UV_REGIONS["trim_chrome"], scale_uv=1.0)
        assign_faces_mat_and_box_uv(bm, dp_faces,     idx_paint, uv_layer, UV_REGIONS["paint"], scale_uv=0.5)

        # Painted A-Pillar & Roof Cantrail Arch (continuous strip from z=+0.88 to z=-1.92)
        z_cant = [+0.880, +0.680, +0.460, +0.220, -0.080, -0.500, -0.960, -1.400, -1.780, -1.920]
        cant_out_v, cant_in_v = [], []
        for z in z_cant:
            y_c = get_roofline_y(z)
            x_o = sign * get_side_profile_x(y_c, z) * get_rear_plan_x(z)
            x_i = sign * (abs(x_o) - 0.062)
            cant_out_v.append(bm.verts.new(g2b(x_o, y_c - 0.018, z)))
            cant_in_v.append(bm.verts.new(g2b(x_i,  y_c + 0.012, z)))
        cant_faces = []
        for i in range(len(z_cant) - 1):
            f_c = bm.faces.new((cant_out_v[i], cant_out_v[i+1], cant_in_v[i+1], cant_in_v[i]) if sign > 0 else (cant_out_v[i], cant_in_v[i], cant_in_v[i+1], cant_out_v[i+1]))
            cant_faces.append(f_c)
        assign_faces_mat_and_box_uv(bm, cant_faces, idx_paint, uv_layer, UV_REGIONS["paint"], scale_uv=0.5)

        # Continuous Rocker Sill Panel & Chrome Sill Strip under the doors (z in [-0.98, +0.86])
        z_sill = [+0.860, +0.400, -0.080, -0.520, -0.980]
        sill_v0, sill_v1, sill_v2, sill_v3 = [], [], [], []
        for z in z_sill:
            sill_v0.append(bm.verts.new(g2b(sign * 0.840, 0.155, z)))
            sill_v1.append(bm.verts.new(g2b(sign * 0.876, 0.194, z)))
            sill_v2.append(bm.verts.new(g2b(sign * 0.880, 0.212, z)))
            sill_v3.append(bm.verts.new(g2b(sign * 0.882, 0.238, z)))
        sill_faces, sill_chr = [], []
        for i in range(len(z_sill) - 1):
            f_s0 = bm.faces.new((sill_v0[i], sill_v0[i+1], sill_v1[i+1], sill_v1[i]) if sign > 0 else (sill_v0[i], sill_v1[i], sill_v1[i+1], sill_v0[i+1]))
            f_sc = bm.faces.new((sill_v1[i], sill_v1[i+1], sill_v2[i+1], sill_v2[i]) if sign > 0 else (sill_v1[i], sill_v2[i], sill_v2[i+1], sill_v1[i+1]))
            f_s2 = bm.faces.new((sill_v2[i], sill_v2[i+1], sill_v3[i+1], sill_v3[i]) if sign > 0 else (sill_v2[i], sill_v3[i], sill_v3[i+1], sill_v2[i+1]))
            sill_faces.extend([f_s0, f_s2])
            sill_chr.append(f_sc)
        assign_faces_mat_and_box_uv(bm, sill_faces, idx_paint, uv_layer, UV_REGIONS["paint"], scale_uv=0.5)
        assign_faces_mat_and_box_uv(bm, sill_chr,   idx_trim,  uv_layer, UV_REGIONS["trim_chrome"], scale_uv=1.0)

    # 3. Sculpted Crowned Roof Panel & Continuous Flush Silver Roof Rails
    z_roof = [+0.220, +0.020, -0.250, -0.550, -0.850, -1.150, -1.450, -1.720, -1.920]
    for sign in (+1.0, -1.0):
        roof_grid = []
        rail_in_b, rail_out_b, rail_out_t, rail_in_t = [], [], [], []
        for z in z_roof:
            y_edge = get_roofline_y(z) + 0.008
            x_edge = get_side_profile_x(get_roofline_y(z), z) * get_rear_plan_x(z) - 0.055
            row = []
            for x_frac in [1.0, 0.72, 0.42, 0.0]:
                rx = sign * x_edge * x_frac
                ry = y_edge + 0.022 * (1.0 - x_frac ** 1.8)
                row.append(bm.verts.new(g2b(rx, ry, z)))
            roof_grid.append(row)

            if -1.75 <= z <= 0.05:
                xr_c = sign * (x_edge - 0.032)
                yr_b = y_edge + 0.004
                yr_t = yr_b + 0.022
                rail_in_b.append(bm.verts.new(g2b(xr_c - sign * 0.015, yr_b, z)))
                rail_out_b.append(bm.verts.new(g2b(xr_c + sign * 0.015, yr_b, z)))
                rail_out_t.append(bm.verts.new(g2b(xr_c + sign * 0.011, yr_t, z)))
                rail_in_t.append(bm.verts.new(g2b(xr_c - sign * 0.011, yr_t, z)))

        f_roof = []
        for i in range(len(z_roof) - 1):
            for j in range(len(roof_grid[0]) - 1):
                v1, v2, v3, v4 = roof_grid[i][j], roof_grid[i+1][j], roof_grid[i+1][j+1], roof_grid[i][j+1]
                f_roof.append(bm.faces.new((v1, v2, v3, v4) if sign > 0 else (v1, v4, v3, v2)))
        assign_faces_mat_and_box_uv(bm, f_roof, idx_paint, uv_layer, UV_REGIONS["paint"], scale_uv=0.45)

        f_rail = []
        for i in range(len(rail_in_b) - 1):
            f1 = bm.faces.new((rail_out_b[i], rail_out_b[i+1], rail_out_t[i+1], rail_out_t[i]) if sign > 0 else (rail_out_b[i], rail_out_t[i], rail_out_t[i+1], rail_out_b[i+1]))
            f2 = bm.faces.new((rail_out_t[i], rail_out_t[i+1], rail_in_t[i+1],  rail_in_t[i])  if sign > 0 else (rail_out_t[i], rail_in_t[i],  rail_in_t[i+1],  rail_out_t[i+1]))
            f3 = bm.faces.new((rail_in_t[i],  rail_in_t[i+1],  rail_in_b[i+1],  rail_in_b[i])  if sign > 0 else (rail_in_t[i],  rail_in_b[i],  rail_in_b[i+1],  rail_in_t[i+1]))
            f_rail.extend([f1, f2, f3])
        f_rail.append(bm.faces.new((rail_in_b[0], rail_out_b[0], rail_out_t[0], rail_in_t[0]) if sign > 0 else (rail_in_b[0], rail_in_t[0], rail_out_t[0], rail_out_b[0])))
        f_rail.append(bm.faces.new((rail_in_b[-1], rail_in_t[-1], rail_out_t[-1], rail_out_b[-1]) if sign > 0 else (rail_in_b[-1], rail_out_b[-1], rail_out_t[-1], rail_in_t[-1])))
        assign_faces_mat_and_box_uv(bm, f_rail, idx_trim, uv_layer, UV_REGIONS["trim_chrome"], scale_uv=1.0)

    # 4. Sleek Raked Windshield (z from +0.86 to +0.20) & Flush Estate Rear Tailgate Window (z from -1.90 to -2.26)
    for sign in (+1.0, -1.0):
        ws_z = [+0.860, +0.680, +0.460, +0.220]
        ws_grid = []
        for z in ws_z:
            y_side = get_roofline_y(z) - 0.005
            x_side = get_side_profile_x(y_side, z) - 0.045
            row = []
            for x_frac in [1.0, 0.65, 0.32, 0.0]:
                wx = sign * x_side * x_frac
                wy = y_side + 0.018 * (1.0 - x_frac ** 1.6)
                wz = z + 0.035 * (1.0 - x_frac ** 1.8)
                row.append(bm.verts.new(g2b(wx, wy, wz)))
            ws_grid.append(row)
        f_ws = []
        for i in range(len(ws_z) - 1):
            for j in range(len(ws_grid[0]) - 1):
                v1, v2, v3, v4 = ws_grid[i][j], ws_grid[i+1][j], ws_grid[i+1][j+1], ws_grid[i][j+1]
                f_ws.append(bm.faces.new((v1, v2, v3, v4) if sign > 0 else (v1, v4, v3, v2)))
        assign_faces_mat_and_box_uv(bm, f_ws, idx_glass, uv_layer, UV_REGIONS["glass"], scale_uv=0.45)

        rw_levels = [
            (1.365, -1.910, 0.545),
            (1.225, -2.035, 0.585),
            (1.085, -2.155, 0.625),
            (0.945, -2.265, 0.660),
        ]
        rw_grid = []
        for ry, rz, r_max_x in rw_levels:
            row = []
            for x_frac in [1.0, 0.65, 0.32, 0.0]:
                rx = sign * r_max_x * x_frac
                cz = rz - 0.025 * (1.0 - x_frac ** 1.8)
                row.append(bm.verts.new(g2b(rx, ry, cz)))
            rw_grid.append(row)
        f_rw = []
        for i in range(len(rw_grid) - 1):
            for j in range(len(rw_grid[0]) - 1):
                v1, v2, v3, v4 = rw_grid[i][j], rw_grid[i+1][j], rw_grid[i+1][j+1], rw_grid[i][j+1]
                f_rw.append(bm.faces.new((v1, v2, v3, v4) if sign > 0 else (v1, v4, v3, v2)))
        assign_faces_mat_and_box_uv(bm, f_rw, idx_glass, uv_layer, UV_REGIONS["glass"], scale_uv=0.45)

    # -----------------------------------------------------------------------
    # B. ILLUMINATED ROOF 'TAXI' SIGN BOX ON TOP OF THE ROOF (PEAK Y = 1.560 m)
    # -----------------------------------------------------------------------
    add_box_part(bm, -0.220, +0.220, 1.442, 1.472, +0.050, +0.230, idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.008, segs=2)

    sx_b, sx_t = 0.202, 0.180
    sy_b, sy_t = 1.470, 1.560
    sz_r, sz_f = +0.068, +0.212
    sv_fl_b = bm.verts.new(g2b(+sx_b, sy_b, sz_f))
    sv_fr_b = bm.verts.new(g2b(-sx_b, sy_b, sz_f))
    sv_fr_t = bm.verts.new(g2b(-sx_t, sy_t, sz_f - 0.012))
    sv_fl_t = bm.verts.new(g2b(+sx_t, sy_t, sz_f - 0.012))

    sv_rl_b = bm.verts.new(g2b(+sx_b, sy_b, sz_r))
    sv_rr_b = bm.verts.new(g2b(-sx_b, sy_b, sz_r))
    sv_rr_t = bm.verts.new(g2b(-sx_t, sy_t, sz_r + 0.012))
    sv_rl_t = bm.verts.new(g2b(+sx_t, sy_t, sz_r + 0.012))

    f_sign_front = bm.faces.new((sv_fl_b, sv_fr_b, sv_fr_t, sv_fl_t))
    f_sign_rear  = bm.faces.new((sv_rr_b, sv_rl_b, sv_rl_t, sv_rr_t))
    f_sign_top   = bm.faces.new((sv_fl_t, sv_fr_t, sv_rr_t, sv_rl_t))
    f_sign_left  = bm.faces.new((sv_rl_b, sv_fl_b, sv_fl_t, sv_rl_t))
    f_sign_right = bm.faces.new((sv_fr_b, sv_rr_b, sv_rr_t, sv_fr_t))

    sign_edges = list({e for f in [f_sign_front, f_sign_rear, f_sign_top, f_sign_left, f_sign_right] for e in f.edges})
    sign_vset  = {sv_fl_b, sv_fr_b, sv_fr_t, sv_fl_t, sv_rl_b, sv_rr_b, sv_rr_t, sv_rl_t}
    b_sign = bmesh.ops.bevel(bm, geom=sign_edges, offset=0.008, segments=2, profile=0.5, affect='EDGES')
    sign_vset.update(b_sign['verts'])
    sign_all_faces = [f for f in bm.faces if all(v in sign_vset for v in f.verts)]
    assign_faces_mat_and_box_uv(bm, sign_all_faces, idx_lamps, uv_layer, (0.06, 0.94, 0.90, 0.97), scale_uv=1.0)
    front_main = [f for f in sign_all_faces if f.normal.y < -0.85]
    rear_main  = [f for f in sign_all_faces if f.normal.y >  0.85]
    if front_main:
        assign_faces_planar_uv(bm, front_main, idx_lamps, uv_layer, UV_REGIONS["lamps_taxi"], axis='Z', flip_u=True)
    if rear_main:
        assign_faces_planar_uv(bm, rear_main,  idx_lamps, uv_layer, UV_REGIONS["lamps_taxi"], axis='Z', flip_u=False)

    # -----------------------------------------------------------------------
    # C. FRONT GRILLE, LICENSE PLATES & REAR BUMPER TRIM
    # -----------------------------------------------------------------------
    # 1. Wide Executive Trapezoidal Radiator Grille (matching sheet.jpg!)
    add_box_part(bm, -0.455, +0.455, 0.430, 0.630, +2.360, +2.408, idx_trim, uv_layer, UV_REGIONS["trim_chrome"], bevel_r=0.015, segs=2)
    add_box_part(bm, -0.428, +0.428, 0.450, 0.612, +2.370, +2.412, idx_trim, uv_layer, UV_REGIONS["trim_dark"],   bevel_r=0.008, segs=2)

    add_box_part(bm, -0.460, +0.460, 0.205, 0.295, +2.350, +2.398, idx_trim, uv_layer, UV_REGIONS["trim_dark"],   bevel_r=0.008, segs=2)
    add_box_part(bm, -0.520, +0.520, 0.176, 0.198, +2.355, +2.404, idx_trim, uv_layer, UV_REGIONS["trim_chrome"], bevel_r=0.005, segs=2)

    # 2. Front & Rear EU License Plates
    f_pl_front = add_box_part(bm, -0.255, +0.255, 0.315, 0.415, +2.382, +2.412, idx_livery, uv_layer, UV_REGIONS["livery_plate"], bevel_r=0.004, segs=1)
    front_pl_main = [f for f in f_pl_front if f.normal.y < -0.8]
    if front_pl_main:
        assign_faces_planar_uv(bm, front_pl_main, idx_livery, uv_layer, UV_REGIONS["livery_plate"], axis='Z', flip_u=True)

    f_pl_rear = add_box_part(bm, -0.255, +0.255, 0.650, 0.755, -2.325, -2.285, idx_livery, uv_layer, UV_REGIONS["livery_plate"], bevel_r=0.004, segs=1)
    rear_pl_main = [f for f in f_pl_rear if f.normal.y > 0.8]
    if rear_pl_main:
        assign_faces_planar_uv(bm, rear_pl_main, idx_livery, uv_layer, UV_REGIONS["livery_plate"], axis='Z', flip_u=False)

    # 3. Lower rear bumper red reflector strip & twin chrome exhaust finishers
    for sign in (+1.0, -1.0):
        add_box_part(bm, min(sign * 0.46, sign * 0.60), max(sign * 0.46, sign * 0.60), 0.320, 0.345, -2.395, -2.355, idx_lamps, uv_layer, UV_REGIONS["lamps_tail"], bevel_r=0.004, segs=1)
        ex_x0 = min(sign * 0.420, sign * 0.580)
        ex_x1 = max(sign * 0.420, sign * 0.580)
        add_box_part(bm, ex_x0, ex_x1, 0.225, 0.280, -2.395, -2.320, idx_trim, uv_layer, UV_REGIONS["trim_chrome"], bevel_r=0.012, segs=2)
        add_box_part(bm, ex_x0 + 0.015, ex_x1 - 0.015, 0.235, 0.270, -2.398, -2.340, idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.006, segs=1)

    add_box_part(bm, -0.340, +0.340, 0.775, 0.800, -2.290, -2.250, idx_trim,  uv_layer, UV_REGIONS["trim_chrome"], bevel_r=0.004, segs=1)
    add_box_part(bm, -0.500, +0.500, 0.215, 0.295, -2.398, -2.330, idx_trim,  uv_layer, UV_REGIONS["trim_dark"],   bevel_r=0.010, segs=2)
    add_box_part(bm, -0.460, +0.460, 0.278, 0.298, -2.402, -2.355, idx_trim,  uv_layer, UV_REGIONS["trim_chrome"], bevel_r=0.004, segs=1)
    add_box_part(bm, -0.240, +0.240, 1.365, 1.385, -1.915, -1.860, idx_lamps, uv_layer, UV_REGIONS["lamps_tail"],  bevel_r=0.003, segs=1)

    # Windshield wipers & cowl panel
    add_box_part(bm, -0.680, +0.680, 0.945, 0.972, +0.830, +0.930, idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.005, segs=1)
    add_box_part(bm, -0.520, +0.080, 0.965, 0.980, +0.855, +0.878, idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.003, segs=1)
    add_box_part(bm, -0.050, +0.550, 0.965, 0.980, +0.865, +0.888, idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.003, segs=1)

    # -----------------------------------------------------------------------
    # D. WHEELWELL ARCH LINERS & FULL UNDERBODY CHASSIS PAN
    # -----------------------------------------------------------------------
    add_box_part(bm, -0.560, +0.560, 0.185, 0.235, -2.100, +2.100, idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.010, segs=1)
    for sign in (+1.0, -1.0):
        for z_ax in (+1.400, -1.400):
            x_in  = sign * 0.610
            x_out = sign * 0.875
            n_arch = 18
            r_lin  = 0.395
            arch_v_in, arch_v_out = [], []
            for k in range(n_arch + 1):
                ang = math.pi * k / n_arch
                az = z_ax + r_lin * math.cos(ang)
                ay = 0.220 + (r_lin + 0.120) * math.sin(ang)
                arch_v_in.append(bm.verts.new(g2b(x_in,  ay, az)))
                arch_v_out.append(bm.verts.new(g2b(x_out, ay, az)))
            arch_faces = []
            for k in range(n_arch):
                f_a = bm.faces.new((arch_v_in[k], arch_v_out[k], arch_v_out[k+1], arch_v_in[k+1]) if sign > 0 else (arch_v_in[k], arch_v_in[k+1], arch_v_out[k+1], arch_v_out[k]))
                arch_faces.append(f_a)
            f_back = bm.faces.new(arch_v_in if sign < 0 else list(reversed(arch_v_in)))
            arch_faces.append(f_back)
            assign_faces_mat_and_box_uv(bm, arch_faces, idx_trim, uv_layer, UV_REGIONS["trim_dark"], scale_uv=0.8)

    # Tapered Cabin B-Pillars & C-Pillars that follow `get_side_profile_x(y, z)` (tumblehome) so they never poke outside the glass!
    for sign in (+1.0, -1.0):
        for z_pil in (-0.080, -0.960):
            pil_levels = [0.240, 0.600, 0.950, 1.180, 1.380]
            p_f_in, p_f_out, p_r_out, p_r_in = [], [], [], []
            for py in pil_levels:
                px_out = sign * (get_side_profile_x(py, z_pil) - 0.012)
                px_in  = sign * (abs(px_out) - 0.055)
                p_f_in.append(bm.verts.new(g2b(px_in,   py, z_pil + 0.038)))
                p_f_out.append(bm.verts.new(g2b(px_out, py, z_pil + 0.038)))
                p_r_out.append(bm.verts.new(g2b(px_out, py, z_pil - 0.038)))
                p_r_in.append(bm.verts.new(g2b(px_in,   py, z_pil - 0.038)))
            pil_faces = []
            for k in range(len(pil_levels) - 1):
                f1 = bm.faces.new((p_f_out[k], p_r_out[k], p_r_out[k+1], p_f_out[k+1]) if sign > 0 else (p_f_out[k], p_f_out[k+1], p_r_out[k+1], p_r_out[k]))
                f2 = bm.faces.new((p_r_in[k],  p_f_in[k],  p_f_in[k+1],  p_r_in[k+1])  if sign > 0 else (p_r_in[k],  p_r_in[k+1],  p_f_in[k+1],  p_f_in[k]))
                f3 = bm.faces.new((p_f_in[k],  p_f_out[k], p_f_out[k+1], p_f_in[k+1])  if sign > 0 else (p_f_in[k],  p_f_in[k+1],  p_f_out[k+1], p_f_out[k]))
                f4 = bm.faces.new((p_r_out[k], p_r_in[k],  p_r_in[k+1],  p_r_out[k+1]) if sign > 0 else (p_r_out[k], p_r_out[k+1], p_r_in[k+1],  p_r_in[k]))
                pil_faces.extend([f1, f2, f3, f4])
            assign_faces_mat_and_box_uv(bm, pil_faces, idx_trim, uv_layer, UV_REGIONS["trim_dark"], scale_uv=1.0)

    # -----------------------------------------------------------------------
    # E. FULL VISIBLE INTERIOR (COGNAC LEATHER SEATS, DASHBOARD, CONSOLE, TAXIMETER)
    # -----------------------------------------------------------------------
    add_box_part(bm, -0.750, +0.750, 0.620, 0.935, +0.520, +0.870, idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.025, segs=2)
    add_box_part(bm, -0.730, +0.730, 0.740, 0.805, +0.495, +0.540, idx_trim, uv_layer, UV_REGIONS["trim_leather"], bevel_r=0.008, segs=2)
    add_box_part(bm, +0.160, +0.580, 0.900, 0.990, +0.530, +0.720, idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.015, segs=2)
    add_box_part(bm, -0.200, +0.520, 0.835, 0.960, +0.505, +0.540, idx_trim, uv_layer, UV_REGIONS["trim_chrome"], bevel_r=0.005, segs=1)

    # Center Console & Taximeter Recessed into Console Center Stack (y=0.78..0.85 so it sits cleanly inside the cabin!)
    add_box_part(bm, -0.155, +0.155, 0.320, 0.680, -0.420, +0.550, idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.018, segs=2)
    add_box_part(bm, -0.135, +0.135, 0.650, 0.720, -0.380, +0.080, idx_trim, uv_layer, UV_REGIONS["trim_leather"], bevel_r=0.015, segs=2)
    f_meter = add_box_part(bm, -0.085, +0.085, 0.775, 0.845, +0.465, +0.525, idx_livery, uv_layer, UV_REGIONS["livery_meter"], bevel_r=0.005, segs=2)
    meter_rear = [f for f in f_meter if f.normal.y > 0.7]
    if meter_rear:
        assign_faces_planar_uv(bm, meter_rear, idx_livery, uv_layer, UV_REGIONS["livery_meter"], axis='Z', flip_u=False)

    add_box_part(bm, -0.115, +0.115, 1.285, 1.340, +0.260, +0.310, idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.006, segs=2)

    # Front Executive Bucket Seats (Cognac Leather)
    for sx in (+0.380, -0.380):
        add_box_part(bm, sx - 0.245, sx + 0.245, 0.380, 0.535, -0.320, +0.180, idx_trim, uv_layer, UV_REGIONS["trim_leather"], bevel_r=0.025, segs=2)
        add_box_part(bm, sx - 0.265, sx - 0.185, 0.500, 0.575, -0.280, +0.150, idx_trim, uv_layer, UV_REGIONS["trim_leather"], bevel_r=0.015, segs=2)
        add_box_part(bm, sx + 0.185, sx + 0.265, 0.500, 0.575, -0.280, +0.150, idx_trim, uv_layer, UV_REGIONS["trim_leather"], bevel_r=0.015, segs=2)
        add_box_part(bm, sx - 0.235, sx + 0.235, 0.520, 1.055, -0.410, -0.240, idx_trim, uv_layer, UV_REGIONS["trim_leather"], bevel_r=0.028, segs=2)
        add_box_part(bm, sx - 0.260, sx - 0.175, 0.580, 0.980, -0.360, -0.200, idx_trim, uv_layer, UV_REGIONS["trim_leather"], bevel_r=0.018, segs=2)
        add_box_part(bm, sx + 0.175, sx + 0.260, 0.580, 0.980, -0.360, -0.200, idx_trim, uv_layer, UV_REGIONS["trim_leather"], bevel_r=0.018, segs=2)
        add_box_part(bm, sx - 0.135, sx + 0.135, 1.075, 1.245, -0.405, -0.275, idx_trim, uv_layer, UV_REGIONS["trim_leather"], bevel_r=0.024, segs=2)
        add_box_part(bm, sx - 0.075, sx + 0.075, 1.035, 1.090, -0.360, -0.320, idx_trim, uv_layer, UV_REGIONS["trim_chrome"],  bevel_r=0.003, segs=1)

    # Rear Executive Passenger Bench & 3 Headrests + Estate Luggage Cover
    add_box_part(bm, -0.680, +0.680, 0.400, 0.555, -1.220, -0.720, idx_trim, uv_layer, UV_REGIONS["trim_leather"], bevel_r=0.025, segs=2)
    add_box_part(bm, -0.680, +0.680, 0.540, 1.045, -1.360, -1.140, idx_trim, uv_layer, UV_REGIONS["trim_leather"], bevel_r=0.028, segs=2)
    for rx in (+0.420, 0.0, -0.420):
        add_box_part(bm, rx - 0.125, rx + 0.125, 1.060, 1.215, -1.355, -1.215, idx_trim, uv_layer, UV_REGIONS["trim_leather"], bevel_r=0.022, segs=2)
    add_box_part(bm, -0.640, +0.640, 0.840, 0.910, -2.100, -1.340, idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.012, segs=1)

    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    bm.to_mesh(mesh)
    bm.free()
    return obj

BODY_OBJ = build_body()
print("Built Body mesh.")

# ---------------------------------------------------------------------------
# 11. TRIANGLE BUDGET TUNING (TARGET: 50,000 TO 95,000 TRIANGLES)
# ---------------------------------------------------------------------------
ALL_MESH_OBJS = [BODY_OBJ, STEERING_OBJ] + WHEEL_OBJS + DOOR_OBJS

def count_total_tris():
    depsgraph = bpy.context.evaluated_depsgraph_get()
    total = 0
    for o in ALL_MESH_OBJS:
        eval_obj = o.evaluated_get(depsgraph)
        m = eval_obj.to_mesh()
        m.calc_loop_triangles()
        total += len(m.loop_triangles)
        eval_obj.to_mesh_clear()
    return total

def refine_for_budget():
    tris_base = count_total_tris()
    print(f"Base triangle count before subsurf: {tris_base}")

    # Subdivide Wheels with Catmull-Clark level 1 so tyres and twin alloy spokes are ultra-round
    for o in WHEEL_OBJS:
        mod = o.modifiers.new(name="Subsurf", type='SUBSURF')
        mod.subdivision_type = 'CATMULL_CLARK'
        mod.levels = 1
        mod.render_levels = 1
        bpy.context.view_layer.objects.active = o
        bpy.ops.object.modifier_apply(modifier=mod.name)

    tris_after_wheels = count_total_tris()
    print(f"Triangle count after Wheel subsurf: {tris_after_wheels}")

    # Subdivide Body with SIMPLE level 1 to land right around ~78k triangles (inside 50k..95k!)
    if tris_after_wheels < 62000:
        mod_b = BODY_OBJ.modifiers.new(name="Subsurf", type='SUBSURF')
        mod_b.subdivision_type = 'SIMPLE'
        mod_b.levels = 1
        mod_b.render_levels = 1
        bpy.context.view_layer.objects.active = BODY_OBJ
        bpy.ops.object.modifier_apply(modifier=mod_b.name)

    tris_final = count_total_tris()
    print(f"Final tuned triangle count (target 50k..95k): {tris_final}")

refine_for_budget()

# Ensure exact bounding box [4.82m L, 1.85m W, 1.56m H, tyres touch y=0.0]
def calibrate_exact_bounds():
    min_x, max_x = 1e9, -1e9
    min_y, max_y = 1e9, -1e9
    min_z, max_z = 1e9, -1e9
    for o in ALL_MESH_OBJS:
        mat_w = o.matrix_world
        for v in o.data.vertices:
            vw = mat_w @ v.co
            gx, gy, gz = vw.x, vw.z, -vw.y
            min_x = min(min_x, gx); max_x = max(max_x, gx)
            min_y = min(min_y, gy); max_y = max(max_y, gy)
            min_z = min(min_z, gz); max_z = max(max_z, gz)

    print(f"Pre-calibration glTF bounds: X=[{min_x:.4f}, {max_x:.4f}], Y=[{min_y:.4f}, {max_y:.4f}], Z=[{min_z:.4f}, {max_z:.4f}]")
    sx = 1.850 / (max_x - min_x)
    sy = 1.560 / (max_y - min_y)
    sz = 4.820 / (max_z - min_z)
    cx = 0.5 * (min_x + max_x)
    cz = 0.5 * (min_z + max_z)

    for o in ALL_MESH_OBJS:
        ox, oy, oz = o.location.x, o.location.z, -o.location.y
        nox = (ox - cx) * sx
        noy = (oy - min_y) * sy
        noz = (oz - cz) * sz
        o.location = g2b(nox, noy, noz)
        for v in o.data.vertices:
            v.co.x *= sx
            v.co.z *= sy
            v.co.y *= sz
        o.data.update()

calibrate_exact_bounds()

# ---------------------------------------------------------------------------
# 12. CREATE EMPTY NODES (Seat_Driver, Door_L, Door_R, Exhaust, Light_Head_L/R, Light_Tail_L/R)
# ---------------------------------------------------------------------------
EMPTY_DEFS = {
    "Seat_Driver":  (+0.380, 0.540, -0.120),
    "Door_L":       (+0.925, 0.000, +0.350),
    "Door_R":       (-0.925, 0.000, +0.350),
    "Exhaust":      (+0.530, 0.255, -2.390),
    "Light_Head_L": (+0.630, 0.605, +2.280),
    "Light_Head_R": (-0.630, 0.605, +2.280),
    "Light_Tail_L": (+0.560, 0.835, -2.240),
    "Light_Tail_R": (-0.560, 0.835, -2.240),
}

EMPTY_OBJS = []
for ename, (ex, ey, ez) in EMPTY_DEFS.items():
    eobj = bpy.data.objects.new(ename, None)
    eobj.empty_display_type = 'PLAIN_AXES'
    eobj.empty_display_size = 0.15
    eobj.location = g2b(ex, ey, ez)
    bpy.context.collection.objects.link(eobj)
    EMPTY_OBJS.append(eobj)

# ---------------------------------------------------------------------------
# 13. EXPORT GLB (city-taxi.glb <= 1.5 MB) & NODES.JSON
# ---------------------------------------------------------------------------
GLB_PATH = os.path.join(WORK_DIR, "city-taxi.glb")

# Preload libdraco.so.9 from Blender's bundled lib directory so Draco compression works in headless mode
import ctypes
for _draco_cand in [
    "/root/blender-spike/blender-5.2.0-linux-x64/lib/libdraco.so.9",
    os.path.join(os.path.dirname(bpy.app.binary_path), "lib", "libdraco.so.9"),
]:
    if os.path.exists(_draco_cand):
        ctypes.CDLL(_draco_cand, mode=ctypes.RTLD_GLOBAL)
        break

# Export with Draco mesh compression enabled so the 64k-tri model + baked textures packs well under 1.5 MB!
bpy.ops.export_scene.gltf(
    filepath=GLB_PATH,
    export_format='GLB',
    export_yup=True,
    export_apply=True,
    export_texcoords=True,
    export_normals=True,
    export_materials='EXPORT',
    export_cameras=False,
    export_lights=False,
    export_image_format='JPEG',
    export_jpeg_quality=80,
    export_draco_mesh_compression_enable=True,
    export_draco_mesh_compression_level=6,
    export_draco_position_quantization=14,
    export_draco_normal_quantization=10,
    export_draco_texcoord_quantization=12,
)
glb_size_mb = os.path.getsize(GLB_PATH) / (1024.0 * 1024.0)
print(f"Exported {GLB_PATH} ({glb_size_mb:.3f} MB)")

PAINTS_LIST = [
    [0.04, 0.04, 0.05],  # Default: Deep Anthracite Black (contract required first)
    [0.05, 0.09, 0.18],  # Riviera Executive Navy Blue (matching sheet.jpg hero finish)
    [0.42, 0.45, 0.49],  # Côte d'Azur Iridium Silver Metallic
    [0.76, 0.77, 0.75],  # Cannes Diamond Pearl White
    [0.24, 0.03, 0.04],  # Monte Carlo Rubellite Burgundy
    [0.03, 0.14, 0.09],  # Nice Emerald Green Metallic
]

def write_nodes_json():
    min_x, max_x = 1e9, -1e9
    min_y, max_y = 1e9, -1e9
    min_z, max_z = 1e9, -1e9
    for o in ALL_MESH_OBJS:
        mat_w = o.matrix_world
        for v in o.data.vertices:
            vw = mat_w @ v.co
            gx, gy, gz = vw.x, vw.z, -vw.y
            min_x = min(min_x, gx); max_x = max(max_x, gx)
            min_y = min(min_y, gy); max_y = max(max_y, gy)
            min_z = min(min_z, gz); max_z = max(max_z, gz)

    nodes_dict = {}
    for o in ALL_MESH_OBJS + EMPTY_OBJS:
        gx, gy, gz = b2g_vec(o.location)
        nodes_dict[o.name] = {
            "position": [gx, gy, gz],
            "type": o.type,
        }

    data = {
        "asset": "city-taxi",
        "contract": "Forge Garage v2",
        "coordinate_system": {"up": "+Y", "front": "+Z", "left": "+X"},
        "bounding_box": {
            "min": [round(min_x, 4), round(min_y, 4), round(min_z, 4)],
            "max": [round(max_x, 4), round(max_y, 4), round(max_z, 4)],
            "size": [round(max_x - min_x, 4), round(max_y - min_y, 4), round(max_z - min_z, 4)],
        },
        "triangles": count_total_tris(),
        "materials": [m.name for m in MATS.values()],
        "nodes": nodes_dict,
        "paints": PAINTS_LIST,
    }
    with open(os.path.join(WORK_DIR, "nodes.json"), "w") as f:
        json.dump(data, f, indent=2)
    print("Wrote nodes.json")

write_nodes_json()

# ---------------------------------------------------------------------------
# 14. RENDER FINAL.PNG, SIDE.PNG, CLAY.PNG & PAINTS.PNG
# ---------------------------------------------------------------------------
def setup_studio_environment():
    bpy.ops.mesh.primitive_plane_add(size=50.0, location=(0.0, 0.0, 0.0))
    ground = bpy.context.active_object
    ground.name = "_StudioGround"
    mat_g = bpy.data.materials.new(name="_StudioGroundMat")
    mat_g.use_nodes = True
    bsdf_g = mat_g.node_tree.nodes.get("Principled BSDF")
    bsdf_g.inputs['Base Color'].default_value = (0.64, 0.64, 0.65, 1.0)
    bsdf_g.inputs['Roughness'].default_value = 0.55
    ground.data.materials.append(mat_g)

    world = bpy.data.worlds.new("StudioWorld")
    scene.world = world
    world.use_nodes = True
    bg = world.node_tree.nodes.get("Background")
    bg.inputs['Color'].default_value = (0.70, 0.72, 0.75, 1.0)
    bg.inputs['Strength'].default_value = 0.95

    lights = []
    for lname, loc_g, energy, size_x, size_y, col in [
        ("_KeyLight",  (+4.2, 4.4, +4.4), 460.0, 4.5, 2.5, (1.00, 0.98, 0.95)),
        ("_FillLight", (-4.4, 3.0, +2.8), 240.0, 4.0, 2.5, (0.92, 0.95, 1.00)),
        ("_RimLight",  (-1.8, 4.5, -4.2), 340.0, 5.0, 2.0, (1.00, 0.99, 0.96)),
    ]:
        ldata = bpy.data.lights.new(name=lname, type='AREA')
        ldata.energy = energy
        ldata.shape = 'RECTANGLE'
        ldata.size = size_x
        ldata.size_y = size_y
        ldata.color = col
        lobj = bpy.data.objects.new(name=lname, object_data=ldata)
        bpy.context.collection.objects.link(lobj)
        lobj.location = g2b(*loc_g)
        direction = g2b(0.0, 0.65, 0.0) - lobj.location
        lobj.rotation_euler = direction.to_track_quat('-Z', 'Y').to_euler()
        lights.append(lobj)

    return ground, lights

def aim_camera(cam_obj, loc_gltf, target_gltf):
    cam_obj.location = g2b(*loc_gltf)
    direction = g2b(*target_gltf) - cam_obj.location
    cam_obj.rotation_euler = direction.to_track_quat('-Z', 'Y').to_euler()

def set_paint_factor(rgb):
    mat_paint = MATS["Paint"]
    for n in mat_paint.node_tree.nodes:
        if n.type == 'MIX' and n.data_type == 'RGBA':
            n.inputs['B'].default_value = (rgb[0], rgb[1], rgb[2], 1.0)

def render_all_views():
    ground, lights = setup_studio_environment()

    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

    # Use default Deep Anthracite Black ([0.04, 0.04, 0.05]) for final.png and side.png
    set_paint_factor(PAINTS_LIST[0])

    # Camera framed at (+4.35, 1.72, +4.95) with 45mm lens so the ENTIRE car (front bumper to rear estate tail)
    # fits comfortably inside the 1920x1080 frame with generous studio margin!
    hero_cam_loc = (+4.35, 1.72, +4.95)
    hero_target  = (-0.05, 0.65, +0.05)

    # 1. final.png (1920x1080 Cycles render, 3/4 front view, soft daylight)
    scene.render.image_settings.file_format = 'PNG'
    scene.render.resolution_x = 1920
    scene.render.resolution_y = 1080
    scene.render.resolution_percentage = 100
    scene.cycles.samples = 64
    cam_data.type = 'PERSP'
    cam_data.lens = 45.0
    aim_camera(cam_obj, hero_cam_loc, hero_target)
    scene.render.filepath = os.path.join(WORK_DIR, "final.png")
    bpy.ops.render.render(write_still=True)
    print("Rendered final.png")

    # 2. clay.png (same view as final, plain grey material)
    mat_clay = bpy.data.materials.new(name="_ClayMat")
    mat_clay.use_nodes = True
    bsdf_c = mat_clay.node_tree.nodes.get("Principled BSDF")
    bsdf_c.inputs['Base Color'].default_value = (0.48, 0.48, 0.49, 1.0)
    bsdf_c.inputs['Roughness'].default_value = 0.42
    scene.view_layers[0].material_override = mat_clay
    scene.render.filepath = os.path.join(WORK_DIR, "clay.png")
    bpy.ops.render.render(write_still=True)
    scene.view_layers[0].material_override = None
    print("Rendered clay.png")

    # 3. side.png (orthographic side view)
    cam_data.type = 'ORTHO'
    cam_data.ortho_scale = 5.65
    aim_camera(cam_obj, (+6.00, 0.78, 0.00), (0.00, 0.78, 0.00))
    scene.render.filepath = os.path.join(WORK_DIR, "side.png")
    bpy.ops.render.render(write_still=True)
    print("Rendered side.png")

    # 4. paints.png (3x2 grid of the same 3/4 view in the six paints from nodes.json)
    cam_data.type = 'PERSP'
    cam_data.lens = 45.0
    aim_camera(cam_obj, hero_cam_loc, hero_target)
    scene.render.resolution_x = 640
    scene.render.resolution_y = 360
    scene.cycles.samples = 28

    tile_imgs = []
    for idx, rgb in enumerate(PAINTS_LIST):
        set_paint_factor(rgb)
        tpath = os.path.join(BAKE_DIR, f"_paint_tile_{idx}.png")
        scene.render.filepath = tpath
        bpy.ops.render.render(write_still=True)
        tile_imgs.append(Image.open(tpath).convert("RGB"))

    grid_img = Image.new("RGB", (640 * 3, 360 * 2))
    for idx, timg in enumerate(tile_imgs):
        col = idx % 3
        row = idx // 3
        grid_img.paste(timg, (col * 640, row * 360))
    grid_img.save(os.path.join(WORK_DIR, "paints.png"))
    print("Rendered paints.png (1920x720 3x2 grid)")

    set_paint_factor(PAINTS_LIST[0])

render_all_views()
print("ALL TASKS COMPLETED SUCCESSFULLY.")
