#!/usr/bin/env python3
"""
Build script for Modern European 7.5-Tonne Cab-Over-Engine Box Delivery Truck
(Iveco Eurocargo / Mercedes Atego / Renault D-Class style, unbranded).

Forge Garage Contract v2 compliant:
- Real dimensions: 7.20 m long (+Z front), 2.40 m wide (+X left), 3.30 m high (+Y up), wheelbase 4.20 m.
- Origin (0,0,0) on ground plane midway between front (+2.10 m) and rear (-2.10 m) axles; tyres touch y = 0.
- Separate mesh nodes:
  Body, Wheel_FL, Wheel_FR, Wheel_RL, Wheel_RR, SteeringWheel, Door_FL, Door_FR, Door_Rear_L, Door_Rear_R.
- Empty nodes:
  Seat_Driver, Door_L, Door_R, Exhaust, Light_Head_L, Light_Head_R, Light_Tail_L, Light_Tail_R.
- Materials (5 materials, <= 8 limit):
  1. Paint (recolourable cab shell, roof wind deflector fairing, front bumper, cab door frames;
     neutral white/grey detail texture, default baseColorFactor=[0.85, 0.86, 0.88], clearcoat 1.0/0.05)
  2. Brushed_Aluminum (corrugated/ribbed aluminium cargo box, anodized corner extrusions, rivets,
     rear roll-up shutter slats, cab front V-shield & door silver accent panels, side underrun bars, fuel tank)
  3. Trim (4-quadrant atlas: gunmetal steel rims & chassis, heavy tyre rubber, cab interior upholstery,
     matte black/grey plastic grille/bumpers/steps/fenders/mirrors)
  4. Glass (alpha BLEND, tinted windscreen, headlight covers & deep stepped cab side windows)
  5. Lamps (emissive warm-white headlights & foglamps, amber indicators, red tail/brake/clearance lights)
- Poly Haven CC0 textures: metal_plate, rubber_tiles, fabric_leather_02, asphalt_02.
"""

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

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}")
                try:
                    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())
                except Exception as e:
                    print(f"Warning downloading {fname}: {e}")

download_textures()

# ==============================================================================
# 2. SCENE RESET & GPU CYCLES SETUP
# ==============================================================================
bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
scene.unit_settings.system = 'METRIC'
scene.unit_settings.scale_length = 1.0

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

setup_cycles_gpu()

# ==============================================================================
# 3. COORDINATE CONVERSION HELPERS (Garage Contract v2 <-> Blender Z-up)
# ==============================================================================
# In Garage Contract v2 (glTF Y-up):
#   +X = vehicle LEFT, +Y = UP, +Z = FRONT
# Blender's glTF exporter with export_yup=True maps:
#   Blender (+X, -Y, +Z) -> glTF (+X, +Y, +Z)
# So to place a point at glTF (gx, gy, gz), its Blender coordinates are (gx, -gz, gy).

def g2b(gx, gy, gz):
    return Vector((float(gx), -float(gz), float(gy)))

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

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

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

def 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

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

    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

    # --- 1. Paint source textures (neutral white/grey detail only, as required by Paint Contract) ---
    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((yy[:, :, None]) ** 2.6 * 0.10, 0.0, 0.10)
    paint_val = 0.96 - grime_grad * (0.50 + 0.60 * (1.0 - as_lum)) - 0.04 * (1.0 - mp_lum)
    paint_base = np.repeat(np.clip(paint_val, 0.83, 0.98), 3, axis=2)
    save_np_jpg(paint_base, os.path.join(TEX_DIR, "src_paint_base.jpg"), quality=85)

    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.12
    paint_rough = 0.24 + 0.07 * mp_arm[:, :, 1] + grime_grad[:, :, 0] * 0.30
    paint_metal = np.full((1024, 1024), 0.15, dtype=np.float32)
    paint_orm = np.stack([paint_ao, paint_rough, paint_metal], axis=2)
    save_np_jpg(r512(paint_orm), os.path.join(TEX_DIR, "src_paint_orm.jpg"), quality=84)

    # --- 2. Brushed_Aluminum Atlas (1024x1024) ---
    # Top half (rows 0..512, UV v = 0.5..1.0): Anodized vertical-grain brushed aluminium cargo box panels
    # Bottom-left (rows 512..1024, cols 0..512, UV u=0..0.5, v=0..0.5): Rear horizontal roll-up shutter slats + warning plate
    # Bottom-right (rows 512..1024, cols 512..1024, UV u=0.5..1.0, v=0..0.5): Smooth satin silver (cab front shield, cab door accent, extrusions, fuel tank)
    alu_base = np.zeros((1024, 1024, 3), dtype=np.float32)
    alu_nor  = flat_nor.copy()
    alu_orm  = np.zeros((1024, 1024, 3), dtype=np.float32)

    mp_v = np.mean(mp_diff, axis=2)
    streaks_v = (
        0.50 * mp_v
        + 0.25 * np.roll(mp_v, 7, axis=0)
        + 0.25 * np.roll(mp_v, -7, axis=0)
    )
    # Fine vertical brushed grain + subtle vertical bay shading
    fine_grain = 0.5 + 0.5 * np.sin(xx[0:512, :] * 320.0 * math.pi)
    seam_x = np.minimum(xx * 8.0 - np.floor(xx * 8.0), 1.0 - (xx * 8.0 - np.floor(xx * 8.0)))
    seam_mask = np.clip(seam_x * 42.0, 0.78, 1.0)
    box_y_local = np.clip(yy[0:512, :] * 2.0, 0.0, 1.0)
    box_grime = (box_y_local ** 3.2) * 0.07 * (1.5 - np.mean(as_diff[0:512, :], axis=2))
    box_val = (0.78 + 0.11 * streaks_v[0:512, :] + 0.02 * fine_grain - box_grime) * seam_mask[0:512, :]
    alu_base[0:512, :, 0] = np.clip(box_val * 0.98, 0.48, 0.94)
    alu_base[0:512, :, 1] = np.clip(box_val * 0.99, 0.49, 0.95)
    alu_base[0:512, :, 2] = np.clip(box_val * 1.01, 0.50, 0.96)

    seam_deriv = np.sin(xx[0:512, :] * 8.0 * 2.0 * math.pi) * np.exp(-((seam_x[0:512, :] * 20.0) ** 2))
    alu_nor[0:512, :, 0] = np.clip(0.5 + seam_deriv * 0.18 + (mp_nor[0:512, :, 0] - 0.5) * 0.14, 0.0, 1.0)
    alu_nor[0:512, :, 1] = np.clip(0.5 + (mp_nor[0:512, :, 1] - 0.5) * 0.10, 0.0, 1.0)
    alu_orm[0:512, :, 0] = np.clip(0.90 + 0.08 * mp_arm[0:512, :, 0] * seam_mask[0:512, :], 0.68, 1.0)
    alu_orm[0:512, :, 1] = np.clip(0.26 + 0.09 * mp_arm[0:512, :, 1] + box_grime * 0.7, 0.18, 0.52)
    alu_orm[0:512, :, 2] = 0.86

    # Bottom-left (rows 512..1024, cols 0..512): Rear horizontal roll-up shutter slats
    sy, sx = np.mgrid[0:512, 0:512] / 512.0
    slat_phase = sy * 30.0 - np.floor(sy * 30.0)
    slat_shade = 0.77 + 0.11 * np.sin(slat_phase * math.pi) - 0.16 * np.exp(-((slat_phase - 0.05) * 22.0) ** 2)
    shutter_val = slat_shade * (0.91 + 0.09 * r512(mp_diff)[:, :, 0])
    alu_base[512:1024, 0:512, 0] = np.clip(shutter_val * 0.98, 0.36, 0.93)
    alu_base[512:1024, 0:512, 1] = np.clip(shutter_val * 0.99, 0.37, 0.94)
    alu_base[512:1024, 0:512, 2] = np.clip(shutter_val * 1.01, 0.38, 0.95)
    slat_dy = np.cos(slat_phase * 2.0 * math.pi) * 0.24
    alu_nor[512:1024, 0:512, 1] = np.clip(0.5 + slat_dy, 0.0, 1.0)
    alu_orm[512:1024, 0:512, 0] = np.clip(0.76 + 0.22 * np.sin(slat_phase * math.pi), 0.55, 1.0)
    alu_orm[512:1024, 0:512, 1] = 0.29
    alu_orm[512:1024, 0:512, 2] = 0.85

    # Bottom-right (rows 512..1024, cols 512..1024): Satin silver (cab front shield, door silver accent, extrusions, fuel tank)
    mp_d5 = r512(mp_diff)
    mp_n5 = r512(mp_nor)
    mp_a5 = r512(mp_arm)
    satin_val = 0.81 + 0.11 * np.mean(mp_d5, axis=2, keepdims=True)
    alu_base[512:1024, 512:1024, 0] = np.clip(satin_val[:, :, 0] * 0.98, 0.68, 0.95)
    alu_base[512:1024, 512:1024, 1] = np.clip(satin_val[:, :, 0] * 0.99, 0.69, 0.96)
    alu_base[512:1024, 512:1024, 2] = np.clip(satin_val[:, :, 0] * 1.01, 0.70, 0.97)
    alu_nor[512:1024, 512:1024] = flat_nor[0:512, 0:512] * 0.88 + mp_n5 * 0.12
    alu_orm[512:1024, 512:1024, 0] = 0.93 + 0.07 * mp_a5[:, :, 0]
    alu_orm[512:1024, 512:1024, 1] = 0.23 + 0.07 * mp_a5[:, :, 1]
    alu_orm[512:1024, 512:1024, 2] = 0.86

    # Yellow warning plate decal in the lower-middle of the shutter quadrant (matching sheet.jpg rear view!)
    alu_pil = Image.fromarray(np.clip(alu_base * 255.0, 0, 255).astype(np.uint8), "RGB")
    adraw = ImageDraw.Draw(alu_pil)
    adraw.rectangle([232, 944, 280, 966], fill=(238, 196, 24), outline=(40, 35, 20), width=2)
    adraw.line([(238, 952), (274, 952)], fill=(35, 30, 18), width=2)
    adraw.line([(242, 958), (270, 958)], fill=(35, 30, 18), width=2)
    alu_base = np.asarray(alu_pil, dtype=np.float32) / 255.0

    save_np_jpg(alu_base, os.path.join(TEX_DIR, "src_alu_base.jpg"), quality=85)
    save_np_jpg(alu_nor,  os.path.join(TEX_DIR, "src_alu_nor.jpg"), quality=85)
    save_np_jpg(r512(alu_orm), os.path.join(TEX_DIR, "src_alu_orm.jpg"), quality=84)

    # --- 3. 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)

    # Q0 (Top-Left, rows 0..512, cols 0..512): Satin Gunmetal/Silver Steel Wheel Rims & Chassis Frame Metal
    steel_col = 0.30 + 0.18 * mp_d5
    trim_base[0:512, 0:512] = steel_col
    trim_nor[0:512, 0:512]  = flat_nor[0:512, 0:512] * 0.80 + mp_n5 * 0.20
    trim_orm[0:512, 0:512, 0] = 0.88 + 0.12 * mp_a5[:, :, 0]
    trim_orm[0:512, 0:512, 1] = 0.30 + 0.10 * mp_a5[:, :, 1]
    trim_orm[0:512, 0:512, 2] = 0.84

    # Q1 (Top-Right, rows 0..512, cols 512..1024): Commercial Truck Tyre Rubber & Tread
    rt_d5 = r512(rt_diff)
    rt_n5 = r512(rt_nor)
    rt_a5 = r512(rt_arm)
    as_d5 = r512(as_diff)
    as_n5 = r512(as_nor)
    ty, tx = np.mgrid[0:512, 0:512] / 512.0
    tread_ribs = 0.5 + 0.5 * np.cos(tx * 2.0 * math.pi * 10.0)
    tread_lugs = 0.5 + 0.5 * np.sin((ty * 28.0 + tx * 3.0) * 2.0 * math.pi)
    tread_mask = np.clip(tread_ribs * 0.68 + tread_lugs * 0.32, 0.0, 1.0)[:, :, None]
    rubber_col = (0.042 + 0.042 * rt_d5 + 0.022 * as_d5) * (0.76 + 0.24 * tread_mask)
    trim_base[0:512, 512:1024] = rubber_col
    trim_nor[0:512, 512:1024]  = rt_n5 * 0.62 + as_n5 * 0.38
    trim_orm[0:512, 512:1024, 0] = 0.66 + 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, rows 512..1024, cols 0..512): Commercial Cab Grey/Charcoal Fabric & Leather Upholstery
    fl_d5 = r512(fl_diff)
    fl_n5 = r512(fl_nor)
    fl_a5 = r512(fl_arm)
    fl_lum = np.mean(fl_d5, axis=2, keepdims=True)
    cab_fabric = np.zeros((512, 512, 3), dtype=np.float32)
    cab_fabric[:, :, 0] = 0.17 * (0.70 + 0.45 * fl_lum[:, :, 0])
    cab_fabric[:, :, 1] = 0.18 * (0.70 + 0.45 * fl_lum[:, :, 0])
    cab_fabric[:, :, 2] = 0.20 * (0.70 + 0.45 * fl_lum[:, :, 0])
    trim_base[512:1024, 0:512] = np.clip(cab_fabric, 0.04, 0.45)
    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.58 + 0.16 * fl_a5[:, :, 1]
    trim_orm[512:1024, 0:512, 2] = 0.02

    # Q3 (Bottom-Right, rows 512..1024, cols 512..1024): Matte Black/Dark Grey Plastic & Honeycomb Grille Mesh
    gy, gx = np.mgrid[0:512, 0:512] / 512.0
    d1 = np.abs(np.sin((gx * 32.0 + gy * 18.0) * math.pi))
    d2 = np.abs(np.sin((gx * 32.0 - gy * 18.0) * math.pi))
    hex_bars = np.clip(1.0 - np.minimum(d1, d2) * 3.4, 0.0, 1.0)[:, :, None]
    dark_trim = 0.048 + 0.040 * mp_d5 + 0.036 * hex_bars
    trim_base[512:1024, 512:1024] = dark_trim
    q3_nor = mp_n5.copy()
    q3_nor[:, :, 0] = np.clip(q3_nor[:, :, 0] + (hex_bars[:, :, 0] - 0.5) * 0.22, 0.0, 1.0)
    q3_nor[:, :, 1] = np.clip(q3_nor[:, :, 1] + (hex_bars[:, :, 0] - 0.5) * 0.22, 0.0, 1.0)
    trim_nor[512:1024, 512:1024] = q3_nor
    trim_orm[512:1024, 512:1024, 0] = 0.55 + 0.40 * hex_bars[:, :, 0]
    trim_orm[512:1024, 512:1024, 1] = 0.48 + 0.20 * (1.0 - hex_bars[:, :, 0])
    trim_orm[512:1024, 512:1024, 2] = 0.10 * hex_bars[:, :, 0]

    save_np_jpg(trim_base, os.path.join(TEX_DIR, "src_trim_base.jpg"), quality=85)
    save_np_jpg(trim_nor,  os.path.join(TEX_DIR, "src_trim_nor.jpg"), quality=85)
    save_np_jpg(r512(trim_orm), os.path.join(TEX_DIR, "src_trim_orm.jpg"), quality=84)

    # --- 4. Lamps Texture (512x512): Detailed Eurocargo Multi-Reflector Headlights, Fog Lights, Amber Repeaters, Tail Lights ---
    lamp_img = Image.new("RGB", (512, 512), (24, 26, 30))
    ldraw = ImageDraw.Draw(lamp_img)

    # Top-Left (x=0..256, y=0..256, UV u=0..0.5, v=0.5..1.0): Detailed chrome-bowl twin-reflector headlight cluster
    ldraw.rectangle([0, 0, 256, 256], fill=(55, 62, 70))
    ldraw.rounded_rectangle([8, 10, 248, 246], radius=18, fill=(135, 142, 152), outline=(45, 50, 58), width=5)
    # Top LED DRL tube
    ldraw.rounded_rectangle([18, 18, 238, 42], radius=8, fill=(252, 250, 242), outline=(170, 178, 188), width=2)
    # Outer main beam multi-faceted reflector bowl
    ldraw.rounded_rectangle([18, 50, 126, 232], radius=22, fill=(195, 202, 212), outline=(85, 92, 102), width=4)
    for r_ring, c_ring in [(44, (225, 230, 238)), (32, (245, 246, 248)), (18, (255, 252, 240)), (8, (150, 158, 168))]:
        ldraw.ellipse([72 - r_ring, 141 - r_ring, 72 + r_ring, 141 + r_ring], fill=c_ring)
    # Inner low beam projector + turn-signal corner
    ldraw.rounded_rectangle([132, 50, 238, 232], radius=22, fill=(185, 192, 202), outline=(85, 92, 102), width=4)
    for r_ring, c_ring in [(42, (220, 226, 235)), (28, (248, 246, 238)), (16, (255, 252, 242)), (7, (140, 148, 158))]:
        ldraw.ellipse([185 - r_ring, 134 - r_ring, 185 + r_ring, 134 + r_ring], fill=c_ring)
    # Subtle lower amber turn-signal strip inside headlamp
    ldraw.rounded_rectangle([144, 194, 226, 224], radius=6, fill=(245, 168, 36))

    # Top-Right (x=256..512, y=0..256, UV u=0.5..1.0, v=0.5..1.0): Amber turn indicators & side marker reflectors
    ldraw.rectangle([256, 0, 512, 256], fill=(235, 138, 14))
    for ry in range(16, 240, 24):
        ldraw.line([(264, ry), (504, ry)], fill=(255, 178, 38), width=8)

    # Bottom-Left (x=0..256, y=256..512, UV u=0..0.5, v=0..0.5): Round bumper fog/spot light
    ldraw.rectangle([0, 256, 256, 512], fill=(42, 46, 52))
    ldraw.ellipse([24, 280, 232, 488], fill=(175, 182, 192), outline=(90, 96, 106), width=8)
    ldraw.ellipse([48, 304, 208, 464], fill=(238, 240, 244))
    ldraw.ellipse([76, 332, 180, 436], fill=(255, 252, 242))

    # Bottom-Right (x=256..512, y=256..512, UV u=0.5..1.0, v=0..0.5): Commercial truck multi-chamber red/amber/white tail light
    ldraw.rectangle([256, 256, 512, 512], fill=(155, 16, 20))
    ldraw.rectangle([268, 272, 368, 496], fill=(238, 24, 28))   # Red brake/tail chamber
    ldraw.rectangle([374, 272, 438, 496], fill=(245, 148, 18))  # Amber indicator chamber
    ldraw.rectangle([444, 272, 500, 384], fill=(242, 244, 248)) # White reverse chamber
    ldraw.rectangle([444, 390, 500, 496], fill=(220, 18, 22))   # Red fog/reflector chamber

    lamp_blur = lamp_img.filter(ImageFilter.GaussianBlur(radius=1.0))
    lamp_np = np.asarray(lamp_img, dtype=np.float32) * 0.80 / 255.0 + np.asarray(lamp_blur, dtype=np.float32) * 0.20 / 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.")

