#!/usr/bin/env python3
"""
Build script for Modern European Four-Door Police Patrol Saloon / Sedan
(French Riviera Police Municipale / Nationale feel, matching sheet.jpg).
Forge Garage Contract v2 compliant:
- Real dimensions: 4.85 m L (+Z front), 1.86 m W (+X left), 1.58 m H (+Y up), 2.82 m wheelbase.
- Origin (0,0,0) on ground plane midway between front (+1.41 m) and rear (-1.41 m) axles.
- Separate mesh nodes:
    Body, Lightbar, 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 (5):
    Paint (baseColorFactor=[0.86, 0.88, 0.92], neutral white/grey detail texture, clearcoat 1.0),
    Trim (dark gunmetal alloy rims, tire rubber, matte black grille & trim, charcoal leather interior, chrome),
    Livery (blue & red reflective side/bonnet stripes, bold generic 'POLICE' lettering on doors/bonnet/trunk),
    Glass (alpha BLEND, tinted, low roughness),
    Lamps (emissive warm-white headlights, ruby-red wrap-around tail light bar, red/blue roof lightbar halves).
- Poly Haven CC0 textures: metal_plate, rubber_tiles, fabric_leather_02, asphalt_02.
"""

import os
import sys
import json
import math
import struct
import urllib.request
import ctypes
import numpy as np
from PIL import Image, ImageDraw, ImageFont, 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
    if hasattr(scene.view_settings, "view_transform"):
        try:
            scene.view_settings.view_transform = 'AgX'
        except Exception:
            pass

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=85):
    arr_u8 = np.clip(arr * 255.0, 0, 255).astype(np.uint8)
    Image.fromarray(arr_u8, "RGB").save(fpath, quality=quality, optimize=True)

def load_bold_font(pt_size):
    for fpath in [
        "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
        "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
    ]:
        if os.path.exists(fpath):
            return ImageFont.truetype(fpath, pt_size)
    return ImageFont.load_default()