# ==============================================================================
# 5. 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, -15.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.02),
        ("alu",   "src_alu_base.jpg",   "src_alu_nor.jpg",   "src_alu_orm.jpg",   1024, 512, 0.03),
        ("trim",  "src_trim_base.jpg",  "src_trim_nor.jpg",  "src_trim_orm.jpg",  512, 512, 0.05),
        ("lamps", "src_lamps_base.jpg", None,                None,                512, 256, 0.00),
    ]

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

    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 = 6.0
        noise_node.inputs['Roughness'].default_value = 0.65
        links.new(uv_node.outputs['UV'], noise_node.inputs['Vector'])

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

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

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

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

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

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

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

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

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

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

            target_nor = bpy.data.images.new(f"baked_{prefix}_nor", width=res_o, height=res_o, 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()

# ==============================================================================
# 6. BUILD GLTF-READY PBR MATERIALS (5 MATERIALS)
# ==============================================================================
UV_REGIONS = {
    "paint_full":   (0.02, 0.02, 0.98, 0.98),
    "alu_box":      (0.01, 0.52, 0.99, 0.98),
    "alu_shutter":  (0.02, 0.02, 0.48, 0.48),
    "alu_satin":    (0.52, 0.02, 0.98, 0.48),
    "trim_steel":   (0.02, 0.52, 0.48, 0.98),
    "trim_rubber":  (0.52, 0.52, 0.98, 0.98),
    "trim_fabric":  (0.02, 0.02, 0.48, 0.48),
    "trim_dark":    (0.52, 0.02, 0.98, 0.48),
    "lamp_head":    (0.02, 0.52, 0.48, 0.98),
    "lamp_amber":   (0.52, 0.52, 0.98, 0.98),
    "lamp_fog":     (0.02, 0.02, 0.48, 0.48),
    "lamp_tail":    (0.52, 0.02, 0.98, 0.48),
    "lamp_red_bar": (0.54, 0.05, 0.70, 0.45),
}

def build_gltf_materials():
    mats = {}

    gltf_occ_group = bpy.data.node_groups.new("glTF Settings", "ShaderNodeTree")
    gltf_occ_group.interface.new_socket(name="Occlusion", in_out='INPUT', socket_type='NodeSocketFloat')

    # --- 1. Paint (recolourable cab, roof deflector fairing, front bumper) ---
    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.name = "PaintFactorMix"
    mix_p.data_type = 'RGBA'
    mix_p.blend_type = 'MULTIPLY'
    mix_p.inputs['Factor'].default_value = 1.0
    # Default fleet white factor [0.85, 0.86, 0.88] as required by Paint Contract
    mix_p.inputs['B'].default_value = (0.85, 0.86, 0.88, 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'])
    occ_p = nt.nodes.new('ShaderNodeGroup')
    occ_p.node_tree = gltf_occ_group
    nt.links.new(sep_p.outputs['Red'], occ_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. Brushed_Aluminum (cargo box panels, corner extrusions, shutter doors, silver cab shield/trim, underrun bars, fuel tank) ---
    mat_alu = bpy.data.materials.new(name="Brushed_Aluminum")
    mat_alu.use_nodes = True
    nt = mat_alu.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_a_base = nt.nodes.new('ShaderNodeTexImage')
    tex_a_base.image = load_img(os.path.join(BAKE_DIR, "alu_base.jpg"), 'sRGB')
    nt.links.new(tex_a_base.outputs['Color'], bsdf.inputs['Base Color'])

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

    tex_a_nor = nt.nodes.new('ShaderNodeTexImage')
    tex_a_nor.image = load_img(os.path.join(BAKE_DIR, "alu_nor.jpg"), 'Non-Color')
    nmap_a = nt.nodes.new('ShaderNodeNormalMap')
    nmap_a.inputs['Strength'].default_value = 0.45
    nt.links.new(tex_a_nor.outputs['Color'], nmap_a.inputs['Color'])
    nt.links.new(nmap_a.outputs['Normal'], bsdf.inputs['Normal'])
    mats["Brushed_Aluminum"] = mat_alu

    # --- 3. Trim (steel rims, chassis, tyres, cab interior, black/grey plastic grille/bumpers/fenders/mirrors) ---
    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_t = nt.nodes.new('ShaderNodeGroup')
    occ_t.node_tree = gltf_occ_group
    nt.links.new(sep_t.outputs['Red'], occ_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

    # --- 4. Glass (alpha BLEND, tinted windscreen & side windows) ---
    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.06, 0.09, 0.11, 1.0)
    bsdf.inputs['Metallic'].default_value = 0.10
    bsdf.inputs['Roughness'].default_value = 0.03
    bsdf.inputs['Alpha'].default_value = 0.34
    if 'IOR' in bsdf.inputs:
        bsdf.inputs['IOR'].default_value = 1.50
    mats["Glass"] = mat_glass

    # --- 5. Lamps (emissive headlights, foglights, amber indicators, red tail/clearance lights) ---
    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_lm = nt.nodes.new('ShaderNodeTexImage')
    tex_lm.image = load_img(os.path.join(BAKE_DIR, "lamps_base.jpg"), 'sRGB')
    nt.links.new(tex_lm.outputs['Color'], bsdf.inputs['Base Color'])
    bsdf.inputs['Roughness'].default_value = 0.08
    bsdf.inputs['Metallic'].default_value = 0.20
    if 'Emission Color' in bsdf.inputs:
        nt.links.new(tex_lm.outputs['Color'], bsdf.inputs['Emission Color'])
        bsdf.inputs['Emission Strength'].default_value = 1.6
    mats["Lamps"] = mat_lamps

    return mats

MATS = build_gltf_materials()
MAT_LIST = [MATS["Paint"], MATS["Brushed_Aluminum"], MATS["Trim"], MATS["Glass"], MATS["Lamps"]]
IDX_PAINT = 0
IDX_ALU   = 1
IDX_TRIM  = 2
IDX_GLASS = 3
IDX_LAMPS = 4

def attach_all_materials(mesh):
    mesh.materials.clear()
    for m in MAT_LIST:
        mesh.materials.append(m)

# ==============================================================================
# 7. GEOMETRY & UV MAPPING HELPERS (IN GLTF Y-UP COORDINATES)
# ==============================================================================
def assign_faces_box_uv(bm, faces, mat_idx, uv_layer, uv_rect, scale_uv=0.35):
    u0, v0, u1, v1 = uv_rect
    du, dv = u1 - u0, v1 - v0
    for f in faces:
        if not f.is_valid:
            continue
        f.material_index = mat_idx
        f.smooth = True
        n = f.normal
        nx, ny, nz = abs(n.x), abs(n.y), abs(n.z)
        for loop in f.loops:
            co = loop.vert.co
            gx, gy, gz = co.x, co.z, -co.y
            if nx >= ny and nx >= nz:
                raw_u, raw_v = gz * scale_uv, gy * scale_uv
            elif nz >= nx and nz >= ny:
                raw_u, raw_v = gx * scale_uv, gz * scale_uv
            else:
                raw_u, raw_v = gx * scale_uv, gy * scale_uv
            fu = raw_u - math.floor(raw_u)
            fv = raw_v - math.floor(raw_v)
            loop[uv_layer].uv = (u0 + fu * du, v0 + fv * dv)

def assign_faces_planar_uv(bm, faces, mat_idx, uv_layer, uv_rect, bounds_u, bounds_v, axis='X', flip_u=False, flip_v=False):
    u0, v0, u1, v1 = uv_rect
    du, dv = u1 - u0, v1 - v0
    bu0, bu1 = bounds_u
    bv0, bv1 = bounds_v
    span_u = (bu1 - bu0) if abs(bu1 - bu0) > 1e-6 else 1.0
    span_v = (bv1 - bv0) if abs(bv1 - bv0) > 1e-6 else 1.0
    for f in faces:
        if not f.is_valid:
            continue
        f.material_index = mat_idx
        f.smooth = True
        for loop in f.loops:
            co = loop.vert.co
            gx, gy, gz = co.x, co.z, -co.y
            if axis == 'X':      # Side projection: u = gz, v = gy
                su = (gz - bu0) / span_u
                sv = (gy - bv0) / span_v
            elif axis == 'Z':    # Front/Rear projection: u = gx, v = gy
                su = (gx - bu0) / span_u
                sv = (gy - bv0) / span_v
            else:                # Top projection: u = gx, v = gz
                su = (gx - bu0) / span_u
                sv = (gz - bv0) / span_v
            su = max(0.0, min(1.0, su))
            sv = max(0.0, min(1.0, sv))
            if flip_u:
                su = 1.0 - su
            if flip_v:
                sv = 1.0 - sv
            loop[uv_layer].uv = (u0 + su * du, v0 + sv * dv)

def add_box(bm, x0, x1, y0, y1, z0, z1, mat_idx, uv_layer, uv_rect,
            bevel_r=0.012, segs=2, subdiv_cuts=0, scale_uv=0.35, planar_axis=None):
    cx = 0.5 * (x0 + x1)
    cy = 0.5 * (y0 + y1)
    cz = 0.5 * (z0 + z1)
    sx = max(1e-4, abs(x1 - x0))
    sy = max(1e-4, abs(y1 - y0))
    sz = max(1e-4, abs(z1 - z0))

    ret = bmesh.ops.create_cube(bm, size=1.0)
    verts = ret['verts']
    bmesh.ops.scale(bm, vec=(sx, sz, sy), verts=verts)
    bmesh.ops.translate(bm, vec=g2b(cx, cy, cz), verts=verts)

    if subdiv_cuts > 0:
        edges = list({e for v in verts if v.is_valid for e in v.link_edges})
        s_ret = bmesh.ops.subdivide_edges(bm, edges=edges, cuts=subdiv_cuts, use_grid_fill=True)
        verts = [v for v in (s_ret.get('geom', []) + verts) if isinstance(v, bmesh.types.BMVert) and v.is_valid]

    if bevel_r > 0.0:
        max_b = 0.38 * min(sx, sy, sz)
        b_off = min(bevel_r, max_b)
        if b_off > 1e-4:
            sharp_edges = [
                e for e in {e for v in verts if v.is_valid for e in v.link_edges}
                if len(e.link_faces) == 2 and e.link_faces[0].normal.dot(e.link_faces[1].normal) < 0.85
            ]
            if sharp_edges:
                b_ret = bmesh.ops.bevel(bm, geom=sharp_edges, offset=b_off, segments=segs, profile=0.5, affect='EDGES')
                verts = [v for v in (b_ret.get('verts', []) + verts) if isinstance(v, bmesh.types.BMVert) and v.is_valid]

    faces = list({f for v in verts if v.is_valid for f in v.link_faces if f.is_valid})
    if planar_axis == 'X':
        assign_faces_planar_uv(bm, faces, mat_idx, uv_layer, uv_rect, (z0, z1), (y0, y1), axis='X')
    elif planar_axis == 'Z':
        assign_faces_planar_uv(bm, faces, mat_idx, uv_layer, uv_rect, (x0, x1), (y0, y1), axis='Z')
    elif planar_axis == 'Y':
        assign_faces_planar_uv(bm, faces, mat_idx, uv_layer, uv_rect, (x0, x1), (z0, z1), axis='Y')
    else:
        assign_faces_box_uv(bm, faces, mat_idx, uv_layer, uv_rect, scale_uv=scale_uv)
    return faces

def add_cylinder(bm, gx, gy, gz, radius, length, axis, mat_idx, uv_layer, uv_rect,
                 segs=24, bevel_r=0.006, r2=None):
    rad2 = radius if r2 is None else r2
    ret = bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=segs,
                                radius1=radius, radius2=rad2, depth=length)
    verts = ret['verts']
    if axis == 'X':
        rot_m = Euler((0.0, math.pi * 0.5, 0.0), 'XYZ').to_matrix().to_4x4()
        bmesh.ops.rotate(bm, matrix=rot_m, verts=verts)
    elif axis == 'Z':
        rot_m = Euler((math.pi * 0.5, 0.0, 0.0), 'XYZ').to_matrix().to_4x4()
        bmesh.ops.rotate(bm, matrix=rot_m, verts=verts)

    bmesh.ops.translate(bm, vec=g2b(gx, gy, gz), verts=verts)
    if bevel_r > 0.0:
        cap_edges = [
            e for e in {e for v in verts if v.is_valid for e in v.link_edges}
            if len(e.link_faces) == 2 and abs(e.link_faces[0].normal.dot(e.link_faces[1].normal)) < 0.55
        ]
        if cap_edges:
            b_ret = bmesh.ops.bevel(bm, geom=cap_edges, offset=bevel_r, segments=2, profile=0.5, affect='EDGES')
            verts = [v for v in (b_ret.get('verts', []) + verts) if isinstance(v, bmesh.types.BMVert) and v.is_valid]
    faces = list({f for v in verts if v.is_valid for f in v.link_faces if f.is_valid})
    assign_faces_box_uv(bm, faces, mat_idx, uv_layer, uv_rect, scale_uv=0.8)
    return faces

def add_quad_prism(bm, pts_front, pts_back, mat_idx, uv_layer, uv_rect, bevel_r=0.005, segs=2, planar_axis=None, bounds_u=None, bounds_v=None):
    """Build a clean 4-vertex quad prism (6 quad faces, ZERO N-gons!) between pts_front[4] and pts_back[4]."""
    vf = [bm.verts.new(g2b(*p)) for p in pts_front]
    vb = [bm.verts.new(g2b(*p)) for p in pts_back]
    faces = [
        bm.faces.new((vf[0], vf[1], vf[2], vf[3])),
        bm.faces.new((vb[3], vb[2], vb[1], vb[0])),
    ]
    for j in range(4):
        jn = (j + 1) % 4
        faces.append(bm.faces.new((vb[j], vb[jn], vf[jn], vf[j])))
    bmesh.ops.recalc_face_normals(bm, faces=faces)
    verts = vf + vb
    if bevel_r > 0.0:
        sharp_edges = [
            e for e in {e for v in verts if v.is_valid for e in v.link_edges}
            if len(e.link_faces) == 2 and e.link_faces[0].normal.dot(e.link_faces[1].normal) < 0.88
        ]
        if sharp_edges:
            b_ret = bmesh.ops.bevel(bm, geom=sharp_edges, offset=bevel_r, segments=segs, profile=0.5, affect='EDGES')
            verts = [v for v in (b_ret.get('verts', []) + verts) if isinstance(v, bmesh.types.BMVert) and v.is_valid]
            faces = list({f for v in verts if v.is_valid for f in v.link_faces if f.is_valid})
    if planar_axis and bounds_u and bounds_v:
        assign_faces_planar_uv(bm, faces, mat_idx, uv_layer, uv_rect, bounds_u, bounds_v, axis=planar_axis)
    else:
        assign_faces_box_uv(bm, faces, mat_idx, uv_layer, uv_rect, scale_uv=0.4)
    return faces

def add_thick_quad_grid(bm, grid_front, grid_back, mat_idx, uv_layer, uv_rect, planar_axis=None, bounds_u=None, bounds_v=None):
    """
    Build a solid 3D shell from two R x C quad grids (grid_front[r][c], grid_back[r][c])
    using ONLY clean quad faces (front surface + back surface + 4 perimeter rim strips) and NO internal partition faces!
    """
    R = len(grid_front)
    C = len(grid_front[0])
    vf = [[bm.verts.new(g2b(*grid_front[r][c])) for c in range(C)] for r in range(R)]
    vb = [[bm.verts.new(g2b(*grid_back[r][c]))  for c in range(C)] for r in range(R)]
    faces = []
    for r in range(R - 1):
        for c in range(C - 1):
            faces.append(bm.faces.new((vf[r][c], vf[r][c + 1], vf[r + 1][c + 1], vf[r + 1][c])))
            faces.append(bm.faces.new((vb[r][c], vb[r + 1][c], vb[r + 1][c + 1], vb[r][c + 1])))
    # Perimeter rim strips
    for c in range(C - 1):
        faces.append(bm.faces.new((vb[0][c], vb[0][c + 1], vf[0][c + 1], vf[0][c])))
        faces.append(bm.faces.new((vf[R - 1][c], vf[R - 1][c + 1], vb[R - 1][c + 1], vb[R - 1][c])))
    for r in range(R - 1):
        faces.append(bm.faces.new((vf[r][0], vf[r + 1][0], vb[r + 1][0], vb[r][0])))
        faces.append(bm.faces.new((vb[r][C - 1], vb[r + 1][C - 1], vf[r + 1][C - 1], vf[r][C - 1])))
    bmesh.ops.recalc_face_normals(bm, faces=faces)
    if planar_axis and bounds_u and bounds_v:
        assign_faces_planar_uv(bm, faces, mat_idx, uv_layer, uv_rect, bounds_u, bounds_v, axis=planar_axis)
    else:
        assign_faces_box_uv(bm, faces, mat_idx, uv_layer, uv_rect, scale_uv=0.4)
    return faces

def add_open_wheel_arch_flare(bm, z_center, y_center, r_in, r_out, x_in, x_out, mat_idx, uv_layer, uv_rect, segs=22):
    """Build an open U-shaped wheel arch flare over a wheel."""
    rings = []
    for i in range(segs + 1):
        ang = math.pi * i / segs
        ca, sa = math.cos(ang), math.sin(ang)
        y_i = y_center + max(-0.08, sa * r_in)
        y_o = y_center + max(-0.08, sa * r_out)
        z_i = z_center + ca * r_in
        z_o = z_center + ca * r_out
        pts = [
            (x_in,  y_i, z_i),
            (x_out, y_i, z_i),
            (x_out, y_o, z_o),
            (x_in,  y_o, z_o),
        ]
        rings.append([bm.verts.new(g2b(*p)) for p in pts])
    faces = []
    for i in range(segs):
        r0, r1 = rings[i], rings[i + 1]
        for j in range(4):
            jn = (j + 1) % 4
            faces.append(bm.faces.new((r0[j], r0[jn], r1[jn], r1[j])))
    faces.append(bm.faces.new(list(reversed(rings[0]))))
    faces.append(bm.faces.new(rings[-1]))
    bmesh.ops.recalc_face_normals(bm, faces=faces)
    assign_faces_box_uv(bm, faces, mat_idx, uv_layer, uv_rect, scale_uv=0.6)
    return faces

# ==============================================================================
# 8. BUILD DEEP-DISH COMMERCIAL STEEL WHEELS (Wheel_FL, Wheel_FR, Wheel_RL, Wheel_RR)
# ==============================================================================
def build_single_tyre_and_rim(bm, uv_layer, x_offset, is_left, is_front_dish):
    """
    Build one commercial 7.5t truck tyre + deep-dish steel wheel rim in local wheel space
    around (x_offset, 0, 0). Outer radius R = 0.440 m.
    """
    R_outer = 0.440
    R_shldr = 0.422
    R_side  = 0.365
    R_rim   = 0.278
    half_w  = 0.130 if is_front_dish else 0.118
    s_out   = 1.0 if is_left else -1.0

    n_circ = 36
    tyre_prof = [
        (x_offset - s_out * half_w * 0.86, R_rim),
        (x_offset - s_out * half_w * 0.98, R_side * 0.92),
        (x_offset - s_out * half_w * 1.00, R_side),
        (x_offset - s_out * half_w * 0.95, R_shldr),
        (x_offset - s_out * half_w * 0.78, R_outer),
        (x_offset - s_out * half_w * 0.25, R_outer),
        (x_offset + s_out * half_w * 0.25, R_outer),
        (x_offset + s_out * half_w * 0.78, R_outer),
        (x_offset + s_out * half_w * 0.95, R_shldr),
        (x_offset + s_out * half_w * 1.00, R_side),
        (x_offset + s_out * half_w * 0.98, R_side * 0.92),
        (x_offset + s_out * half_w * 0.86, R_rim),
    ]
    rings = []
    for k in range(n_circ):
        ang = 2.0 * math.pi * k / n_circ
        ca, sa = math.cos(ang), math.sin(ang)
        ring = []
        for px, pr in tyre_prof:
            ring.append(bm.verts.new(g2b(px, pr * sa, pr * ca)))
        rings.append(ring)

    tyre_faces = []
    m_p = len(tyre_prof)
    for k in range(n_circ):
        r0 = rings[k]
        r1 = rings[(k + 1) % n_circ]
        for j in range(m_p):
            jn = (j + 1) % m_p
            tyre_faces.append(bm.faces.new((r0[j], r0[jn], r1[jn], r1[j])))
    bmesh.ops.recalc_face_normals(bm, faces=tyre_faces)
    assign_faces_box_uv(bm, tyre_faces, IDX_TRIM, uv_layer, UV_REGIONS["trim_rubber"], scale_uv=1.2)

    # Subtle 3D circumferential tread blocks (embedded cleanly in outer shoulder so silhouette is round!)
    for k in range(n_circ):
        ang = 2.0 * math.pi * k / n_circ
        ca, sa = math.cos(ang), math.sin(ang)
        for rib_x in (-0.068, 0.0, +0.068):
            add_box(
                bm,
                x_offset + rib_x - 0.022, x_offset + rib_x + 0.022,
                (R_outer - 0.014) * sa - 0.014, (R_outer - 0.014) * sa + 0.014,
                (R_outer - 0.014) * ca - 0.014, (R_outer - 0.014) * ca + 0.014,
                IDX_TRIM, uv_layer, UV_REGIONS["trim_rubber"], bevel_r=0.003, segs=1
            )

    if is_front_dish:
        dish_pts = [
            (x_offset - s_out * half_w * 0.86, R_rim),
            (x_offset + s_out * half_w * 0.86, R_rim),
            (x_offset + s_out * half_w * 0.90, R_rim * 0.93),
            (x_offset + s_out * half_w * 0.56, R_rim * 0.84),
            (x_offset + s_out * half_w * 0.96, R_rim * 0.64),
            (x_offset + s_out * half_w * 0.94, R_rim * 0.42),
            (x_offset + s_out * half_w * 0.62, R_rim * 0.36),
            (x_offset + s_out * half_w * 0.88, R_rim * 0.24),
            (x_offset + s_out * half_w * 0.88, 0.02),
        ]
    else:
        dish_pts = [
            (x_offset - s_out * half_w * 0.86, R_rim),
            (x_offset + s_out * half_w * 0.86, R_rim),
            (x_offset + s_out * half_w * 0.90, R_rim * 0.92),
            (x_offset + s_out * half_w * 0.68, R_rim * 0.82),
            (x_offset - s_out * half_w * 0.32, R_rim * 0.60),
            (x_offset - s_out * half_w * 0.48, R_rim * 0.38),
            (x_offset + s_out * half_w * 0.45, R_rim * 0.30),
            (x_offset + s_out * half_w * 0.45, 0.02),
        ]

    d_rings = []
    for k in range(n_circ):
        ang = 2.0 * math.pi * k / n_circ
        ca, sa = math.cos(ang), math.sin(ang)
        d_rings.append([bm.verts.new(g2b(px, pr * sa, pr * ca)) for px, pr in dish_pts])
    rim_faces = []
    for k in range(n_circ):
        r0 = d_rings[k]
        r1 = d_rings[(k + 1) % n_circ]
        for j in range(len(dish_pts) - 1):
            rim_faces.append(bm.faces.new((r0[j], r0[j + 1], r1[j + 1], r1[j])))
    bmesh.ops.recalc_face_normals(bm, faces=rim_faces)
    assign_faces_box_uv(bm, rim_faces, IDX_TRIM, uv_layer, UV_REGIONS["trim_steel"], scale_uv=1.2)

    n_holes = 10
    r_hole = R_rim * (0.71 if is_front_dish else 0.68)
    x_hole = x_offset + s_out * half_w * (0.76 if is_front_dish else 0.16)
    r_lug  = R_rim * 0.47
    x_lug  = x_offset + s_out * half_w * (0.76 if is_front_dish else -0.38)
    for h in range(n_holes):
        ang = 2.0 * math.pi * h / n_holes
        ca, sa = math.cos(ang), math.sin(ang)
        add_cylinder(
            bm, x_hole, r_hole * sa, r_hole * ca,
            0.024, 0.018, 'X', IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], segs=12, bevel_r=0.002
        )
        add_cylinder(
            bm, x_lug, r_lug * sa, r_lug * ca,
            0.013, 0.024, 'X', IDX_TRIM, uv_layer, UV_REGIONS["trim_steel"], segs=8, bevel_r=0.002
        )

def build_wheel_node(name, gx, gy, gz, is_left=True, is_rear_twin=False):
    mesh = bpy.data.meshes.new(f"{name}_Mesh")
    attach_all_materials(mesh)
    obj = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(obj)
    obj.location = g2b(gx, gy, gz)

    bm = bmesh.new()
    uv_layer = bm.loops.layers.uv.new("UVMap")
    s_out = 1.0 if is_left else -1.0

    if not is_rear_twin:
        build_single_tyre_and_rim(bm, uv_layer, 0.0, is_left=is_left, is_front_dish=True)
        add_cylinder(bm, s_out * 0.095, 0.0, 0.0, 0.088, 0.075, 'X', IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], segs=20, bevel_r=0.006)
        for b in range(10):
            ang = 2.0 * math.pi * b / 10.0
            add_cylinder(
                bm, s_out * 0.132, 0.068 * math.sin(ang), 0.068 * math.cos(ang),
                0.007, 0.012, 'X', IDX_TRIM, uv_layer, UV_REGIONS["trim_steel"], segs=6, bevel_r=0.001
            )
    else:
        build_single_tyre_and_rim(bm, uv_layer, +s_out * 0.135, is_left=is_left, is_front_dish=False)
        build_single_tyre_and_rim(bm, uv_layer, -s_out * 0.135, is_left=not is_left, is_front_dish=False)
        add_cylinder(bm, +s_out * 0.085, 0.0, 0.0, 0.092, 0.220, 'X', IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], segs=20, bevel_r=0.008)
        for b in range(10):
            ang = 2.0 * math.pi * b / 10.0
            add_cylinder(
                bm, +s_out * 0.194, 0.070 * math.sin(ang), 0.070 * math.cos(ang),
                0.008, 0.014, 'X', IDX_TRIM, uv_layer, UV_REGIONS["trim_steel"], segs=6, bevel_r=0.001
            )

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

WHEEL_FL = build_wheel_node("Wheel_FL", +0.965, 0.440, +2.100, is_left=True,  is_rear_twin=False)
WHEEL_FR = build_wheel_node("Wheel_FR", -0.965, 0.440, +2.100, is_left=False, is_rear_twin=False)
WHEEL_RL = build_wheel_node("Wheel_RL", +0.865, 0.440, -2.100, is_left=True,  is_rear_twin=True)
WHEEL_RR = build_wheel_node("Wheel_RR", -0.865, 0.440, -2.100, is_left=False, is_rear_twin=True)
WHEEL_OBJS = [WHEEL_FL, WHEEL_FR, WHEEL_RL, WHEEL_RR]
print("Built 4 Wheel nodes (front single + rear twin deep-dish wheels).")

# ==============================================================================
# 9. BUILD STEERING WHEEL NODE (SteeringWheel)
# ==============================================================================
def build_steering_wheel():
    mesh = bpy.data.meshes.new("SteeringWheel_Mesh")
    attach_all_materials(mesh)
    obj = bpy.data.objects.new("SteeringWheel", mesh)
    bpy.context.collection.objects.link(obj)
    obj.location = g2b(+0.540, 1.480, +2.920)

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

    tilt = math.radians(28.0)
    rot_m = Euler((tilt, 0.0, 0.0), 'XYZ').to_matrix().to_4x4()

    n_seg = 28
    n_sub = 10
    R_maj = 0.215
    r_min = 0.018
    rings = []
    for i in range(n_seg):
        a = 2.0 * math.pi * i / n_seg
        ca, sa = math.cos(a), math.sin(a)
        ring = []
        for j in range(n_sub):
            b = 2.0 * math.pi * j / n_sub
            cb, sb = math.cos(b), math.sin(b)
            px = (R_maj + r_min * cb) * ca
            pz = (R_maj + r_min * cb) * sa
            py = r_min * sb
            ring.append(bm.verts.new(g2b(px, py, pz)))
        rings.append(ring)
    ring_faces = []
    for i in range(n_seg):
        r0 = rings[i]
        r1 = rings[(i + 1) % n_seg]
        for j in range(n_sub):
            jn = (j + 1) % n_sub
            ring_faces.append(bm.faces.new((r0[j], r0[jn], r1[jn], r1[j])))
    assign_faces_box_uv(bm, ring_faces, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], scale_uv=1.0)

    add_cylinder(bm, 0.0, -0.015, 0.0, 0.068, 0.055, 'Y', IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], segs=16, bevel_r=0.006)
    for sx in (-1.0, 1.0):
        add_box(bm, sx * 0.05, sx * 0.20, -0.012, 0.012, -0.025, 0.025, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.004, segs=1)
        add_box(bm, sx * 0.04, sx * 0.14, -0.012, 0.012, -0.195, -0.020, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.004, segs=1)

    bmesh.ops.rotate(bm, matrix=rot_m, 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()

# ==============================================================================
# 10. BUILD CAB DOORS (Door_FL, Door_FR) WITH CLEAN QUAD SILVER PANEL & STEPPED WINDOW
# ==============================================================================
def build_cab_door(name, is_left=True):
    """
    Build cab door with origin at its front vertical hinge axis:
      (±1.070, 1.480, +3.240)
    Uses pure beveled quad prisms (ZERO N-gon triangulation artifacts!) for the
    sweeping silver accent panel and stepped Eurocargo window frame.
    """
    s = 1.0 if is_left else -1.0
    hx, hy, hz = s * 1.070, 1.480, +3.240

    mesh = bpy.data.meshes.new(f"{name}_Mesh")
    attach_all_materials(mesh)
    obj = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(obj)
    obj.location = g2b(hx, hy, hz)

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

    def add_door_box(x0, x1, y0, y1, z0, z1, mat_idx, uv_rect, bevel_r=0.008, segs=2, subdiv_cuts=0, planar_axis=None):
        xa, xb = min(s * x0, s * x1) - hx, max(s * x0, s * x1) - hx
        ya, yb = y0 - hy, y1 - hy
        za, zb = z0 - hz, z1 - hz
        return add_box(bm, xa, xb, ya, yb, za, zb, mat_idx, uv_layer, uv_rect,
                       bevel_r=bevel_r, segs=segs, subdiv_cuts=subdiv_cuts, planar_axis=planar_axis)

    def add_door_quad(pts_y_z, x_in, x_out, mat_idx, uv_rect, bevel_r=0.006):
        """pts_y_z is 4 points [(y0,z0), (y1,z1), (y2,z2), (y3,z3)] in side view."""
        p_out = [(s * x_out - hx, y - hy, z - hz) for (y, z) in pts_y_z]
        p_in  = [(s * x_in  - hx, y - hy, z - hz) for (y, z) in pts_y_z]
        if not is_left:
            p_out, p_in = p_in, p_out
        return add_quad_prism(
            bm, p_out, p_in, mat_idx, uv_layer, uv_rect, bevel_r=bevel_r, segs=2,
            planar_axis='X', bounds_u=(1.74 - hz, 3.22 - hz), bounds_v=(0.95 - hy, 2.38 - hy)
        )

    # 1. Lower painted door base above front wheel arch / step (Y=0.98..1.20, Z=1.74..3.22)
    add_door_box(1.035, 1.076, 0.98, 1.20, 1.74, 3.22, IDX_PAINT, UV_REGIONS["paint_full"], bevel_r=0.010, segs=2, subdiv_cuts=1, planar_axis='X')
    # Forward lower door extension above upper step (Y=0.82..1.00, Z=2.66..3.22)
    add_door_box(1.035, 1.076, 0.82, 1.00, 2.66, 3.22, IDX_PAINT, UV_REGIONS["paint_full"], bevel_r=0.008, segs=2, planar_axis='X')
    # Horizontal lower door character belt molding (Y=1.07..1.12, Z=1.74..3.21)
    add_door_box(1.045, 1.080, 1.07, 1.12, 1.74, 3.21, IDX_PAINT, UV_REGIONS["paint_full"], bevel_r=0.004, segs=1, planar_axis='X')

    # 2. Sweeping Silver/Brushed Aluminum Door Accent Panel along stepped beltline (Y=1.20..1.52, Z=1.82..3.02)
    sil_cols_z = [3.02, 2.52, 1.82]
    sil_top_y  = [1.36, 1.52, 1.52]
    sil_f, sil_b = [], []
    for r_y_mode in (0, 1):
        rf, rb = [], []
        for kz, z_val in enumerate(sil_cols_z):
            yv = 1.20 if r_y_mode == 0 else sil_top_y[kz]
            rf.append((s * 1.078 - hx, yv - hy, z_val - hz))
            rb.append((s * 1.044 - hx, yv - hy, z_val - hz))
        sil_f.append(rf)
        sil_b.append(rb)
    if not is_left:
        sil_f, sil_b = sil_b, sil_f
    add_thick_quad_grid(
        bm, sil_f, sil_b, IDX_ALU, uv_layer, UV_REGIONS["alu_satin"],
        planar_axis='X', bounds_u=(1.82 - hz, 3.02 - hz), bounds_v=(1.20 - hy, 1.52 - hy)
    )

    # 3. Painted Raked Forward Door A-Pillar Frame, Upper Window Header & Rear Door Pillar (Paint)
    # Lower vertical front door pillar (Y=1.20..1.52, Z=3.02..3.23)
    add_door_box(1.035, 1.076, 1.20, 1.52, 3.02, 3.23, IDX_PAINT, UV_REGIONS["paint_full"], bevel_r=0.008, segs=2, planar_axis='X')
    # Upper raked front door A-pillar (Y=1.50..2.38, raking from Z=3.23 at Y=1.50 back to Z=3.10 at Y=2.38 to match windscreen!)
    add_door_quad([(1.50, 3.23), (1.36, 3.01), (2.36, 2.94), (2.38, 3.10)], 1.034, 1.075, IDX_PAINT, UV_REGIONS["paint_full"], bevel_r=0.008)
    add_door_box(1.030, 1.072, 2.30, 2.41, 1.66, 3.08, IDX_PAINT, UV_REGIONS["paint_full"], bevel_r=0.010, segs=2, planar_axis='X')
    add_door_box(1.035, 1.075, 0.98, 2.40, 1.66, 1.84, IDX_PAINT, UV_REGIONS["paint_full"], bevel_r=0.008, segs=2, planar_axis='X')

    # 4. Black Window Rubber Gasket Surround & Stepped Bottom Sill (Trim Q3)
    add_door_box(1.044, 1.080, 1.50, 1.56, 1.82, 2.53, IDX_TRIM, UV_REGIONS["trim_dark"], bevel_r=0.004, segs=1)
    add_door_quad([(1.34, 3.02), (1.50, 2.53), (1.56, 2.53), (1.40, 3.02)], 1.044, 1.080, IDX_TRIM, UV_REGIONS["trim_dark"], bevel_r=0.004)
    add_door_quad([(1.36, 3.02), (1.39, 2.95), (2.30, 2.89), (2.30, 2.96)], 1.042, 1.078, IDX_TRIM, UV_REGIONS["trim_dark"], bevel_r=0.004)
    add_door_box(1.044, 1.078, 1.52, 2.31, 1.82, 1.88, IDX_TRIM, UV_REGIONS["trim_dark"], bevel_r=0.004, segs=1)
    add_door_box(1.038, 1.076, 2.26, 2.32, 1.82, 2.95, IDX_TRIM, UV_REGIONS["trim_dark"], bevel_r=0.004, segs=1)

    # 5. Deep Stepped Side Window Glass built as ONE continuous 2x3 Quad Grid (ZERO internal seam line!)
    gw_z_bot = [2.96, 2.53, 1.87]
    gw_y_bot = [1.39, 1.54, 1.54]
    gw_z_top = [2.90, 2.50, 1.87]
    gw_y_top = [2.27, 2.27, 2.27]
    gw_f = [
        [(s * 1.063 - hx, gw_y_bot[k] - hy, gw_z_bot[k] - hz) for k in range(3)],
        [(s * 1.060 - hx, gw_y_top[k] - hy, gw_z_top[k] - hz) for k in range(3)],
    ]
    gw_b = [
        [(s * 1.054 - hx, gw_y_bot[k] - hy, gw_z_bot[k] - hz) for k in range(3)],
        [(s * 1.051 - hx, gw_y_top[k] - hy, gw_z_top[k] - hz) for k in range(3)],
    ]
    if not is_left:
        gw_f, gw_b = gw_b, gw_f
    add_thick_quad_grid(bm, gw_f, gw_b, IDX_GLASS, uv_layer, UV_REGIONS["paint_full"])

    # 6. Recessed Black Commercial Door Handle & Lock Cylinder (Y=1.05..1.17, Z=1.80..1.96)
    add_door_box(1.060, 1.085, 1.05, 1.17, 1.80, 1.96, IDX_TRIM, UV_REGIONS["trim_dark"], bevel_r=0.006, segs=2)
    add_door_box(1.076, 1.090, 1.08, 1.14, 1.83, 1.93, IDX_TRIM, UV_REGIONS["trim_steel"], bevel_r=0.003, segs=1)

    # 7. Interior Door Card & Grab Handle (visible through tinted Glass!)
    add_door_box(1.000, 1.035, 0.98, 1.48, 1.78, 3.14, IDX_TRIM, UV_REGIONS["trim_fabric"], bevel_r=0.010, segs=1)
    add_door_box(0.980, 1.015, 1.24, 1.30, 2.15, 2.85, IDX_TRIM, UV_REGIONS["trim_dark"], bevel_r=0.006, segs=1)

    # 8. Heavy-Duty Commercial Dual Wing Mirrors (mounted on forward door frame, reaching X = ±1.200 m!)
    add_door_box(1.068, 1.182, 2.16, 2.20, 2.94, 3.00, IDX_TRIM, UV_REGIONS["trim_dark"], bevel_r=0.005, segs=1)
    add_door_box(1.068, 1.182, 1.56, 1.60, 2.94, 3.00, IDX_TRIM, UV_REGIONS["trim_dark"], bevel_r=0.005, segs=1)
    add_cylinder(bm, s * 1.172 - hx, 1.88 - hy, 2.97 - hz, 0.014, 0.64, 'Y', IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], segs=12, bevel_r=0.002)
    # Main upper tall commercial mirror housing (Y=1.80..2.18, X=1.12..1.20)
    add_door_box(1.115, 1.200, 1.80, 2.18, 2.88, 2.98, IDX_TRIM, UV_REGIONS["trim_dark"], bevel_r=0.014, segs=2)
    add_door_box(1.120, 1.194, 1.82, 2.16, 2.872, 2.885, IDX_ALU, UV_REGIONS["alu_satin"], bevel_r=0.003, segs=1)
    # Lower wide-angle convex mirror housing (Y=1.60..1.77, X=1.12..1.198)
    add_door_box(1.118, 1.198, 1.60, 1.77, 2.88, 2.98, IDX_TRIM, UV_REGIONS["trim_dark"], bevel_r=0.012, segs=2)
    add_door_box(1.122, 1.192, 1.62, 1.75, 2.872, 2.885, IDX_ALU, UV_REGIONS["alu_satin"], bevel_r=0.003, segs=1)

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