def draw_centered_text(draw, box, text, font, fill):
    x0, y0, x1, y1 = box
    bbox = draw.textbbox((0, 0), text, font=font)
    tw = bbox[2] - bbox[0]
    th = bbox[3] - bbox[1]
    tx = x0 + (x1 - x0 - tw) * 0.5 - bbox[0]
    ty = y0 + (y1 - y0 - th) * 0.5 - bbox[1]
    draw.text((tx, ty), text, font=font, fill=fill)

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 near-white 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.6 * 0.10, 0.0, 0.10)
    paint_val = 0.965 - grime_grad * (0.55 + 0.65 * (1.0 - as_lum)) - 0.032 * (1.0 - mp_lum)
    paint_base = np.repeat(np.clip(paint_val, 0.84, 0.99), 3, axis=2)
    save_np_jpg(paint_base, os.path.join(TEX_DIR, "src_paint_base.jpg"), quality=86)

    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.93 + mp_nor * 0.04 + as_nor * 0.03
    save_np_jpg(paint_nor, os.path.join(TEX_DIR, "src_paint_nor.jpg"), quality=85)

    paint_ao = 0.94 + 0.06 * mp_arm[:, :, 0] - grime_grad[:, :, 0] * 0.14
    paint_rough = 0.11 + 0.05 * mp_arm[:, :, 1] + grime_grad[:, :, 0] * 0.35
    paint_metal = np.full((1024, 1024), 0.18, dtype=np.float32) - grime_grad[:, :, 0] * 0.10
    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), dtype=np.float32) / 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

    mp_d5 = r512(mp_diff)
    mp_n5 = r512(mp_nor)
    mp_a5 = r512(mp_arm)
    rt_d5 = r512(rt_diff)
    rt_n5 = r512(rt_nor)
    rt_a5 = r512(rt_arm)
    fl_d5 = r512(fl_diff)
    fl_n5 = r512(fl_nor)
    fl_a5 = r512(fl_arm)
    as_d5 = r512(as_diff)
    as_n5 = r512(as_nor)

    # Q0 (Top-Left, y=0..512, x=0..512 -> UV v=0.5..1.0, u=0.0..0.5):
    # Dark Gunmetal / Anthracite Machined Alloy Wheel Rim (matching sheet.jpg swatch "Dark Alloy Wheel")
    alloy_col = 0.23 + 0.16 * mp_d5
    trim_base[0:512, 0:512] = alloy_col
    trim_nor[0:512, 0:512]  = flat_nor[0:512, 0:512] * 0.78 + mp_n5 * 0.22
    trim_orm[0:512, 0:512, 0] = 0.90 + 0.10 * mp_a5[:, :, 0]
    trim_orm[0:512, 0:512, 1] = 0.22 + 0.08 * mp_a5[:, :, 1]
    trim_orm[0:512, 0:512, 2] = 0.88

    # Q1 (Top-Right, y=0..512, x=512..1024 -> UV v=0.5..1.0, u=0.5..1.0):
    # Tyre Rubber & Sidewall Tread (from rubber_tiles + asphalt_02)
    ty, tx = np.mgrid[0:512, 0:512] / 512.0
    tread_ribs = 0.5 + 0.5 * np.cos(tx * 2.0 * math.pi * 14.0)
    tread_sipes = 0.5 + 0.5 * np.sin((ty * 36.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.038 + 0.040 * rt_d5 + 0.022 * 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.65 + as_n5 * 0.35
    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.72 + 0.14 * rt_a5[:, :, 1]
    trim_orm[0:512, 512:1024, 2] = 0.02

    # Q2 (Bottom-Left, y=512..1024, x=0..512 -> UV v=0.0..0.5, u=0.0..0.5):
    # Police Tactical Charcoal Leather & Cabin Upholstery (from fabric_leather_02)
    fl_lum = np.mean(fl_d5, axis=2, keepdims=True)
    charcoal_leather = np.zeros((512, 512, 3), dtype=np.float32)
    charcoal_leather[:, :, 0] = 0.075 * (0.70 + 0.50 * fl_lum[:, :, 0])
    charcoal_leather[:, :, 1] = 0.080 * (0.70 + 0.50 * fl_lum[:, :, 0])
    charcoal_leather[:, :, 2] = 0.092 * (0.70 + 0.50 * fl_lum[:, :, 0])
    trim_base[512:1024, 0:512] = np.clip(charcoal_leather, 0.03, 0.22)
    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.52 + 0.16 * fl_a5[:, :, 1]
    trim_orm[512:1024, 0:512, 2] = 0.02

    # Q3 (Bottom-Right, y=512..1024, x=512..1024 -> UV v=0.0..0.5, u=0.5..1.0):
    # Split into two halves:
    #   x=512..768 (u=0.50..0.75): Matte Black Plastic Trim & Grille Honeycomb (matching sheet.jpg "Black Plastic Trim")
    #   x=768..1024 (u=0.75..1.00): Bright Chrome / Polished Steel (exhaust tips, lightbar center bracket, brake discs)
    gy, gx = np.mgrid[0:512, 0:256] / 256.0
    d1 = np.abs(np.sin((gx * 24.0 + gy * 16.0) * math.pi))
    d2 = np.abs(np.sin((gx * 24.0 - gy * 16.0) * math.pi))
    hex_bars = np.clip(1.0 - np.minimum(d1, d2) * 3.2, 0.0, 1.0)[:, :, None]
    black_plastic = 0.024 + 0.028 * mp_d5[:, 0:256] + 0.025 * hex_bars
    trim_base[512:1024, 512:768] = black_plastic
    q3_nor = mp_n5[:, 0:256].copy()
    q3_nor[:, :, 0] = np.clip(q3_nor[:, :, 0] + (hex_bars[:, :, 0] - 0.5) * 0.18, 0.0, 1.0)
    q3_nor[:, :, 1] = np.clip(q3_nor[:, :, 1] + (hex_bars[:, :, 0] - 0.5) * 0.18, 0.0, 1.0)
    trim_nor[512:1024, 512:768] = q3_nor
    trim_orm[512:1024, 512:768, 0] = 0.60 + 0.38 * hex_bars[:, :, 0]
    trim_orm[512:1024, 512:768, 1] = 0.42 + 0.20 * (1.0 - hex_bars[:, :, 0])
    trim_orm[512:1024, 512:768, 2] = 0.12 * hex_bars[:, :, 0]

    # Bright Chrome sub-quadrant (x=768..1024, y=512..1024)
    chrome_col = 0.76 + 0.18 * mp_d5[:, 256:512]
    trim_base[512:1024, 768:1024] = chrome_col
    trim_nor[512:1024, 768:1024]  = flat_nor[512:1024, 768:1024] * 0.85 + mp_n5[:, 256:512] * 0.15
    trim_orm[512:1024, 768:1024, 0] = 0.92 + 0.08 * mp_a5[:, 256:512, 0]
    trim_orm[512:1024, 768:1024, 1] = 0.14 + 0.08 * mp_a5[:, 256:512, 1]
    trim_orm[512:1024, 768:1024, 2] = 0.94

    save_np_jpg(trim_base, os.path.join(TEX_DIR, "src_trim_base.jpg"), quality=86)
    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), dtype=np.float32) / 255.0
    save_np_jpg(trim_orm_512, os.path.join(TEX_DIR, "src_trim_orm.jpg"), quality=84)

    # --- 3. Livery Atlas (1024x1024): French Riviera Police Stripes & 'POLICE' Lettering ---
    # Note: In PIL, y=0 is TOP (UV v=1.0) and y=1024 is BOTTOM (UV v=0.0).
    liv_img = Image.new("RGB", (1024, 1024), (242, 245, 250))
    draw = ImageDraw.Draw(liv_img)

    blue_col = (14, 62, 168)    # French Police Reflective Cobalt Blue (matching sheet.jpg swatch)
    red_col  = (212, 24, 28)    # French Police Reflective Crimson Red (matching sheet.jpg swatch)
    white_bg = (242, 245, 250)  # Clean white reflective background

    # Band 1: y = 0..256 (UV v = 0.75..1.00) -> Side Doors & Fenders Livery Band
    # Mapped purely across the two doors + front fender leading edge (z in -0.960..+0.960):
    #   - Left Solid Blue Block (front fender + front half of Door_FL): x = 0..235
    #   - White gap with bold blue "POLICE" lettering centered across Door_FL rear & Door_RL: x = 235..795
    #   - Right Solid Blue Block (trailing edge of Door_RL): x = 795..1024
    #   - White separation gap: y = 166..182
    #   - Thin Blue Pin-Stripe: y = 182..196
    #   - White separation gap: y = 196..208
    #   - Red Reflective Lower Stripe: y = 208..238
    draw.rectangle([0, 0, 1024, 256], fill=white_bg)
    draw.rectangle([0, 24, 232, 166], fill=blue_col)
    draw.rectangle([792, 24, 1024, 166], fill=blue_col)
    font_side = load_bold_font(112)
    draw_centered_text(draw, (240, 22, 784, 166), "POLICE", font_side, blue_col)
    draw.rectangle([0, 182, 1024, 196], fill=blue_col)
    draw.rectangle([0, 208, 1024, 238], fill=red_col)

    # Band 2: y = 256..512 (UV v = 0.50..0.75) -> Bonnet / Hood Livery Graphic
    # Matching sheet.jpg:
    #   - Left & Right flanking diagonal/longitudinal Blue-White-Red reflective stripes along hood edges
    #   - Bold Blue "POLICE" lettering across the lower-center of the hood
    draw.rectangle([0, 256, 1024, 512], fill=white_bg)
    # Left hood stripe (outer blue, thin white gap, inner red):
    draw.polygon([(24, 258), (94, 258), (144, 510), (58, 510)], fill=blue_col)
    draw.polygon([(106, 258), (156, 258), (218, 510), (158, 510)], fill=red_col)
    # Right hood stripe (inner red, thin white gap, outer blue):
    draw.polygon([(1024 - 156, 258), (1024 - 106, 258), (1024 - 158, 510), (1024 - 218, 510)], fill=red_col)
    draw.polygon([(1024 - 94, 258), (1024 - 24, 258), (1024 - 58, 510), (1024 - 144, 510)], fill=blue_col)
    font_hood = load_bold_font(96)
    draw_centered_text(draw, (220, 350, 804, 475), "POLICE", font_hood, blue_col)

    # Band 3: y = 512..768 (UV v = 0.25..0.50) -> Rear Trunk "POLICE" Panel + Solid Reflective Swatches
    # x = 0..640: Rear Trunk Deck "POLICE" lettering on white background (matching sheet.jpg rear view!)
    # x = 640..832: Solid Reflective Blue swatch (for pure blue decal strips)
    # x = 832..1024: Solid Reflective Red swatch (for pure red decal strips)
    draw.rectangle([0, 512, 640, 768], fill=white_bg)
    font_rear = load_bold_font(96)
    draw_centered_text(draw, (40, 560, 600, 720), "POLICE", font_rear, blue_col)
    draw.rectangle([640, 512, 832, 768], fill=blue_col)
    draw.rectangle([832, 512, 1024, 768], fill=red_col)

    # Band 4: y = 768..1024 (UV v = 0.00..0.25) -> Police MDT Console Screen & EU License Plate
    draw.rectangle([0, 768, 1024, 1024], fill=(22, 25, 30))
    draw.rectangle([32, 796, 620, 956], fill=(20, 22, 26), outline=(140, 148, 158), width=4)
    draw.rectangle([42, 806, 104, 946], fill=(14, 58, 160))
    draw.rectangle([104, 806, 610, 946], fill=(235, 238, 244))
    font_plate = load_bold_font(72)
    draw_centered_text(draw, (110, 814, 604, 938), "PM-064-AZ", font_plate, (24, 28, 34))

    # Police MDT Digital Radar / Dispatch Screen (x=660..990, y=790..990)
    draw.rectangle([660, 790, 990, 990], fill=(12, 18, 28), outline=(75, 85, 100), width=4)
    draw.rectangle([680, 810, 970, 855], fill=(18, 72, 165))
    draw.rectangle([680, 870, 820, 965], fill=(22, 145, 95))
    draw.rectangle([835, 870, 970, 965], fill=(205, 42, 42))

    # Blend subtle micro-prismatic retro-reflective texture from metal_plate into the blue & red stripes
    liv_np = np.asarray(liv_img, dtype=np.float32) / 255.0
    yy_l, xx_l = np.mgrid[0:1024, 0:1024]
    prism = 0.5 + 0.5 * np.sin((xx_l + yy_l) * 0.85) * np.cos((xx_l - yy_l) * 0.85)
    is_colored = (np.max(liv_np, axis=2) - np.min(liv_np, axis=2)) > 0.25
    prism_mod = np.where(is_colored, 0.94 + 0.10 * prism, 0.97 + 0.03 * mp_diff[:, :, 0])[:, :, None]
    liv_np = np.clip(liv_np * prism_mod, 0.0, 1.0)
    save_np_jpg(liv_np, os.path.join(TEX_DIR, "src_livery_base.jpg"), quality=88)

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

    # --- 4. Lamps Texture (512x512): Roof Lightbar (Blue/White/Red) + Headlights + Racetrack Taillights ---
    lamp_img = Image.new("RGB", (512, 512), (20, 22, 28))
    ldraw = ImageDraw.Draw(lamp_img)

    # Top half (y=0..256 -> UV v=0.50..1.00): Aerodynamic LED Roof Lightbar
    for y in range(256):
        v_profile = math.sin(y / 255.0 * math.pi)
        # Blue section (x=0..192)
        b_r = int(20 + 55 * v_profile)
        b_g = int(95 + 95 * v_profile)
        b_b = int(235 + 20 * v_profile)
        ldraw.line([(0, y), (192, y)], fill=(b_r, b_g, b_b))
        # Center white/silver LED section (x=192..320)
        w_c = int(205 + 48 * v_profile)
        ldraw.line([(192, y), (320, y)], fill=(w_c, w_c, min(255, w_c + 8)))
        # Red section (x=320..512)
        r_r = int(230 + 25 * v_profile)
        r_g = int(22 + 45 * v_profile)
        r_b = int(28 + 35 * v_profile)
        ldraw.line([(320, y), (512, y)], fill=(r_r, r_g, r_b))

    for cx in range(12, 180, 28):
        ldraw.rounded_rectangle([cx, 58, cx + 20, 198], radius=4, fill=(120, 200, 255), outline=(30, 80, 190), width=2)
    for cx in range(202, 310, 26):
        ldraw.rounded_rectangle([cx, 66, cx + 18, 190], radius=3, fill=(252, 252, 255), outline=(160, 170, 185), width=2)
    for cx in range(332, 500, 28):
        ldraw.rounded_rectangle([cx, 58, cx + 20, 198], radius=4, fill=(255, 115, 95), outline=(175, 20, 25), width=2)

    # Bottom-Left (x=0..256, y=256..512 -> UV u=0.0..0.5, v=0.0..0.5):
    # Charger-style Smoked Dark Headlight Cluster with Crisp Warm-White C-Ring LED DRL & Dual Projectors
    ldraw.rectangle([0, 256, 256, 512], fill=(16, 18, 22))
    # Outer C-ring LED DRL tube (matching sheet.jpg!)
    ldraw.rounded_rectangle([14, 286, 242, 480], radius=42, fill=(18, 20, 26), outline=(252, 248, 238), width=11)
    # Dark chrome divider rings + dual projector lenses
    ldraw.ellipse([40, 326, 122, 438], fill=(32, 36, 44), outline=(140, 155, 175), width=5)
    ldraw.ellipse([58, 348, 104, 416], fill=(235, 242, 255))
    ldraw.ellipse([136, 332, 210, 432], fill=(32, 36, 44), outline=(140, 155, 175), width=4)
    ldraw.ellipse([154, 354, 192, 410], fill=(245, 240, 228))
    # Amber corner turn signal indicator
    ldraw.rounded_rectangle([18, 444, 62, 474], radius=6, fill=(255, 150, 16))

    # Bottom-Right (x=256..512, y=256..512 -> UV u=0.5..1.0, v=0.0..0.5):
    # Full-Width Charger-style "Racetrack" Ruby-Red LED Taillight Ribbon
    ldraw.rectangle([256, 256, 512, 512], fill=(22, 8, 10))
    ldraw.rounded_rectangle([264, 280, 504, 488], radius=38, fill=(32, 10, 12), outline=(252, 22, 26), width=20)
    ldraw.rounded_rectangle([290, 314, 478, 454], radius=22, fill=(185, 14, 18), outline=(255, 65, 55), width=8)
    ldraw.rectangle([318, 368, 450, 400], fill=(242, 238, 232))

    lamp_blur = lamp_img.filter(ImageFilter.GaussianBlur(radius=1.0))
    lamp_np = np.asarray(lamp_img, dtype=np.float32) * 0.82 / 255.0 + np.asarray(lamp_blur, dtype=np.float32) * 0.18 / 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.025),
        ("trim",   "src_trim_base.jpg",   "src_trim_nor.jpg",   "src_trim_orm.jpg",   512,  512, 0.065),
        ("livery", "src_livery_base.jpg", None,                 "src_livery_orm.jpg", 1024, 256, 0.000),
        ("lamps",  "src_lamps_base.jpg",  None,                 None,                 512,  256, 0.000),
    ]

    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 = 85

    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 = 40.0
        noise_node.inputs['Detail'].default_value = 5.0
        noise_node.inputs['Roughness'].default_value = 0.60
        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.02 if prefix not in ('lamps', 'livery') 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 (baseColorFactor=[0.86, 0.88, 0.92], neutral white/grey detail texture, clearcoat 1.0) ---
    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.86, 0.88, 0.92, 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 Material Output", "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.22
    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.65
    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'])
    occ_node_l = nt.nodes.new('ShaderNodeGroup')
    occ_node_l.node_tree = gltf_occ_group
    nt.links.new(sep_l.outputs['Red'], occ_node_l.inputs['Occlusion'])
    if 'Coat Weight' in bsdf.inputs:
        bsdf.inputs['Coat Weight'].default_value = 1.0
        bsdf.inputs['Coat Roughness'].default_value = 0.05
    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.05, 0.08, 0.10, 1.0)
    bsdf.inputs['Roughness'].default_value = 0.03
    bsdf.inputs['Metallic'].default_value = 0.10
    bsdf.inputs['IOR'].default_value = 1.48
    bsdf.inputs['Alpha'].default_value = 0.44
    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.4
    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 & UV MAPPING HELPERS