DOOR_FL = build_cab_door("Door_FL", is_left=True)
DOOR_FR = build_cab_door("Door_FR", is_left=False)
print("Built Cab Door nodes (Door_FL, Door_FR).")

# ==============================================================================
# 11. BUILD REAR CARGO BOX DOORS (Door_Rear_L, Door_Rear_R)
# ==============================================================================
def build_rear_box_door(name, is_left=True):
    s = 1.0 if is_left else -1.0
    hx, hy, hz = s * 1.140, 2.100, -3.550

    mesh = bpy.data.meshes.new(f"{name}_Mesh")
    attach_all_materials(mesh)
    obj = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(obj)
    obj.location = g2b(hx, hy, hz)

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

    x_in  = 0.002 * s if is_left else -1.085
    x_out = +1.085 if is_left else -0.002

    f_main = add_box(
        bm,
        x_in - hx, x_out - hx,
        1.050 - hy, 3.190 - hy,
        -3.546 - hz, -3.515 - hz,
        IDX_ALU, uv_layer, UV_REGIONS["alu_shutter"],
        bevel_r=0.005, segs=1, planar_axis='Z'
    )
    assign_faces_planar_uv(
        bm, f_main, IDX_ALU, uv_layer, UV_REGIONS["alu_shutter"],
        bounds_u=(-1.085 - hx, +1.085 - hx), bounds_v=(1.050 - hy, 3.190 - hy),
        axis='Z', flip_v=True
    )

    n_slats = 24
    y_bot, y_top = 1.100, 3.170
    dy = (y_top - y_bot) / n_slats
    for k in range(n_slats):
        ys0 = y_bot + k * dy + 0.005
        ys1 = y_bot + (k + 1) * dy - 0.005
        f_slat = add_box(
            bm,
            x_in - hx, x_out - hx,
            ys0 - hy, ys1 - hy,
            -3.555 - hz, -3.542 - hz,
            IDX_ALU, uv_layer, UV_REGIONS["alu_shutter"],
            bevel_r=0.003, segs=1
        )
        assign_faces_planar_uv(
            bm, f_slat, IDX_ALU, uv_layer, UV_REGIONS["alu_shutter"],
            bounds_u=(-1.085 - hx, +1.085 - hx), bounds_v=(1.050 - hy, 3.190 - hy),
            axis='Z', flip_v=True
        )

    add_box(bm, x_in - hx, x_out - hx, 1.025 - hy, 1.065 - hy, -3.558 - hz, -3.520 - hz,
            IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.004, segs=1)
    add_box(bm, x_in - hx, x_out - hx, 1.065 - hy, 1.105 - hy, -3.558 - hz, -3.525 - hz,
            IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.004, segs=1)

    if is_left:
        add_box(bm, -0.065 - hx, +0.085 - hx, 1.095 - hy, 1.165 - hy, -3.568 - hz, -3.545 - hz,
                IDX_TRIM, uv_layer, UV_REGIONS["trim_steel"], bevel_r=0.004, segs=1)
        add_box(bm, -0.045 - hx, +0.145 - hx, 1.115 - hy, 1.142 - hy, -3.580 - hz, -3.560 - hz,
                IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.004, segs=1)

    for hy_k in (1.28, 2.10, 2.95):
        add_cylinder(
            bm, s * 1.135 - hx, hy_k - hy, -3.558 - hz,
            0.014, 0.075, 'Y', IDX_TRIM, uv_layer, UV_REGIONS["trim_steel"], segs=10, bevel_r=0.002
        )

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

DOOR_REAR_L = build_rear_box_door("Door_Rear_L", is_left=True)
DOOR_REAR_R = build_rear_box_door("Door_Rear_R", is_left=False)
DOOR_OBJS = [DOOR_FL, DOOR_FR, DOOR_REAR_L, DOOR_REAR_R]
print("Built Rear Box Door nodes (Door_Rear_L, Door_Rear_R).")

# ==============================================================================
# 12. BUILD MAIN BODY NODE (Cab, Roof Wind Deflector, Cargo Box, Chassis, Fuel Tank, Interior)
# ==============================================================================
def build_body_node():
    mesh = bpy.data.meshes.new("Body_Mesh")
    attach_all_materials(mesh)
    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")

    # --------------------------------------------------------------------------
    # A. CORRUGATED / RIBBED ALUMINIUM CARGO BOX BODY
    # Z from -3.550 m to +1.420 m, X from -1.160 m to +1.160 m, Y from 0.960 m to 3.300 m
    # --------------------------------------------------------------------------
    box_z0, box_z1 = -3.550, +1.420
    box_x0, box_x1 = -1.155, +1.155
    box_y0, box_y1 =  0.980,  3.275

    f_box = add_box(
        bm, box_x0, box_x1, box_y0, box_y1, box_z0, box_z1,
        IDX_ALU, uv_layer, UV_REGIONS["alu_box"],
        bevel_r=0.025, segs=3, subdiv_cuts=2, planar_axis='X'
    )
    top_faces = [f for f in f_box if f.is_valid and abs(f.normal.z) > 0.7]
    if top_faces:
        assign_faces_planar_uv(bm, top_faces, IDX_ALU, uv_layer, UV_REGIONS["alu_box"], (box_x0, box_x1), (box_z0, box_z1), axis='Y')
    front_faces = [f for f in f_box if f.is_valid and abs(f.normal.y) > 0.7]
    if front_faces:
        assign_faces_planar_uv(bm, front_faces, IDX_ALU, uv_layer, UV_REGIONS["alu_box"], (box_x0, box_x1), (box_y0, box_y1), axis='Z')

    # Anodized Aluminium Perimeter Corner Extrusions
    for sx in (-1.0, +1.0):
        add_box(bm, sx * 1.135, sx * 1.172, 3.215, 3.300, box_z0 - 0.015, box_z1 + 0.010,
                IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.014, segs=2, subdiv_cuts=1)
        add_box(bm, sx * 1.135, sx * 1.174, 0.940, 1.035, box_z0 - 0.015, box_z1 + 0.010,
                IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.010, segs=2, subdiv_cuts=1)
        add_box(bm, sx * 1.135, sx * 1.172, 0.940, 3.300, +1.345, +1.430,
                IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.014, segs=2, subdiv_cuts=1)
        add_box(bm, sx * 1.085, sx * 1.172, 0.940, 3.300, -3.568, -3.485,
                IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.012, segs=2, subdiv_cuts=1)
        # Rounded cast-aluminium 3-way front-top & rear-top corner caps
        add_box(bm, sx * 1.125, sx * 1.176, 3.205, 3.300, +1.335, +1.435,
                IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.022, segs=3)
        add_box(bm, sx * 1.125, sx * 1.176, 3.205, 3.300, -3.568, -3.475,
                IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.018, segs=3)

    add_box(bm, -1.168, +1.168, 3.215, 3.300, +1.350, +1.430, IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.014, segs=2)
    add_box(bm, -1.168, +1.168, 3.190, 3.300, -3.568, -3.485, IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.012, segs=2)
    add_box(bm, -1.168, +1.168, 0.940, 1.035, -3.568, -3.485, IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.010, segs=2)
    add_box(bm, -1.168, +1.168, 0.940, 1.035, +1.350, +1.430, IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.010, segs=2)

    # 7 Subtle Vertical Panel Seam Strips on each side of the cargo box (8 bays matching sheet.jpg!)
    n_bays = 8
    bay_dz = (1.350 - (-3.485)) / n_bays
    for b_idx in range(1, n_bays):
        zb = -3.485 + b_idx * bay_dz
        for sx in (-1.0, +1.0):
            add_box(bm, sx * 1.152, sx * 1.158, 1.035, 3.215, zb - 0.008, zb + 0.008,
                    IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.002, segs=1)

    # Physical 3D Rivet Heads along the top rail, bottom rub-rail, and front vertical corner extrusions
    for r_idx in range(22):
        zr = -3.42 + r_idx * (4.72 / 21.0)
        for sx in (-1.0, +1.0):
            add_box(bm, sx * 1.168, sx * 1.176, 3.245, 3.262, zr - 0.009, zr + 0.009,
                    IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.003, segs=1)
            add_box(bm, sx * 1.168, sx * 1.176, 0.985, 1.002, zr - 0.009, zr + 0.009,
                    IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.003, segs=1)
    for ry_idx in range(12):
        yr = 1.08 + ry_idx * (2.10 / 11.0)
        for sx in (-1.0, +1.0):
            add_box(bm, sx * 1.168, sx * 1.176, yr - 0.009, yr + 0.009, +1.378, +1.396,
                    IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.003, segs=1)

    # Roof cross-ribs
    for r_idx in range(6):
        zr = -3.00 + r_idx * 0.80
        add_box(bm, -1.140, +1.140, 3.268, 3.296, zr - 0.025, zr + 0.025,
                IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.005, segs=1)

    # Top Rear Clearance Lights & Bottom Corner Dock Buffers
    add_box(bm, -0.095, +0.095, 3.235, 3.268, -3.575, -3.555, IDX_LAMPS, uv_layer, UV_REGIONS["lamp_red_bar"], bevel_r=0.003, segs=1)
    for sx in (-1.0, +1.0):
        add_box(bm, sx * 0.96, sx * 1.06, 3.235, 3.268, -3.575, -3.555, IDX_LAMPS, uv_layer, UV_REGIONS["lamp_red_bar"], bevel_r=0.003, segs=1)
        add_box(bm, sx * 1.115, sx * 1.155, 1.54, 1.66, -3.575, -3.555, IDX_LAMPS, uv_layer, UV_REGIONS["lamp_red_bar"], bevel_r=0.003, segs=1)
        add_box(bm, sx * 1.04, sx * 1.16, 0.935, 1.025, -3.600, -3.555, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.008, segs=2)
        for zm in (+1.02, -0.85, -3.28):
            add_box(bm, sx * 1.168, sx * 1.180, 0.945, 0.980, zm - 0.040, zm + 0.040,
                    IDX_LAMPS, uv_layer, UV_REGIONS["lamp_amber"], bevel_r=0.003, segs=1)

    # --------------------------------------------------------------------------
    # B. SEAMLESS AERODYNAMIC ROOF WIND DEFLECTOR FAIRING ABOVE CAB (Paint)
    # Built as a pure quad-grid shell (NO N-gon caps and NO sticking-out black bars!)
    # with the sculpted horizontal collar step from sheet.jpg!
    # --------------------------------------------------------------------------
    fairing_z_rows = [
        # (z, width_scale, dome_height_above_252, collar_recess)
        (+3.22, 0.86, 0.00, 0.000),
        (+3.15, 0.91, 0.11, 0.008),
        (+3.02, 0.95, 0.24, 0.018),
        (+2.82, 0.98, 0.40, 0.022),
        (+2.56, 1.00, 0.55, 0.022),
        (+2.25, 1.02, 0.66, 0.022),
        (+1.90, 1.03, 0.72, 0.020),
        (+1.62, 1.04, 0.74, 0.015),
        (+1.46, 1.04, 0.75, 0.010),
    ]
    u_vals = np.linspace(-1.0, 1.0, 19)
    f_grid_outer = []
    f_grid_inner = []
    for fz, w_sc, h_dome, c_rec in fairing_z_rows:
        row_o, row_i = [], []
        for u in u_vals:
            au = abs(u)
            gx = u * 1.02 * w_sc
            arch_factor = max(0.0, 1.0 - (u / 1.0) ** 2) ** 0.62
            gy = 2.51 + h_dome * arch_factor
            if 0.78 <= au <= 0.92 and h_dome > 0.08:
                gx *= (1.0 - c_rec)
                gy -= 0.012
            gz = fz - 0.09 * (u ** 2) * (1.0 if fz > 2.5 else 0.25)
            row_o.append((gx, gy, gz))
            row_i.append((gx * 0.96, max(2.49, gy - 0.028), gz - 0.025))
        f_grid_outer.append(row_o)
        f_grid_inner.append(row_i)
    add_thick_quad_grid(bm, f_grid_outer, f_grid_inner, IDX_PAINT, uv_layer, UV_REGIONS["paint_full"])

    # --------------------------------------------------------------------------
    # C. TILT CAB SHELL, CONTINUOUS CURVED WINDSCREEN, SILVER V-SHIELD & GRILLE
    # --------------------------------------------------------------------------
    # 1. Cab Rear Wall & Roof Header (Paint)
    add_box(bm, -1.060, +1.060, 0.88, 2.52, +1.52, +1.76, IDX_PAINT, uv_layer, UV_REGIONS["paint_full"], bevel_r=0.028, segs=3, subdiv_cuts=1)
    add_box(bm, -1.050, +1.050, 2.38, 2.53, +1.70, +3.21, IDX_PAINT, uv_layer, UV_REGIONS["paint_full"], bevel_r=0.042, segs=3, subdiv_cuts=1)
    # Flush white/amber roof clearance marker lights at outer top corners of cab forehead (matching sheet.jpg!)
    for sx in (-1.0, +1.0):
        add_box(bm, sx * 0.78, sx * 0.90, 2.48, 2.525, +3.14, +3.215, IDX_LAMPS, uv_layer, UV_REGIONS["lamp_head"], bevel_r=0.006, segs=2)

    # 2. Raked Front A-Pillars framing the panoramic windscreen (built as clean 4-vertex quad prisms!)
    for sx in (-1.0, +1.0):
        ap_f = [
            (sx * 0.93, 1.50, +3.47),
            (sx * 1.06, 1.50, +3.37),
            (sx * 1.03, 2.42, +3.14),
            (sx * 0.90, 2.42, +3.20),
        ]
        ap_b = [
            (sx * 0.93, 1.50, +3.22),
            (sx * 1.06, 1.50, +3.18),
            (sx * 1.03, 2.42, +3.02),
            (sx * 0.90, 2.42, +3.05),
        ]
        if sx < 0:
            ap_f = [ap_f[1], ap_f[0], ap_f[3], ap_f[2]]
            ap_b = [ap_b[1], ap_b[0], ap_b[3], ap_b[2]]
        add_quad_prism(bm, ap_f, ap_b, IDX_PAINT, uv_layer, UV_REGIONS["paint_full"], bevel_r=0.010, segs=2)

    # 3. ONE Continuous Curved Panoramic Windscreen (Glass) with ZERO internal vertical seam lines!
    ws_x = np.linspace(-0.94, +0.94, 15)
    ws_y = [1.55, 1.84, 2.13, 2.40]
    ws_front, ws_back = [], []
    for r_idx, wy in enumerate(ws_y):
        t_y = (wy - 1.55) / (2.40 - 1.55)
        z_base = 3.41 * (1.0 - t_y) + 3.15 * t_y
        x_taper = 1.0 - 0.03 * t_y
        row_f, row_b = [], []
        for wx in ws_x:
            gx = wx * x_taper
            z_arch = 0.065 * (1.0 - (wx / 0.94) ** 2) * (1.0 - 0.2 * t_y)
            gz = z_base + z_arch
            row_f.append((gx, wy, gz + 0.008))
            row_b.append((gx, wy, gz - 0.008))
        ws_front.append(row_f)
        ws_back.append(row_b)
    add_thick_quad_grid(bm, ws_front, ws_back, IDX_GLASS, uv_layer, UV_REGIONS["paint_full"])

    # Black rubber windscreen perimeter gasket (top & bottom strips) + Twin Commercial Wipers
    add_box(bm, -0.96, +0.96, 2.38, 2.42, +3.14, +3.21, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.006, segs=1)
    add_box(bm, -0.95, +0.95, 1.51, 1.56, +3.39, +3.475, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.005, segs=1)
    for wx in (-0.52, +0.16):
        add_box(bm, wx - 0.02, wx + 0.44, 1.55, 1.58, +3.46, +3.505, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.003, segs=1)
        add_box(bm, wx + 0.10, wx + 0.54, 1.58, 1.61, +3.45, +3.495, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.003, segs=1)

    # 4. Sculpted Curved Cab Front Nose, Wide Silver V-Shield & Lower Honeycomb V-Grille
    # Main Cab Nose Cheeks with generous corner rounding (Y = 0.86..1.53, Z = +3.18..+3.475)
    add_box(bm, -1.068, +1.068, 0.86, 1.53, +3.18, +3.475, IDX_PAINT, uv_layer, UV_REGIONS["paint_full"], bevel_r=0.055, segs=4, subdiv_cuts=1)

    # Helper for front nose curvature Z(x) sitting cleanly in front of Z=+3.475 across the ENTIRE width!
    def z_nose_curve(gx, z_center=3.512):
        return z_center - 0.024 * ((gx / 0.92) ** 2)

    # 4a. Smooth Flush Curved Brushed Silver V-Shield Panel (Brushed_Aluminum, matching sheet.jpg!)
    # Wide at top (X in [-0.90, +0.90], Y = 1.44), tapering in a crisp V-shape to X in [-0.52, +0.52] at bottom (Y = 1.12)!
    sh_u = np.linspace(-1.0, +1.0, 15)
    sh_rows = [
        # (half_width, y_center, y_edge, z_offset)
        (0.50, 1.11, 1.15, +0.005),
        (0.65, 1.22, 1.25, +0.007),
        (0.78, 1.33, 1.35, +0.007),
        (0.90, 1.43, 1.44, +0.004),
    ]
    sh_f, sh_b = [], []
    for hw, yc, ye, z_off in sh_rows:
        rf, rb = [], []
        for u in sh_u:
            gx = u * hw
            gy = yc * (1.0 - u ** 2) + ye * (u ** 2)
            gz = z_nose_curve(gx, 3.515) + z_off
            rf.append((gx, gy, gz))
            rb.append((gx, gy, gz - 0.025))
        sh_f.append(rf)
        sh_b.append(rb)
    add_thick_quad_grid(
        bm, sh_f, sh_b, IDX_ALU, uv_layer, UV_REGIONS["alu_satin"],
        planar_axis='Z', bounds_u=(-0.90, +0.90), bounds_v=(1.11, 1.44)
    )

    # 4b. Dark Recessed Honeycomb V-Grille Trough below & around the Silver V-Shield (Y = 0.90..1.36)
    gr_rows = [
        (0.50, 0.90, 0.92, +0.002),
        (0.64, 1.01, 1.05, +0.003),
        (0.78, 1.15, 1.21, +0.003),
        (0.88, 1.28, 1.36, +0.002),
    ]
    gr_f, gr_b = [], []
    for hw, yc, ye, z_off in gr_rows:
        rf, rb = [], []
        for u in sh_u:
            gx = u * hw
            gy = yc * (1.0 - u ** 2) + ye * (u ** 2)
            gz = z_nose_curve(gx, 3.504) + z_off
            rf.append((gx, gy, gz))
            rb.append((gx, gy, gz - 0.025))
        gr_f.append(rf)
        gr_b.append(rb)
    add_thick_quad_grid(bm, gr_f, gr_b, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"])

    # Horizontal mesh louver bars inside the lower central radiator grille (Y = 0.92..1.06, X = -0.48..+0.48)
    for gy_bar in (0.94, 0.99, 1.04):
        add_box(bm, -0.48, +0.48, gy_bar - 0.008, gy_bar + 0.008, +3.495, +3.515, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.002, segs=1)

    # Vertical Corner Air Deflector Louvers at outer front cab corners (X = ±0.96..±1.065, Y = 0.91..1.28)
    for sx in (-1.0, +1.0):
        add_box(bm, sx * 0.95, sx * 1.068, 0.91, 1.28, +3.40, +3.485, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.008, segs=2)
        for vy in (0.96, 1.04, 1.12, 1.20):
            add_box(bm, sx * 0.96, sx * 1.072, vy - 0.010, vy + 0.010, +3.42, +3.495, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.003, segs=1)

    # --------------------------------------------------------------------------
    # D. SCULPTED HEAVY FRONT BUMPER, RECESSED HEADLIGHTS, FOG LIGHTS & OPEN FOOTSTEPS
    # Y from 0.35 m to 0.85 m, Z from +3.20 m to +3.60 m
    # --------------------------------------------------------------------------
    add_box(bm, -1.05, +1.05, 0.83, 0.88, +3.22, +3.51, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.006, segs=1)

    # Main Sculpted Front Bumper Shell (Paint) with rounded wrap-around corners
    add_box(bm, -1.075, +1.075, 0.36, 0.85, +3.20, +3.55, IDX_PAINT, uv_layer, UV_REGIONS["paint_full"], bevel_r=0.038, segs=3, subdiv_cuts=1)

    # Sculpted Trapezoidal Bull-Bar Center Frame & Angled Flanking Struts (Paint)
    # Upper central bumper crossbar with recessed license plate niche (Y = 0.62..0.84, X = -0.56..+0.56)
    add_box(bm, -0.56, +0.56, 0.62, 0.84, +3.50, +3.595, IDX_PAINT, uv_layer, UV_REGIONS["paint_full"], bevel_r=0.016, segs=2)
    add_box(bm, -0.26, +0.26, 0.67, 0.78, +3.54, +3.600, IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.004, segs=1)
    # Lower bumper chin lip below central air dam (Y = 0.35..0.41, X = -0.64..+0.64)
    add_box(bm, -0.64, +0.64, 0.35, 0.41, +3.48, +3.590, IDX_PAINT, uv_layer, UV_REGIONS["paint_full"], bevel_r=0.012, segs=2)

    # Deep Recessed Central Trapezoidal Air Dam & Intercooler Grille (Y = 0.40..0.63, X = -0.46..+0.46)
    add_box(bm, -0.46, +0.46, 0.40, 0.63, +3.46, +3.555, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.008, segs=2)
    for by in (0.45, 0.51, 0.57):
        add_box(bm, -0.44, +0.44, by - 0.008, by + 0.008, +3.52, +3.568, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.002, segs=1)

    # Angled Painted Struts separating the central air dam from the outer headlamp/foglamp pockets!
    for sx in (-1.0, +1.0):
        st_f = [
            (sx * 0.44, 0.64, +3.592),
            (sx * 0.56, 0.64, +3.585),
            (sx * 0.64, 0.36, +3.585),
            (sx * 0.52, 0.36, +3.592),
        ]
        st_b = [(p[0], p[1], +3.48) for p in st_f]
        if sx < 0:
            st_f = [st_f[1], st_f[0], st_f[3], st_f[2]]
            st_b = [st_b[1], st_b[0], st_b[3], st_b[2]]
        add_quad_prism(bm, st_f, st_b, IDX_PAINT, uv_layer, UV_REGIONS["paint_full"], bevel_r=0.010, segs=2)

        # Recessed Headlight Housing Bezel (Y = 0.58..0.77, X = ±0.58..±0.99)
        add_box(bm, sx * 0.57, sx * 1.00, 0.57, 0.78, +3.48, +3.558, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.008, segs=2)
        # Detailed Twin-Reflector Headlamp Lens & Chrome Reflector (Lamps) + Clear Glass Cover (Glass)
        f_hl = add_box(bm, sx * 0.59, sx * 0.98, 0.59, 0.76, +3.50, +3.566, IDX_LAMPS, uv_layer, UV_REGIONS["lamp_head"], bevel_r=0.006, segs=2)
        assign_faces_planar_uv(
            bm, f_hl, IDX_LAMPS, uv_layer, UV_REGIONS["lamp_head"],
            bounds_u=(min(sx * 0.59, sx * 0.98), max(sx * 0.59, sx * 0.98)), bounds_v=(0.59, 0.76),
            axis='Z', flip_u=(sx < 0)
        )
        add_box(bm, sx * 0.585, sx * 0.985, 0.585, 0.765, +3.564, +3.574, IDX_GLASS, uv_layer, UV_REGIONS["paint_full"], bevel_r=0.004, segs=1)

        # Lower Recessed Fog Light Bezel Pocket (Y = 0.38..0.55, X = ±0.64..±0.98)
        add_box(bm, sx * 0.63, sx * 0.98, 0.38, 0.55, +3.48, +3.555, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.008, segs=2)
        add_cylinder(bm, sx * 0.85, 0.47, +3.552, 0.042, 0.024, 'Z', IDX_LAMPS, uv_layer, UV_REGIONS["lamp_fog"], segs=16, bevel_r=0.003)
        add_cylinder(bm, sx * 0.73, 0.43, +3.552, 0.025, 0.024, 'Z', IDX_LAMPS, uv_layer, UV_REGIONS["lamp_fog"], segs=12, bevel_r=0.002)

        # Open Recessed Cab Entry Footsteps ahead of Front Wheel (Z = +2.64..+3.20, Y = 0.36..0.82)
        # Inner dark recessed step-well backing wall at X = ±0.90
        add_box(bm, sx * 0.86, sx * 0.93, 0.37, 0.82, +2.64, +3.20, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.008, segs=1)
        # Forward & rear vertical painted step-well cheeks so it blends into the bumper & wheel arch!
        add_box(bm, sx * 0.90, sx * 1.072, 0.37, 0.83, +3.15, +3.24, IDX_PAINT, uv_layer, UV_REGIONS["paint_full"], bevel_r=0.008, segs=2)
        add_box(bm, sx * 0.90, sx * 1.072, 0.37, 0.83, +2.62, +2.70, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.008, segs=2)
        # Two open horizontal non-slip silver step treads (Y = 0.39..0.43 and Y = 0.60..0.64)
        add_box(bm, sx * 0.91, sx * 1.075, 0.38, 0.425, +2.65, +3.18, IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.004, segs=1)
        add_box(bm, sx * 0.91, sx * 1.075, 0.60, 0.645, +2.65, +3.18, IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.004, segs=1)

        # Front Open Wheel Arch Flare (curving cleanly over the front wheel at Z=+2.10, Y=0.44!)
        add_open_wheel_arch_flare(
            bm, z_center=+2.100, y_center=0.440, r_in=0.485, r_out=0.565,
            x_in=sx * 0.97, x_out=sx * 1.110, mat_idx=IDX_TRIM, uv_layer=uv_layer, uv_rect=UV_REGIONS["trim_dark"], segs=22
        )
        add_cylinder(bm, sx * 0.68, 0.440, +2.100, 0.47, 0.34, 'X', IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], segs=20, bevel_r=0.0)
        # Front wheel arch rear mudflap + amber side turn-signal repeater (matching sheet.jpg!)
        add_box(bm, sx * 0.86, sx * 1.095, 0.18, 0.46, +1.52, +1.56, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.004, segs=1)
        add_box(bm, sx * 1.100, sx * 1.118, 0.56, 0.60, +1.55, +1.62, IDX_LAMPS, uv_layer, UV_REGIONS["lamp_amber"], bevel_r=0.003, segs=1)
        add_box(bm, sx * 1.080, sx * 1.100, 0.72, 0.76, +1.58, +1.66, IDX_LAMPS, uv_layer, UV_REGIONS["lamp_amber"], bevel_r=0.003, segs=1)

    # --------------------------------------------------------------------------
    # E. CHASSIS FRAME RAILS, SUB-FRAME, FUEL TANK, SIDE UNDERRUN BARS & REAR FENDERS
    # --------------------------------------------------------------------------
    for sx in (-1.0, +1.0):
        add_box(bm, sx * 0.39, sx * 0.47, 0.68, 0.88, -3.54, +3.48, IDX_TRIM, uv_layer, UV_REGIONS["trim_steel"], bevel_r=0.008, segs=1)
        add_box(bm, sx * 0.40, sx * 0.46, 0.88, 0.98, -3.50, +1.38, IDX_TRIM, uv_layer, UV_REGIONS["trim_steel"], bevel_r=0.006, segs=1)
        for z_br in (-3.20, -2.50, -1.60, -0.80, 0.00, +0.80, +1.30):
            add_box(bm, sx * 0.45, sx * 1.06, 0.88, 0.97, z_br - 0.035, z_br + 0.035,
                    IDX_TRIM, uv_layer, UV_REGIONS["trim_steel"], bevel_r=0.005, segs=1)

    for z_cm in (-3.45, -2.70, -2.10, -1.20, -0.20, +0.80, +2.10, +3.10):
        add_box(bm, -0.44, +0.44, 0.70, 0.86, z_cm - 0.04, z_cm + 0.04, IDX_TRIM, uv_layer, UV_REGIONS["trim_steel"], bevel_r=0.006, segs=1)

    add_cylinder(bm, 0.0, 0.42, +2.100, 0.055, 1.72, 'X', IDX_TRIM, uv_layer, UV_REGIONS["trim_steel"], segs=16, bevel_r=0.004)
    add_cylinder(bm, 0.0, 0.44, -2.100, 0.078, 1.56, 'X', IDX_TRIM, uv_layer, UV_REGIONS["trim_steel"], segs=18, bevel_r=0.004)
    add_cylinder(bm, 0.0, 0.44, -2.100, 0.195, 0.28, 'Z', IDX_TRIM, uv_layer, UV_REGIONS["trim_steel"], segs=18, bevel_r=0.012)
    add_cylinder(bm, 0.0, 0.52, -0.200, 0.045, 3.80, 'Z', IDX_TRIM, uv_layer, UV_REGIONS["trim_steel"], segs=12, bevel_r=0.002)
    for sx in (-1.0, +1.0):
        add_box(bm, sx * 0.48, sx * 0.56, 0.48, 0.56, +1.55, +2.65, IDX_TRIM, uv_layer, UV_REGIONS["trim_steel"], bevel_r=0.006, segs=1)
        add_box(bm, sx * 0.48, sx * 0.58, 0.50, 0.60, -2.70, -1.50, IDX_TRIM, uv_layer, UV_REGIONS["trim_steel"], bevel_r=0.006, segs=1)

    # Large Brushed-Metal D-Section Fuel Tank & Clamping Straps (visible between axles, Z = -0.95..+0.68 m)
    for sx in (-1.0, +1.0):
        add_box(bm, sx * 0.50, sx * 1.02, 0.42, 0.80, -0.95, +0.68, IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.055, segs=3)
        for zs in (-0.70, -0.14, +0.42):
            add_box(bm, sx * 0.49, sx * 1.032, 0.41, 0.81, zs - 0.022, zs + 0.022,
                    IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.004, segs=1)
        add_box(bm, sx * 0.54, sx * 1.06, 0.39, 0.82, +0.82, +1.36, IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.025, segs=2)
        add_box(bm, sx * 0.56, sx * 1.07, 0.60, 0.64, +0.83, +1.35, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.004, segs=1)

    add_cylinder(bm, +0.25, 0.52, +1.15, 0.11, 0.58, 'Z', IDX_TRIM, uv_layer, UV_REGIONS["trim_steel"], segs=16, bevel_r=0.008)
    add_cylinder(bm, +0.42, 0.38, +0.75, 0.038, 0.42, 'X', IDX_TRIM, uv_layer, UV_REGIONS["trim_steel"], segs=12, bevel_r=0.003)

    # Anodized Aluminium Side Underrun Protection Bars (Cyclist Guards, matching sheet.jpg!)
    for sx in (-1.0, +1.0):
        add_box(bm, sx * 1.090, sx * 1.135, 0.44, 0.515, -1.42, +1.32, IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.008, segs=2)
        add_box(bm, sx * 1.090, sx * 1.135, 0.64, 0.715, -1.42, +1.32, IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.008, segs=2)
        for z_st in (-1.30, -0.48, +0.34, +1.16):
            add_box(bm, sx * 1.045, sx * 1.115, 0.44, 0.95, z_st - 0.028, z_st + 0.028,
                    IDX_ALU, uv_layer, UV_REGIONS["alu_satin"], bevel_r=0.005, segs=1)

    # Rear Thermoplastic Half-Round Wheel Mudguards & Mudflaps over the Twin Rear Wheels (Z = -2.10 m)
    for sx in (-1.0, +1.0):
        add_open_wheel_arch_flare(
            bm, z_center=-2.100, y_center=0.440, r_in=0.475, r_out=0.525,
            x_in=sx * 0.58, x_out=sx * 1.145, mat_idx=IDX_TRIM, uv_layer=uv_layer, uv_rect=UV_REGIONS["trim_dark"], segs=22
        )
        add_box(bm, sx * 0.60, sx * 1.135, 0.16, 0.46, -2.635, -2.600, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.004, segs=1)
        add_box(bm, sx * 1.125, sx * 1.148, 0.90, 0.935, -1.62, -1.55, IDX_LAMPS, uv_layer, UV_REGIONS["lamp_amber"], bevel_r=0.003, segs=1)

    # Horizontal Spare Wheel Slung Under Rear Chassis Overhang (Z = -3.02 m, Y = 0.46 m)
    add_cylinder(bm, -0.22, 0.46, -3.02, 0.42, 0.22, 'Y', IDX_TRIM, uv_layer, UV_REGIONS["trim_rubber"], segs=24, bevel_r=0.025)
    add_cylinder(bm, -0.22, 0.46, -3.02, 0.26, 0.23, 'Y', IDX_TRIM, uv_layer, UV_REGIONS["trim_steel"], segs=18, bevel_r=0.008)
    add_box(bm, -0.68, +0.35, 0.33, 0.37, -3.08, -2.96, IDX_TRIM, uv_layer, UV_REGIONS["trim_steel"], bevel_r=0.005, segs=1)

    # Rear Underrun Protection Bumper & Tail-Light Clusters
    add_box(bm, -1.10, +1.10, 0.62, 0.76, -3.55, -3.46, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.010, segs=2)
    add_box(bm, -1.08, +1.08, 0.42, 0.51, -3.58, -3.50, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.010, segs=2)
    for sx in (-1.0, +1.0):
        add_box(bm, sx * 0.42, sx * 0.50, 0.44, 0.76, -3.55, -3.46, IDX_TRIM, uv_layer, UV_REGIONS["trim_steel"], bevel_r=0.006, segs=1)
        add_box(bm, sx * 0.88, sx * 0.96, 0.44, 0.76, -3.55, -3.46, IDX_TRIM, uv_layer, UV_REGIONS["trim_steel"], bevel_r=0.006, segs=1)
        f_tl = add_box(bm, sx * 0.64, sx * 1.04, 0.635, 0.745, -3.575, -3.535, IDX_LAMPS, uv_layer, UV_REGIONS["lamp_tail"], bevel_r=0.006, segs=2)
        assign_faces_planar_uv(
            bm, f_tl, IDX_LAMPS, uv_layer, UV_REGIONS["lamp_tail"],
            bounds_u=(min(sx * 0.64, sx * 1.04), max(sx * 0.64, sx * 1.04)), bounds_v=(0.635, 0.745),
            axis='Z', flip_u=(sx > 0)
        )

    # --------------------------------------------------------------------------
    # F. BELIEVABLE COMMERCIAL TRUCK CAB INTERIOR (VISIBLE THROUGH WINDSCREEN & SIDE GLASS)
    # --------------------------------------------------------------------------
    add_box(bm, -0.98, +0.98, 0.96, 1.04, +1.60, +3.36, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.010, segs=1)
    add_box(bm, -0.26, +0.26, 1.02, 1.24, +1.68, +3.10, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.025, segs=2)
    add_box(bm, -0.96, +0.96, 1.22, 1.54, +2.98, +3.42, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.028, segs=2)
    add_box(bm, +0.28, +0.78, 1.50, 1.64, +3.02, +3.32, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.020, segs=2)
    add_cylinder(bm, +0.540, 1.36, +3.04, 0.042, 0.34, 'Z', IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], segs=12, bevel_r=0.004)
    add_box(bm, -0.14, +0.14, 2.26, 2.34, +3.05, +3.12, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.006, segs=1)

    for sx_seat, w_seat in [(+0.54, 0.25), (-0.46, 0.36)]:
        add_box(bm, sx_seat - w_seat * 0.7, sx_seat + w_seat * 0.7, 1.02, 1.24, +2.02, +2.44, IDX_TRIM, uv_layer, UV_REGIONS["trim_dark"], bevel_r=0.012, segs=1)
        add_box(bm, sx_seat - w_seat, sx_seat + w_seat, 1.22, 1.36, +2.00, +2.54, IDX_TRIM, uv_layer, UV_REGIONS["trim_fabric"], bevel_r=0.025, segs=2)
        add_box(bm, sx_seat - w_seat * 0.94, sx_seat + w_seat * 0.94, 1.34, 1.98, +1.84, +2.06, IDX_TRIM, uv_layer, UV_REGIONS["trim_fabric"], bevel_r=0.028, segs=2)
        add_box(bm, sx_seat - 0.14, sx_seat + 0.14, 1.96, 2.18, +1.85, +2.03, IDX_TRIM, uv_layer, UV_REGIONS["trim_fabric"], bevel_r=0.022, segs=2)

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