# ---------------------------------------------------------------------------
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_alloy":       (0.04, 0.46, 0.54, 0.96),   # Q0 Top-Left: Dark gunmetal alloy wheel rim
    "trim_rubber":      (0.54, 0.96, 0.54, 0.96),   # Q1 Top-Right: Tyre rubber & tread
    "trim_leather":     (0.04, 0.46, 0.04, 0.46),   # Q2 Bottom-Left: Charcoal police cabin leather
    "trim_dark":        (0.52, 0.73, 0.04, 0.46),   # Q3 Left half: Matte black plastic trim & grille mesh
    "trim_chrome":      (0.77, 0.98, 0.04, 0.46),   # Q3 Right half: Bright chrome / brake rotor
    "livery_side_full": (0.00, 1.00, 0.75, 1.00),   # Band 1 (PIL y=0..256): Side doors & fenders POLICE stripe
    "livery_hood":      (0.00, 1.00, 0.50, 0.75),   # Band 2 (PIL y=256..512): Hood stripes + POLICE
    "livery_rear_text": (0.04, 0.58, 0.28, 0.47),   # Band 3 left: Rear trunk "POLICE"
    "livery_blue":      (0.65, 0.79, 0.28, 0.47),   # Band 3 mid: Solid reflective blue
    "livery_red":       (0.84, 0.98, 0.28, 0.47),   # Band 3 right: Solid reflective red
    "livery_plate":     (0.04, 0.60, 0.06, 0.22),   # Band 4 left: EU License plate
    "livery_mdt":       (0.65, 0.96, 0.04, 0.22),   # Band 4 right: Police MDT dispatch screen
    "lamps_bar_blue":   (0.02, 0.36, 0.54, 0.96),   # Lamps Top-Left: Translucent Blue LED half
    "lamps_bar_white":  (0.38, 0.62, 0.54, 0.96),   # Lamps Top-Mid: Translucent White LED center
    "lamps_bar_red":    (0.64, 0.98, 0.54, 0.96),   # Lamps Top-Right: Translucent Red LED half
    "lamps_head":       (0.04, 0.46, 0.04, 0.46),   # Lamps Bottom-Left: Headlight cluster
    "lamps_tail":       (0.54, 0.96, 0.04, 0.46),   # Lamps Bottom-Right: Racetrack red taillight
    "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, flip_v=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
            if flip_v:
                ry = 1.0 - ry
            loop[uv_layer].uv = (u0 + rx * (u1 - u0), v0 + ry * (v1 - v0))