BODY_OBJ = build_body_node()
print("Built Body mesh node.")

# ==============================================================================
# 13. TRIANGLE BUDGET TUNING (TARGET: 55,000 TO 105,000 TRIANGLES)
# ==============================================================================
ALL_MESH_OBJS = [BODY_OBJ, STEERING_OBJ] + WHEEL_OBJS + DOOR_OBJS

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

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

    if tris_base < 60000:
        for o in WHEEL_OBJS:
            mod = o.modifiers.new(name="Subsurf", type='SUBSURF')
            mod.subdivision_type = 'SIMPLE'
            mod.levels = 1
            mod.render_levels = 1
            bpy.context.view_layer.objects.active = o
            bpy.ops.object.modifier_apply(modifier=mod.name)

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

    if tris_mid < 56000:
        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 55k..105k): {tris_final}")

refine_for_budget()

# ==============================================================================
# 14. CALIBRATE EXACT BOUNDING BOX (7.20 m L, 2.40 m W, 3.30 m H, Body at (0,0,0), tyres on 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 = 2.400 / (max_x - min_x)
    sy = 3.300 / (max_y - min_y)
    sz = 7.200 / (max_z - min_z)
    cx = 0.5 * (min_x + max_x)
    cz = 0.5 * (min_z + max_z)

    for o in ALL_MESH_OBJS:
        if o == BODY_OBJ:
            # Keep Body origin at exact (0.0, 0.0, 0.0) and shift/scale its vertices directly!
            o.location = g2b(0.0, 0.0, 0.0)
            for v in o.data.vertices:
                gx, gy, gz = v.co.x, v.co.z, -v.co.y
                ngx = (gx - cx) * sx
                ngy = (gy - min_y) * sy
                ngz = (gz - cz) * sz
                v.co = g2b(ngx, ngy, ngz)
            o.data.update()
        else:
            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()

# ==============================================================================
# 15. CREATE EMPTY NODES (Seat_Driver, Door_L, Door_R, Exhaust, Light_Head_L/R, Light_Tail_L/R)
# ==============================================================================
EMPTY_DEFS = {
    "Seat_Driver":  (+0.540, 1.320, +2.250),
    "Door_L":       (+1.070, 1.480, +3.240),
    "Door_R":       (-1.070, 1.480, +3.240),
    "Exhaust":      (+0.580, 0.380, +0.750),
    "Light_Head_L": (+0.785, 0.675, +3.575),
    "Light_Head_R": (-0.785, 0.675, +3.575),
    "Light_Tail_L": (+0.840, 0.690, -3.575),
    "Light_Tail_R": (-0.840, 0.690, -3.575),
}

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)

# ==============================================================================
# 16. EXPORT GLB (box-truck.glb <= 2.0 MB) & WRITE NODES.JSON
# ==============================================================================
GLB_PATH = os.path.join(WORK_DIR, "box-truck.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

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 enforce_glb_paint_factor(filepath):
    with open(filepath, "rb") as f:
        data = f.read()
    magic, version, length = struct.unpack_from("<III", data, 0)
    chunk0_len, chunk0_type = struct.unpack_from("<II", data, 12)
    json_bytes = data[20:20 + chunk0_len]
    rest = data[20 + chunk0_len:]
    gltf = json.loads(json_bytes.decode("utf-8"))
    for mat in gltf.get("materials", []):
        if mat.get("name") == "Paint":
            pbr = mat.setdefault("pbrMetallicRoughness", {})
            pbr["baseColorFactor"] = [0.85, 0.86, 0.88, 1.0]
            ext = mat.setdefault("extensions", {})
            ext["KHR_materials_clearcoat"] = {"clearcoatFactor": 1.0, "clearcoatRoughnessFactor": 0.05}
            if "extensionsUsed" not in gltf:
                gltf["extensionsUsed"] = []
            if "KHR_materials_clearcoat" not in gltf["extensionsUsed"]:
                gltf["extensionsUsed"].append("KHR_materials_clearcoat")
    new_json = json.dumps(gltf, separators=(',', ':')).encode("utf-8")
    pad = (4 - (len(new_json) % 4)) % 4
    new_json += b" " * pad
    new_total = 12 + 8 + len(new_json) + len(rest)
    out = struct.pack("<III", magic, version, new_total) + struct.pack("<II", len(new_json), chunk0_type) + new_json + rest
    with open(filepath, "wb") as f:
        f.write(out)

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

PAINTS_LIST = [
    [0.85, 0.86, 0.88],  # Default: Fleet White (contract required first)
    [0.14, 0.22, 0.34],  # Eurocargo Slate Blue (matching sheet.jpg hero finish!)
    [0.68, 0.06, 0.06],  # Express Logistics Crimson Red
    [0.82, 0.56, 0.04],  # Autobahn Freight Amber-Yellow
    [0.06, 0.30, 0.16],  # Eco-Delivery Forest Green
    [0.82, 0.24, 0.03],  # Municipal Works Safety Orange
]

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,
        }

    bbox = {
        "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)],
    }
    data = {
        "asset": "box-truck",
        "contract": "Forge Garage v2",
        "coordinate_system": {"up": "+Y", "front": "+Z", "left": "+X"},
        "bounding_box": bbox,
        "bbox": bbox,
        "triangles": count_total_tris(),
        "materials": [m.name for m in MAT_LIST],
        "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:", json.dumps(bbox))