def assign_side_livery_uv(bm, faces, mat_idx, uv_layer, z_world_offset, y_world_offset, is_left=True):
    """Maps side livery faces along world Z (from z_start=+0.95 to z_end=-0.96) and Y (0.275..0.675)
    onto Band 1 of Livery Atlas (v=0.75..1.00) so the leading blue block, the full 'POLICE' lettering
    across both doors, the trailing blue block on Door_RL, and the red lower stripe fit completely on the doors!"""
    z_start = +0.95   # Leading edge of side blue block on front fender/door
    z_end   = -0.96   # Rear edge of rear door (before rear wheel arch)
    y_bot   = 0.275   # Bottom of red lower stripe
    y_top   = 0.675   # Top of blue main stripe
    u0, u1 = 0.01, 0.99
    v0, v1 = 0.765, 0.975
    for f in faces:
        f.material_index = mat_idx
        f.smooth = True
        for loop in f.loops:
            co = loop.vert.co
            gz = (-co.y) + z_world_offset
            gy = co.z + y_world_offset
            if is_left:
                t_u = (z_start - gz) / (z_start - z_end)
            else:
                # Mirror around door midpoint z = -0.005 so "POLICE" reads left-to-right on the right side too!
                z_mir = 2.0 * (-0.005) - gz
                t_u = (z_start - z_mir) / (z_start - z_end)
            t_u = max(0.0, min(1.0, t_u))
            t_v = max(0.0, min(1.0, (gy - y_bot) / (y_top - y_bot)))
            loop[uv_layer].uv = (u0 + t_u * (u1 - u0), v0 + t_v * (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 DARK ALLOY WHEELS (Wheel_FL, Wheel_FR, Wheel_RL, Wheel_RR)
# ---------------------------------------------------------------------------
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.348
    r_rim   = 0.246
    w_half  = 0.118
    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_alloy"],  scale_uv=1.2)

    # Central Dark Alloy Hub Bowl, Center Cap & 5 Chrome Lug Bolts
    add_cyl_part(bm, (sign * (w_half - 0.038), 0.0, 0.0), 0.078, 0.040, 'X', idx_trim, uv_layer, UV_REGIONS["trim_alloy"],  segs=24, bevel_r=0.004)
    add_cyl_part(bm, (sign * (w_half - 0.016), 0.0, 0.0), 0.034, 0.016, 'X', idx_trim, uv_layer, UV_REGIONS["trim_chrome"], segs=20, bevel_r=0.003)
    for b_i in range(5):
        ang = 2.0 * math.pi * b_i / 5.0
        by = 0.055 * math.cos(ang)
        bz = 0.055 * 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_chrome"], segs=10, bevel_r=0.0015)

    # 5 Sculpted Twin V-Spokes (10 Dark Gunmetal 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.14
            a1 = base_ang + side_s * 0.23
            r0 = 0.062
            r1 = 0.236
            spk_verts = []
            for rad, ang_s, x_out, w_s, thick_s in [
                (r0,               a0,               w_half - 0.025, 0.021, 0.024),
                (0.5 * (r0 + r1),  0.5 * (a0 + a1),  w_half - 0.011, 0.018, 0.020),
                (r1,               a1,               w_half - 0.015, 0.022, 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_alloy"], scale_uv=2.0)

    # Internal Ventilated Steel Brake Disc Rotor & Caliper
    add_cyl_part(bm, (sign * (w_half - 0.066), 0.0, 0.0), 0.180, 0.022, 'X', idx_trim, uv_layer, UV_REGIONS["trim_chrome"], segs=32, bevel_r=0.002)
    cal_z = -0.118 if z_hub > 0 else 0.118
    add_box_part(
        bm,
        min(sign * (w_half - 0.088), sign * (w_half - 0.042)),
        max(sign * (w_half - 0.088), sign * (w_half - 0.042)),
        -0.055, 0.085,
        cal_z - 0.045, cal_z + 0.045,
        idx_trim, uv_layer, UV_REGIONS["trim_alloy"], 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.775, 0.348, +1.410, is_left=True),
    build_wheel("Wheel_FR", -0.775, 0.348, +1.410, is_left=False),
    build_wheel("Wheel_RL", +0.775, 0.348, -1.410, is_left=True),
    build_wheel("Wheel_RR", -0.775, 0.348, -1.410, is_left=False),
]
print("Built 4 dark alloy wheels.")

# ---------------------------------------------------------------------------
# 8. BUILD AERODYNAMIC ROOF LED LIGHTBAR (Lightbar)
# ---------------------------------------------------------------------------
def build_lightbar():
    mesh = bpy.data.meshes.new("Lightbar")
    obj = bpy.data.objects.new("Lightbar", mesh)
    bpy.context.collection.objects.link(obj)
    cx, cy, cz = 0.0, 1.525, -0.050
    obj.location = g2b(cx, cy, cz)

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

    def w2l(x, y, z):
        return g2b(x - cx, y - cy, z - cz)

    # 1. Two Low-Profile Black Mounting Feet sitting on the roof crown (y = 1.462..1.516)
    for sign in (+1.0, -1.0):
        x_in  = sign * 0.340
        x_out = sign * 0.480
        add_box_part(
            bm,
            min(x_in - cx, x_out - cx), max(x_in - cx, x_out - cx),
            1.462 - cy, 1.518 - cy,
            -0.145 - cz, +0.045 - cz,
            idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.006, segs=2
        )
        add_box_part(
            bm,
            min(sign * 0.330 - cx, sign * 0.495 - cx), max(sign * 0.330 - cx, sign * 0.495 - cx),
            1.454 - cy, 1.468 - cy,
            -0.155 - cz, +0.055 - cz,
            idx_trim, uv_layer, UV_REGIONS["trim_rubber"], bevel_r=0.003, segs=1
        )

    # 2. Central Support Spine Plate under lightbar
    add_box_part(
        bm,
        -0.520 - cx, +0.520 - cx,
        1.510 - cy, 1.526 - cy,
        -0.150 - cz, +0.050 - cz,
        idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.005, segs=2
    )

    # 3. Aerodynamic Low-Profile LED Lightbar Bar (y = 1.522..1.580)
    x_stations = [-0.565, -0.535, -0.420, -0.135, -0.125, +0.125, +0.135, +0.420, +0.535, +0.565]
    n_prof = 16
    rings = []
    for xs in x_stations:
        edge_t = max(0.0, (abs(xs) - 0.420) / (0.565 - 0.420))
        half_dz = 0.122 * (1.0 - 0.28 * (edge_t ** 1.6))
        y_mid   = 1.551
        half_dy = 0.029 * (1.0 - 0.18 * (edge_t ** 1.6))
        ring = []
        for k in range(n_prof):
            ang = 2.0 * math.pi * k / n_prof
            ca, sa = math.cos(ang), math.sin(ang)
            pz = cz + half_dz * (abs(ca) ** 0.65) * (1.0 if ca >= 0 else -1.0)
            py = y_mid + half_dy * (abs(sa) ** 0.65) * (1.0 if sa >= 0 else -1.0)
            ring.append(bm.verts.new(w2l(xs, py, pz)))
        rings.append(ring)

    blue_faces  = []
    white_faces = []
    red_faces   = []
    cap_trim_faces = []

    for i in range(len(x_stations) - 1):
        x_mid = 0.5 * (x_stations[i] + x_stations[i+1])
        for k in range(n_prof):
            kn = (k + 1) % n_prof
            v1, v2, v3, v4 = rings[i][k], rings[i+1][k], rings[i+1][kn], rings[i][kn]
            f = bm.faces.new((v1, v2, v3, v4))
            ang_mid = 2.0 * math.pi * (k + 0.5) / n_prof
            if abs(math.sin(ang_mid)) > 0.92 and abs(x_mid) < 0.48:
                cap_trim_faces.append(f)
            else:
                if x_mid < -0.130:
                    blue_faces.append(f)
                elif x_mid <= 0.130:
                    white_faces.append(f)
                else:
                    red_faces.append(f)

    f_right_cap = bm.faces.new(list(reversed(rings[0])))
    f_left_cap  = bm.faces.new(rings[-1])
    blue_faces.append(f_right_cap)
    red_faces.append(f_left_cap)

    assign_faces_mat_and_box_uv(bm, blue_faces,     idx_lamps, uv_layer, UV_REGIONS["lamps_bar_blue"],  scale_uv=1.8)
    assign_faces_mat_and_box_uv(bm, white_faces,    idx_lamps, uv_layer, UV_REGIONS["lamps_bar_white"], scale_uv=1.8)
    assign_faces_mat_and_box_uv(bm, red_faces,      idx_lamps, uv_layer, UV_REGIONS["lamps_bar_red"],   scale_uv=1.8)
    assign_faces_mat_and_box_uv(bm, cap_trim_faces, idx_trim,  uv_layer, UV_REGIONS["trim_chrome"],     scale_uv=1.5)

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

LIGHTBAR_OBJ = build_lightbar()
print("Built separate Lightbar node.")

# ---------------------------------------------------------------------------
# 9. 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.855, +0.450)

    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(-23.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.")

# ---------------------------------------------------------------------------
# 10. THREE-BOX SEDAN SURFACE PARAMETRIZATION & FOUR HINGED DOORS
# ---------------------------------------------------------------------------
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.880 - 0.032 * t
    elif y_up <= 0.58:
        t = (y_up - 0.22) / (0.58 - 0.22)
        w = 0.880 + 0.032 * math.sin(t * math.pi * 0.68)
    elif y_up <= 0.95:
        t = (y_up - 0.58) / (0.95 - 0.58)
        w = 0.906 - 0.024 * (t ** 1.35)
    else:
        t = min(1.0, (y_up - 0.95) / (1.46 - 0.95))
        w = 0.882 - 0.228 * (t ** 1.18)
    return w

def get_sedan_roofline_y(z_front):
    """Returns three-box sedan roof / window top height Y at longitudinal position z_front."""
    if z_front >= 0.20:
        t = min(1.0, max(0.0, (0.86 - z_front) / (0.86 - 0.20)))
        return 0.950 + (1.455 - 0.950) * (1.0 - (1.0 - t) ** 2.0)
    elif z_front >= -0.25:
        t = (0.20 - z_front) / (0.20 - (-0.25))
        return 1.455 + 0.017 * math.sin(t * math.pi * 0.65)
    elif z_front >= -1.10:
        t = (-0.25 - z_front) / (-0.25 - (-1.10))
        return 1.470 - 0.060 * (t ** 1.55)
    elif z_front >= -1.78:
        t = (-1.10 - z_front) / (-1.10 - (-1.78))
        return 1.410 - (1.410 - 1.015) * (t ** 1.25)
    else:
        t = min(1.0, (-1.78 - z_front) / (-1.78 - (-2.36)))
        return 1.015 - 0.045 * t

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.060
    else:
        z_hinge = -0.068
        z_rear  = -0.960

    x_hinge = sign * 0.890
    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")

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

    # 1. Lower Door Skin + Integrated Livery Stripe Band (y = 0.275..0.675) + Upper Painted Door Shoulder + Black Beltline Trim
    n_z = 16
    y_levels = [0.235, 0.275, 0.380, 0.525, 0.675, 0.810, 0.945, 0.966]
    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.80 and y < 0.55:
                dz = z - (-1.410)
                if abs(dz) < 0.44:
                    arch_y = 0.348 + math.sqrt(max(0.0, 0.44**2 - dz**2))
                    y_adj = max(y, arch_y)
            x_out = sign * get_side_profile_x(y_adj, z)
            if 1 <= j <= 4:
                x_out += sign * 0.0012
            elif j == 7:
                x_out += sign * 0.0020
            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 = []
    livery_faces = []
    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 1 <= j <= 3:
                livery_faces.append(f_out)
            elif j == 6:
                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_side_livery_uv(bm,       livery_faces, idx_livery, uv_layer, z_world_offset=z_hinge, y_world_offset=y_hinge, is_left=is_left)
    assign_faces_mat_and_box_uv(bm, belt_faces,  idx_trim,  uv_layer, UV_REGIONS["trim_dark"], 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 <= 4 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 + Black Frame + Black B-Pillar Trim)
    win_bot_v = []
    win_top_v = []
    frm_top_v = []
    for z in z_vals:
        yb = 0.964
        yt = max(yb + 0.030, get_sedan_roofline_y(z) - 0.034)
        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 (is_front and i == n_z - 1) or (not is_front and i in (0, n_z - 1)):
            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_dark"], 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 Body-Coloured Exterior Door Pull Handle (at y=0.81, matching sheet.jpg!)
    z_hdl_c = z_rear + 0.165
    y_hdl_c = 0.815
    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.016 - y_hinge, y_hdl_c + 0.016 - 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.006, segs=2
    )

    # 4. Interior Door Card Armrest
    add_box_part(
        bm,
        min(sign * 0.770 - x_hinge, sign * 0.850 - x_hinge),
        max(sign * 0.770 - x_hinge, sign * 0.850 - x_hinge),
        0.700 - y_hinge, 0.755 - 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 Aerodynamic Wing Mirror (White Painted Cap on Black Stalk, matching sheet.jpg!)
    if is_front:
        z_mir = z_hinge - 0.175
        y_mir = 0.968
        x_mir_in  = sign * 0.850
        x_mir_out = sign * 0.928
        add_box_part(
            bm,
            min(x_mir_in - x_hinge, sign * 0.898 - x_hinge),
            max(x_mir_in - x_hinge, sign * 0.898 - x_hinge),
            y_mir - 0.030 - y_hinge, y_mir + 0.006 - y_hinge,
            z_mir - 0.032 - z_hinge, z_mir + 0.042 - z_hinge,
            idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.005, segs=2
        )
        add_box_part(
            bm,
            min(sign * 0.868 - x_hinge, x_mir_out - x_hinge),
            max(sign * 0.868 - x_hinge, x_mir_out - x_hinge),
            y_mir - 0.008 - y_hinge, y_mir + 0.080 - y_hinge,
            z_mir - 0.062 - z_hinge, z_mir + 0.044 - z_hinge,
            idx_paint, uv_layer, UV_REGIONS["paint"], bevel_r=0.016, segs=2
        )
        add_box_part(
            bm,
            min(sign * 0.872 - x_hinge, x_mir_out - sign * 0.006 - x_hinge),
            max(sign * 0.872 - x_hinge, x_mir_out - sign * 0.006 - x_hinge),
            y_mir - 0.002 - y_hinge, y_mir + 0.072 - y_hinge,
            z_mir - 0.064 - z_hinge, z_mir - 0.055 - 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 & livery grids.")

# ---------------------------------------------------------------------------
# 11. BUILD MAIN THREE-BOX SEDAN BODY SHELL & 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.410, -1.410):
            dz = z - z_ax
            r_arch = 0.398
            if abs(dz) < r_arch:
                y_circle = 0.348 + 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.410, -1.410):
            dz = z - z_ax
            dy = y - 0.348
            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 (with Livery Panel), Front Fenders & Front Bumper Fascia (z from +0.840 to +2.425)
    z_front_stations = [
        +0.840, +0.930, +1.020, +1.120, +1.220, +1.315, +1.410, +1.505,
        +1.600, +1.700, +1.800, +1.900, +2.000, +2.100, +2.190, +2.270,
        +2.340, +2.395, +2.425
    ]

    def get_hood_center_y(z):
        if z <= 2.16:
            t = (z - 0.840) / (2.160 - 0.840)
            return 0.964 - 0.215 * (t ** 1.42)
        elif z <= 2.35:
            t = (z - 2.160) / (2.350 - 2.160)
            return 0.749 - 0.085 * (t ** 1.35)
        else:
            t = (z - 2.350) / (2.425 - 2.350)
            return 0.664 - 0.034 * t

    def get_front_plan_x(z):
        if z <= 1.78:
            return 1.0
        t = (z - 1.78) / (2.425 - 1.78)
        return 1.0 - 0.32 * (t ** 1.75)

    hood_livery_faces = []

    for sign in (+1.0, -1.0):
        fender_grid = []
        hood_grid   = []
        for i_z, z in enumerate(z_front_stations):
            plan_scale = get_front_plan_x(z)
            y_bot = arch_cut_y(z, 0.165 if z < 2.05 else 0.180)
            y_top = get_hood_center_y(z) - 0.020
            col = []
            for frac in [0.0, 0.18, 0.42, 0.66, 0.85, 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)
                col.append(bm.verts.new(g2b(sign * xx_base, yy, z)))
            fender_grid.append(col)

            x_fend = abs(col[-1].co.x)
            y_fend = col[-1].co.z
            y_cent = get_hood_center_y(z)
            h_col = [col[-1]]
            for h_frac in [0.80, 0.56, 0.28, 0.0]:
                hx = sign * x_fend * h_frac
                dome = 0.015 * math.sin((1.0 - h_frac) * math.pi) if z < 2.24 else 0.0
                hy = y_fend + (y_cent - y_fend) * (1.0 - h_frac ** 1.6) + dome
                h_col.append(bm.verts.new(g2b(hx, hy, z)))
            hood_grid.append(h_col)

        f_fender = []
        f_fend_liv = []
        for i in range(len(z_front_stations) - 1):
            z_m = 0.5 * (z_front_stations[i] + z_front_stations[i+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 z_m <= 0.95 and 1 <= j <= 3:
                    f_fend_liv.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 = bm.faces.new((v1, v2, v3, v4) if sign > 0 else (v1, v4, v3, v2))
                if 0.92 <= z_m <= 2.19 and j >= 1:
                    hood_livery_faces.append(f)
                else:
                    f_fender.append(f)

        assign_faces_mat_and_box_uv(bm, f_fender, idx_paint, uv_layer, UV_REGIONS["paint"], scale_uv=0.45)
        if f_fend_liv:
            assign_side_livery_uv(bm, f_fend_liv, idx_livery, uv_layer, z_world_offset=0.0, y_world_offset=0.0, is_left=(sign > 0))

        # Seamless Front Bumper Fascia closing directly from fender_grid[-1] (z=+2.425) inward to x=0!
        # Let's assign the central grille & lower air dam columns directly inside nose_grid so they are 100% flush with the curved bumper!
        front_edge_col = fender_grid[-1]
        nose_grid = []
        for j, v_corner in enumerate(front_edge_col):
            cx_corner = abs(v_corner.co.x)
            cy_corner = v_corner.co.z
            row = [v_corner]
            for x_frac in [0.76, 0.54, 0.28, 0.0]:
                nx = sign * cx_corner * x_frac
                nz = 2.400 + 0.025 * (1.0 - x_frac ** 1.8)
                if j == 0:
                    nz -= 0.022
                row.append(bm.verts.new(g2b(nx, cy_corner, nz)))
            nose_grid.append(row)
        f_nose_paint = []
        f_nose_grille = []
        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))
                # Charger-style wide black upper grille (j=3..4, k>=1) + white painted bumper bar (j=2) + black lower intake (j=1, k>=1)!
                if k >= 1 and (j in (1, 3, 4)):
                    f_nose_grille.append(f)
                else:
                    f_nose_paint.append(f)
        assign_faces_mat_and_box_uv(bm, f_nose_paint,  idx_paint, uv_layer, UV_REGIONS["paint"],     scale_uv=0.45)
        assign_faces_mat_and_box_uv(bm, f_nose_grille, idx_trim,  uv_layer, UV_REGIONS["trim_dark"], scale_uv=1.20)

    # Map the entire Hood Livery region (both left and right halves together) onto UV_REGIONS["livery_hood"]!
    # With flip_u=False and flip_v=True, "POLICE" on the hood reads upright and left-to-right from the front view!
    if hood_livery_faces:
        assign_faces_planar_uv(bm, hood_livery_faces, idx_livery, uv_layer, UV_REGIONS["livery_hood"], axis='Y', flip_u=False, flip_v=True)

    # 2. Three-Box Sedan Rear Clip, Rear Fenders, Rear Quarter Livery Stripe, C-Pillar & Horizontal Trunk Deck
    z_rear_stations = [
        -0.960, -1.060, -1.160, -1.260, -1.335, -1.410, -1.485, -1.565,
        -1.660, -1.760, -1.860, -1.960, -2.060, -2.150, -2.235, -2.310,
        -2.375, -2.410, -2.425
    ]

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

    for sign in (+1.0, -1.0):
        rf_grid = []
        trunk_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.220)
            y_shoulder = 0.965 if z >= -1.78 else (1.015 - 0.045 * min(1.0, (-1.78 - z) / 0.58))
            sweep_up = max(0.0, (-1.15 - z) * 0.22)
            y0 = y_bot
            y1 = max(y_bot + 0.015, 0.275 + sweep_up * 0.45)
            y2 = max(y1 + 0.025,    0.450 + sweep_up * 0.70)
            y3 = max(y2 + 0.030,    0.675 + sweep_up * 0.82)
            y4 = max(y3 + 0.025,    0.835 + sweep_up * 0.35)
            y5 = max(y4 + 0.020,    y_shoulder)
            col = []
            for j, yy in enumerate([y0, y1, y2, y3, y4, y5]):
                xx = get_side_profile_x(min(yy, 0.95), z) * ps
                if z > -1.90:
                    xx += arch_flare_x(z, yy)
                if 1 <= j <= 3 and i <= 14:
                    xx += 0.0012
                col.append(bm.verts.new(g2b(sign * xx, yy, z)))
            rf_grid.append(col)

            if z <= -1.76:
                x_sh = abs(col[-1].co.x)
                y_sh = col[-1].co.z
                y_tr_cent = get_sedan_roofline_y(z) + (0.018 if z < -2.25 else 0.010)
                t_col = [col[-1]]
                for t_frac in [0.75, 0.50, 0.25, 0.0]:
                    tx = sign * x_sh * t_frac
                    ty = y_sh + (y_tr_cent - y_sh) * (1.0 - t_frac ** 1.6)
                    t_col.append(bm.verts.new(g2b(tx, ty, z)))
                trunk_grid.append(t_col)

        f_rf = []
        f_rq_liv = []
        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 in (1, 2)) and i < 14:
                    f_rq_liv.append(f)
                else:
                    f_rf.append(f)
        for i in range(len(trunk_grid) - 1):
            for j in range(len(trunk_grid[0]) - 1):
                v1, v2, v3, v4 = trunk_grid[i][j], trunk_grid[i+1][j], trunk_grid[i+1][j+1], trunk_grid[i][j+1]
                f_rf.append(bm.faces.new((v1, v2, v3, v4) if sign > 0 else (v1, v4, v3, v2)))

        assign_faces_mat_and_box_uv(bm, f_rf, idx_paint, uv_layer, UV_REGIONS["paint"], scale_uv=0.45)
        if f_rq_liv:
            assign_faces_mat_and_box_uv(bm, f_rq_liv, idx_livery, uv_layer, UV_REGIONS["livery_blue"], scale_uv=1.0)

        # Seamless Rear Fascia & Rear Bumper closing directly from rf_grid[-1] (z=-2.425) inward to x=0!
        rear_edge_col = rf_grid[-1]
        tg_grid = []
        for j, v_rc in enumerate(rear_edge_col):
            cx_rc = abs(v_rc.co.x)
            cy_rc = v_rc.co.z
            row = [v_rc]
            for x_frac in [0.75, 0.50, 0.25, 0.0]:
                rx = sign * cx_rc * x_frac
                if j >= 4:
                    rz = -2.355 - 0.020 * (1.0 - x_frac ** 1.6)
                elif j >= 2:
                    rz = -2.385 - 0.020 * (1.0 - x_frac ** 1.6)
                else:
                    rz = -2.400 - 0.022 * (1.0 - x_frac ** 1.6)
                row.append(bm.verts.new(g2b(rx, cy_rc, max(-2.425, 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_tg.append(bm.faces.new((v1, v2, v3, v4) if sign > 0 else (v1, v4, v3, v2)))
        assign_faces_mat_and_box_uv(bm, f_tg, idx_paint, uv_layer, UV_REGIONS["paint"], scale_uv=0.45)

        # Sedan Rear Quarter Fixed Window & Sleek Raked C-Pillar Sail (from z = -0.960 to z = -1.780)
        z_rq_bot = [-0.960, -1.120, -1.280, -1.440, -1.600, -1.780]
        z_rq_top = [-0.960, -1.040, -1.140, -1.280, -1.480, -1.760]
        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.964
            yt = max(yb + 0.024, get_sedan_roofline_y(zt) - 0.032)
            yc_b = yb + 0.018
            yc_t = yt + 0.022
            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_trim_faces, cp_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) - 3:
                cp_faces.extend([f_cb, f_w, f_ct])
            else:
                rq_trim_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_trim_faces, idx_trim,  uv_layer, UV_REGIONS["trim_dark"], scale_uv=1.0)
        assign_faces_mat_and_box_uv(bm, cp_faces,      idx_paint, uv_layer, UV_REGIONS["paint"], scale_uv=0.5)

        # Painted A-Pillar, Roof Cantrail Arch & C-Pillar Upper Rail (z=+0.88 to z=-1.76)
        z_cant = [+0.880, +0.680, +0.450, +0.200, -0.050, -0.450, -0.850, -1.120, -1.450, -1.760]
        cant_out_v, cant_in_v = [], []
        for z in z_cant:
            y_c = get_sedan_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.065)
            cant_out_v.append(bm.verts.new(g2b(x_o, y_c - 0.016, z)))
            cant_in_v.append(bm.verts.new(g2b(x_i,  y_c + 0.010, 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 under the doors (z in [-0.98, +0.86])
        z_sill = [+0.860, +0.400, -0.060, -0.520, -0.980]
        sill_v0, sill_v1, sill_v2 = [], [], []
        for z in z_sill:
            sill_v0.append(bm.verts.new(g2b(sign * 0.845, 0.155, z)))
            sill_v1.append(bm.verts.new(g2b(sign * 0.882, 0.195, z)))
            sill_v2.append(bm.verts.new(g2b(sign * 0.886, 0.238, z)))
        sill_faces = []
        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_s1 = 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]))
            sill_faces.extend([f_s0, f_s1])
        assign_faces_mat_and_box_uv(bm, sill_faces, idx_paint, uv_layer, UV_REGIONS["paint"], scale_uv=0.5)

    # 3. Sculpted Crowned Sedan Roof Panel (z from +0.200 to -1.120) + Shark-Fin Antenna at z = -0.95
    z_roof = [+0.200, +0.020, -0.200, -0.450, -0.700, -0.920, -1.120]
    for sign in (+1.0, -1.0):
        roof_grid = []
        for z in z_roof:
            y_edge = get_sedan_roofline_y(z) + 0.006
            x_edge = get_side_profile_x(get_sedan_roofline_y(z), z) - 0.055
            row = []
            for x_frac in [1.0, 0.70, 0.38, 0.0]:
                rx = sign * x_edge * x_frac
                ry = y_edge + 0.018 * (1.0 - x_frac ** 1.8)
                row.append(bm.verts.new(g2b(rx, ry, z)))
            roof_grid.append(row)
        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)

    add_box_part(bm, -0.022, +0.022, 1.415, 1.468, -1.060, -0.910, idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.006, segs=2)

    # 4. Sleek Raked Windshield (z from +0.86 to +0.20) & Raked Sedan Rear Window / Backlight (z from -1.12 to -1.78)
    for sign in (+1.0, -1.0):
        ws_z = [+0.860, +0.680, +0.450, +0.200]
        ws_grid = []
        for z in ws_z:
            y_side = get_sedan_roofline_y(z) - 0.004
            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.016 * (1.0 - x_frac ** 1.6)
                wz = z + 0.032 * (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_z = [-1.120, -1.340, -1.560, -1.780]
        rw_grid = []
        for z in rw_z:
            y_side = get_sedan_roofline_y(z) - 0.004
            x_side = get_side_profile_x(y_side, z) * get_rear_plan_x(z) - 0.055
            row = []
            for x_frac in [1.0, 0.65, 0.32, 0.0]:
                rx = sign * x_side * x_frac
                ry = y_side + 0.014 * (1.0 - x_frac ** 1.6)
                rz = z - 0.025 * (1.0 - x_frac ** 1.8)
                row.append(bm.verts.new(g2b(rx, ry, rz)))
            rw_grid.append(row)
        f_rw = []
        for i in range(len(rw_z) - 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. CHARGER-STYLE BLACK FRONT GRILLE CROSSHAIR BARS, INTAKES & HEADLIGHTS
    # -----------------------------------------------------------------------
    # Note: The curved front bumper fascia (`nose_grid`) sits at z = +2.400 (corners) to +2.425 (center).
    # 1) Upper Charger-style radiator grille + crosshair bars (y = 0.465..0.630)
    add_box_part(bm, -0.450, +0.450, 0.465, 0.630, +2.395, +2.430, idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.012, segs=2)
    add_box_part(bm, -0.435, +0.435, 0.535, 0.562, +2.405, +2.435, idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.004, segs=1)
    add_box_part(bm, -0.024, +0.024, 0.470, 0.625, +2.405, +2.435, idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.004, segs=1)
    # 2) Recessed lower central air intake (y = 0.195..0.320) below the painted front bumper crossbar (y = 0.320..0.465)
    add_box_part(bm, -0.430, +0.430, 0.195, 0.320, +2.385, +2.422, idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.008, segs=2)

    # Contour-following Side Bumper Air Intakes (matching sheet.jpg!)
    for sign in (+1.0, -1.0):
        si_cols = [
            (0.475, 0.215, 0.305, +2.412),
            (0.560, 0.220, 0.310, +2.398),
            (0.635, 0.228, 0.315, +2.375),
        ]
        si_v = []
        for xa, yb, yt, zf in si_cols:
            si_v.append((bm.verts.new(g2b(sign * xa, yb, zf)), bm.verts.new(g2b(sign * xa, yt, zf))))
        si_faces = []
        for k in range(len(si_cols) - 1):
            vb0, vt0 = si_v[k]
            vb1, vt1 = si_v[k+1]
            si_faces.append(bm.faces.new((vb0, vb1, vt1, vt0) if sign < 0 else (vb0, vt0, vt1, vb1)))
        assign_faces_mat_and_box_uv(bm, si_faces, idx_trim, uv_layer, UV_REGIONS["trim_dark"], scale_uv=1.5)

    # Swept-Back Charger-Style LED Headlights (seamless continuation of upper grille!)
    for sign in (+1.0, -1.0):
        hl_z_list = [+2.395, +2.310, +2.195, +2.065]
        hl_front_v = []
        hl_back_v  = []
        for idx_h, zh in enumerate(hl_z_list):
            ps = get_front_plan_x(zh)
            y_hood_edge = get_hood_center_y(zh) - 0.016
            if idx_h == 0:
                xh = 0.450
                yb = 0.470
                yt = 0.620
                zf = 2.426
            else:
                yb = y_hood_edge - 0.125 + 0.014 * idx_h
                yt = y_hood_edge + 0.005
                xh = get_side_profile_x(0.58, zh) * ps + 0.008
                zf = zh + 0.014
            hl_front_v.append((bm.verts.new(g2b(sign * xh, yb, zf)), bm.verts.new(g2b(sign * xh, yt, zf))))
            hl_back_v.append((bm.verts.new(g2b(sign * (xh - 0.025), yb, zf - 0.035)), bm.verts.new(g2b(sign * (xh - 0.025), yt, zf - 0.035))))

        hl_faces = []
        hl_trim_faces = []
        for k in range(len(hl_z_list) - 1):
            vb0, vt0 = hl_front_v[k]
            vb1, vt1 = hl_front_v[k+1]
            hl_faces.append(bm.faces.new((vb0, vb1, vt1, vt0) if sign < 0 else (vb0, vt0, vt1, vb1)))
            vbb0, vbt0 = hl_back_v[k]
            vbb1, vbt1 = hl_back_v[k+1]
            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_dark"], scale_uv=1.0)

    # -----------------------------------------------------------------------
    # C. CHARGER-STYLE FULL-WIDTH RACETRACK LED TAILLIGHT, REAR 'POLICE' PANEL & TWIN EXHAUSTS
    # -----------------------------------------------------------------------
    add_box_part(bm, -0.700, +0.700, 0.785, 0.925, -2.430, -2.360, idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.012, segs=2)
    f_tail_bar = add_box_part(bm, -0.680, +0.680, 0.800, 0.910, -2.435, -2.375, idx_lamps, uv_layer, UV_REGIONS["lamps_tail"], bevel_r=0.010, segs=2)
    tail_rear_main = [f for f in f_tail_bar if f.normal.y > 0.75]
    if tail_rear_main:
        assign_faces_planar_uv(bm, tail_rear_main, idx_lamps, uv_layer, UV_REGIONS["lamps_tail"], axis='Z', flip_u=False)

    # 2. Rear Trunk "POLICE" Lettering Panel directly below the central tail light bar (y = 0.64..0.76, x = -0.32..+0.32)
    f_rear_pol = add_box_part(bm, -0.320, +0.320, 0.640, 0.765, -2.430, -2.370, idx_livery, uv_layer, UV_REGIONS["livery_rear_text"], bevel_r=0.004, segs=1)
    rear_pol_main = [f for f in f_rear_pol if f.normal.y > 0.75]
    if rear_pol_main:
        assign_faces_planar_uv(bm, rear_pol_main, idx_livery, uv_layer, UV_REGIONS["livery_rear_text"], axis='Z', flip_u=True)

    # 3. Lower Rear Matte Black Valance / Diffuser & Twin Rectangular Chrome Exhaust Tips (matching sheet.jpg rear view!)
    add_box_part(bm, -0.680, +0.680, 0.210, 0.345, -2.432, -2.350, idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.014, segs=2)
    for sign in (+1.0, -1.0):
        ex_x0 = min(sign * 0.440, sign * 0.610)
        ex_x1 = max(sign * 0.440, sign * 0.610)
        add_box_part(bm, ex_x0, ex_x1, 0.228, 0.288, -2.436, -2.355, idx_trim, uv_layer, UV_REGIONS["trim_chrome"], bevel_r=0.010, segs=2)
        add_box_part(bm, ex_x0 + 0.015, ex_x1 - 0.015, 0.238, 0.278, -2.438, -2.365, idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.005, segs=1)
        add_box_part(bm, min(sign * 0.54, sign * 0.68), max(sign * 0.54, sign * 0.68), 0.365, 0.390, -2.432, -2.380, 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, B-PILLARS & FULL UNDERBODY CHASSIS PAN
    # -----------------------------------------------------------------------
    add_box_part(bm, -0.750, +0.750, 0.165, 0.235, -2.280, +2.280, 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.410, -1.410):
            x_in  = sign * 0.610
            x_out = sign * 0.880
            n_arch = 18
            r_lin  = 0.398
            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 following tumblehome
    for sign in (+1.0, -1.0):
        for z_pil in (-0.064, -0.960):
            pil_levels = [0.240, 0.600, 0.955, 1.180, 1.390]
            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 POLICE PATROL INTERIOR (CHARCOAL SEATS, DASHBOARD, CONSOLE, MDT)
    # -----------------------------------------------------------------------
    add_box_part(bm, -0.750, +0.750, 0.620, 0.935, +0.510, +0.870, idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.025, segs=2)
    add_box_part(bm, +0.160, +0.580, 0.895, 0.985, +0.520, +0.715, idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.015, segs=2)
    add_box_part(bm, -0.200, +0.520, 0.830, 0.955, +0.495, +0.530, idx_trim, uv_layer, UV_REGIONS["trim_chrome"], bevel_r=0.005, segs=1)

    add_box_part(bm, -0.155, +0.155, 0.320, 0.680, -0.420, +0.540, 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_mdt = add_box_part(bm, -0.105, +0.105, 0.765, 0.865, +0.455, +0.515, idx_livery, uv_layer, UV_REGIONS["livery_mdt"], bevel_r=0.006, segs=2)
    mdt_rear = [f for f in f_mdt if f.normal.y > 0.7]
    if mdt_rear:
        assign_faces_planar_uv(bm, mdt_rear, idx_livery, uv_layer, UV_REGIONS["livery_mdt"], axis='Z', flip_u=False)

    add_box_part(bm, -0.115, +0.115, 1.300, 1.355, +0.240, +0.290, idx_trim, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.006, segs=2)

    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)

    add_box_part(bm, -0.680, +0.680, 0.400, 0.555, -1.200, -0.700, 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.035, -1.340, -1.120, 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.050, 1.200, -1.335, -1.195, idx_trim, uv_layer, UV_REGIONS["trim_leather"], bevel_r=0.022, segs=2)
    add_box_part(bm, -0.640, +0.640, 0.940, 0.995, -1.780, -1.320, 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.")

# ---------------------------------------------------------------------------
# 12. TRIANGLE BUDGET TUNING (TARGET: 50,000 TO 95,000 TRIANGLES)
# ---------------------------------------------------------------------------
ALL_MESH_OBJS = [BODY_OBJ, LIGHTBAR_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}")

    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}")

    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.85 m long, 1.86 m wide, 1.58 m high, 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.860 / (max_x - min_x)
    sy = 1.580 / (max_y - min_y)
    sz = 4.850 / (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()

# ---------------------------------------------------------------------------
# 13. 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.930, 0.000, +0.350),
    "Door_R":       (-0.930, 0.000, +0.350),
    "Exhaust":      (+0.525, 0.255, -2.410),
    "Light_Head_L": (+0.635, 0.595, +2.310),
    "Light_Head_R": (-0.635, 0.595, +2.310),
    "Light_Tail_L": (+0.560, 0.840, -2.380),
    "Light_Tail_R": (-0.560, 0.840, -2.380),
}

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)

# ---------------------------------------------------------------------------
# 14. EXPORT GLB (police-sedan.glb <= 1.5 MB) & NODES.JSON
# ---------------------------------------------------------------------------
GLB_PATH = os.path.join(WORK_DIR, "police-sedan.glb")

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

PAINTS_LIST = [
    [0.86, 0.88, 0.92],  # Default: Police Fleet White-Silver (required first)
    [0.05, 0.12, 0.28],  # Gendarmerie / Police Nationale Dark Navy Blue
    [0.44, 0.47, 0.52],  # Cote d'Azur Highway Patrol Metallic Silver
    [0.06, 0.06, 0.08],  # Unmarked / Raid Tactical Anthracite Black
    [0.14, 0.26, 0.48],  # Riviera Municipal Azure Blue Metallic
    [0.72, 0.06, 0.07],  # Command / Intervention Crimson Red
]

bpy.ops.object.select_all(action='DESELECT')
for o in ALL_MESH_OBJS + EMPTY_OBJS:
    o.select_set(True)

bpy.ops.export_scene.gltf(
    filepath=GLB_PATH,
    export_format='GLB',
    use_selection=True,
    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=82,
    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,
)

def patch_glb_materials(filepath):
    blob = open(filepath, 'rb').read()
    jl = struct.unpack_from('<I', blob, 12)[0]
    doc = json.loads(blob[20:20 + jl])
    binary = blob[20 + jl:]
    ext_used = set(doc.get('extensionsUsed', []))
    ext_used.add('KHR_materials_clearcoat')
    doc['extensionsUsed'] = sorted(ext_used)
    for mat in doc.get('materials', []):
        if mat.get('name') == 'Paint':
            pbr = mat.setdefault('pbrMetallicRoughness', {})
            pbr['baseColorFactor'] = [0.86, 0.88, 0.92, 1.0]
            exts = mat.setdefault('extensions', {})
            exts['KHR_materials_clearcoat'] = {
                'clearcoatFactor': 1.0,
                'clearcoatRoughnessFactor': 0.05
            }
        elif mat.get('name') == 'Glass':
            mat['alphaMode'] = 'BLEND'
            mat['doubleSided'] = True
    js = json.dumps(doc, separators=(',', ':')).encode('utf-8')
    js += b' ' * ((-len(js)) % 4)
    total_len = 12 + 8 + len(js) + len(binary)
    with open(filepath, 'wb') as f:
        f.write(struct.pack('<III', 0x46546c67, 2, total_len))
        f.write(struct.pack('<II', len(js), 0x4e4f534a))
        f.write(js)
        f.write(binary)

patch_glb_materials(GLB_PATH)
glb_size_mb = os.path.getsize(GLB_PATH) / (1024.0 * 1024.0)
print(f"Exported {GLB_PATH} ({glb_size_mb:.3f} MB)")

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": "police-sedan",
        "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)],
        },
        "wheelbase": 2.82,
        "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()

# ---------------------------------------------------------------------------
# 15. RENDER FINAL.PNG, SIDE.PNG, CLAY.PNG & PAINTS.PNG
# ---------------------------------------------------------------------------
def setup_studio_environment():
    bpy.ops.mesh.primitive_plane_add(size=60.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.62, 0.63, 0.65, 1.0)
    bsdf_g.inputs['Roughness'].default_value = 0.52
    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.68, 0.71, 0.75, 1.0)
    bg.inputs['Strength'].default_value = 0.90

    lights = []
    for lname, loc_g, energy, size_x, size_y, col in [
        ("_KeyLight",  (+4.2, 4.4, +4.4), 480.0, 4.5, 2.5, (1.00, 0.98, 0.95)),
        ("_FillLight", (-4.4, 3.0, +2.8), 250.0, 4.0, 2.5, (0.92, 0.95, 1.00)),
        ("_RimLight",  (-1.8, 4.5, -4.2), 350.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

    set_paint_factor(PAINTS_LIST[0])

    # Camera framed at (+4.25, 1.62, +4.80) with 45mm lens so the entire car has generous studio margin!
    hero_cam_loc = (+4.25, 1.62, +4.80)
    hero_target  = (0.00,  0.65, +0.00)

    # 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.79, 0.00), (0.00, 0.79, 0.00))
    scene.render.filepath = os.path.join(WORK_DIR, "side.png")
    bpy.ops.render.render(write_still=True)
    print("Rendered side.png")

    # 4. rear diagnostic view
    cam_data.type = 'PERSP'
    cam_data.lens = 45.0
    aim_camera(cam_obj, (-3.90, 1.58, -4.50), (0.00, 0.65, -0.10))
    scene.render.resolution_x = 1280
    scene.render.resolution_y = 720
    scene.cycles.samples = 36
    scene.render.filepath = os.path.join(BAKE_DIR, "_rear_diag.png")
    bpy.ops.render.render(write_still=True)
    print("Rendered _rear_diag.png")

    # 5. 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.")