write_nodes_json()

# ==============================================================================
# 17. RENDER FINAL.PNG, SIDE.PNG, CLAY.PNG & PAINTS.PNG
# ==============================================================================
def setup_studio_environment():
    bpy.ops.mesh.primitive_plane_add(size=80.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.72, 0.73, 0.74, 1.0)
    bsdf_g.inputs['Roughness'].default_value = 0.65
    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.76, 0.78, 0.80, 1.0)
    bg.inputs['Strength'].default_value = 1.05

    lights = []
    for lname, loc_g, energy, size_x, size_y, col in [
        ("_KeyLight",  (+7.2, 7.6, +7.4), 1350.0, 6.5, 4.0, (1.00, 0.98, 0.95)),
        ("_FillLight", (-7.4, 5.2, +5.4),  680.0, 6.0, 4.0, (0.93, 0.96, 1.00)),
        ("_RimLight",  (-3.2, 7.2, -7.6),  880.0, 6.5, 3.5, (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, 1.55, 0.0) - lobj.location
        lobj.rotation_euler = direction.to_track_quat('-Z', 'Y').to_euler()
        lights.append(lobj)

    return ground, lights

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

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

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

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

    # Use Eurocargo Slate Blue ([0.14, 0.22, 0.34]) for final.png & side.png to match sheet.jpg hero finish!
    set_paint_factor([0.14, 0.22, 0.34])

    # Hero 3/4 front-left view matching sheet.jpg angle, framing entire 7.20m truck with generous studio margins!
    hero_cam_loc = (+7.60, 2.50, +8.10)
    hero_target  = (-0.15, 1.50, +0.05)

    # 1. final.png (1920x1080 Cycles render, 3/4 front view, soft daylight)
    scene.render.image_settings.file_format = 'PNG'
    scene.render.resolution_x = 1920
    scene.render.resolution_y = 1080
    scene.render.resolution_percentage = 100
    scene.cycles.samples = 64
    cam_data.type = 'PERSP'
    cam_data.lens = 46.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.54, 0.55, 0.56, 1.0)
    bsdf_c.inputs['Roughness'].default_value = 0.48
    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 = 8.40
    aim_camera(cam_obj, (+10.0, 1.65, 0.00), (0.00, 1.65, 0.00))
    scene.render.filepath = os.path.join(WORK_DIR, "side.png")
    bpy.ops.render.render(write_still=True)
    print("Rendered side.png")

    # 4. paints.png (3x2 grid of the same 3/4 view in the six paints from nodes.json)
    cam_data.type = 'PERSP'
    cam_data.lens = 46.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.")
