#!/usr/bin/env python3
"""
Build script for Modern European 7.5-Tonne Cab-Over Flatbed / Drop-Side Utility Truck (DAF LF style).
Forge Garage Contract v2 compliant:
- Real dimensions: 7.10 m long (+Z front), 2.38 m wide (+X left), 2.55 m high (+Y up), wheelbase 4.15 m.
- Origin (0,0,0) on ground plane midway between front (+2.075 m) and rearmost (-2.075 m) axles; tyres touch y = 0.
- Separate named mesh nodes:
  Body, Wheel_FL, Wheel_FR, Wheel_RL, Wheel_RR, SteeringWheel, Door_FL, Door_FR.
- Empty nodes (no mesh):
  Seat_Driver, Door_L, Door_R, Exhaust, Light_Head_L, Light_Head_R, Light_Tail_L, Light_Tail_R.
- Materials (8 total, <= 8 budget):
  Paint (baseColorFactor=[0.12, 0.28, 0.52], neutral near-white baked grime/seam detail, clearcoat 1.0),
  Livery (cream upper cab two-tone panel, satin silver front grille bar, plates & reflectors),
  Timber (weathered horizontal wooden planks on flatbed floor, upper drop-sides, tailgate & headache rack),
  Aluminium (anodized ribbed drop-side panels, stake posts, latches, side guard bars, wheel rims, steps),
  Chassis (black steel chassis rails, crossmembers, toolbox, fuel tank, axles, suspension, grille mesh, interior),
  Rubber (commercial steer & dual rear tyres with 3D tread lugs, mudflaps, window gaskets),
  Glass (alpha BLEND, tinted, cab interior visible),
  Lamps (emissive warm-white headlights, red tail/brake lights, amber indicators & side markers).
- Poly Haven CC0 textures: weathered_planks, metal_plate, rusty_metal_02, rubber_tiles.
"""

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

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

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

def download_textures():
    for ph_id in POLYHAVEN_IDS:
        for map_type in ["diff", "nor_gl", "arm"]:
            fname = f"{ph_id}_{map_type}_1k.jpg"
            fpath = os.path.join(TEX_DIR, fname)
            if not os.path.exists(fpath) or os.path.getsize(fpath) < 1000:
                url = f"https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{ph_id}/{fname}"
                print(f"Downloading {url} -> {fpath}")
                req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
                with urllib.request.urlopen(req, timeout=30) as resp, open(fpath, "wb") as out:
                    out.write(resp.read())

download_textures()

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

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

setup_cycles_gpu()

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

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

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

def build_source_maps():
    # Rotate weathered_planks 90 deg so wood grain & plank seams run horizontally along U (x-axis)
    wp_diff = np.rot90(load_ph_np("weathered_planks", "diff", (1024, 1024)), 1)
    wp_nor_raw = np.rot90(load_ph_np("weathered_planks", "nor_gl", (1024, 1024)), 1)
    wp_nor = wp_nor_raw.copy()
    wp_nor[:, :, 0] = wp_nor_raw[:, :, 1]
    wp_nor[:, :, 1] = 1.0 - wp_nor_raw[:, :, 0]
    wp_arm  = np.rot90(load_ph_np("weathered_planks", "arm", (1024, 1024)), 1)

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

    rm_diff = load_ph_np("rusty_metal_02", "diff", (1024, 1024))
    rm_nor  = load_ph_np("rusty_metal_02", "nor_gl", (1024, 1024))
    rm_arm  = load_ph_np("rusty_metal_02", "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))

    yy, xx = np.mgrid[0:1024, 0:1024] / 1024.0
    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 maps (near-white neutral detail with subtle grey grime, panel seams, dust & wear) ---
    mp_lum = np.mean(mp_diff, axis=2, keepdims=True)
    rm_lum = np.mean(rm_diff, axis=2, keepdims=True)
    lower_dust = np.clip((1.0 - yy[:, :, None]) ** 2.4 * 0.08, 0.0, 0.08)
    paint_val = 0.96 - lower_dust * (0.5 + 0.5 * (1.0 - rm_lum)) - 0.03 * (1.0 - mp_lum)
    paint_base = np.repeat(np.clip(paint_val, 0.84, 0.98), 3, axis=2)
    save_np_jpg(r512(paint_base), os.path.join(TEX_DIR, "src_paint_base.jpg"), quality=86)

    paint_nor = flat_nor * 0.90 + mp_nor * 0.10
    save_np_jpg(r512(paint_nor), os.path.join(TEX_DIR, "src_paint_nor.jpg"), quality=85)

    paint_ao = np.clip(0.94 + 0.06 * mp_arm[:, :, 0], 0.80, 1.0)
    paint_rough = np.clip(0.14 + 0.08 * mp_arm[:, :, 1] + lower_dust[:, :, 0] * 1.2, 0.08, 0.38)
    paint_metal = np.full((1024, 1024), 0.10, 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. Livery source maps (Cream two-tone cab upper panel + brushed silver grille header + EU plates) ---
    liv_base = np.zeros((512, 512, 3), dtype=np.float32)
    liv_orm  = np.zeros((512, 512, 3), dtype=np.float32)
    mp_d5 = r512(mp_diff)
    mp_a5 = r512(mp_arm)
    rm_d5 = r512(rm_diff)

    # Top half (y=0..256): Warm cream/ivory painted cab upper panel (matching sheet.jpg upper cab!)
    cream_rgb = np.array([0.88, 0.84, 0.72], dtype=np.float32)[None, None, :]
    cream_variation = 0.95 + 0.06 * np.mean(mp_d5, axis=2, keepdims=True) - 0.03 * (1.0 - np.mean(rm_d5, axis=2, keepdims=True))
    liv_base[0:256, :] = np.clip(cream_rgb * cream_variation[0:256, :], 0.70, 0.96)
    liv_orm[0:256, :, 0] = 0.94 + 0.06 * mp_a5[0:256, :, 0]
    liv_orm[0:256, :, 1] = 0.16 + 0.08 * mp_a5[0:256, :, 1]
    liv_orm[0:256, :, 2] = 0.05

    # Bottom-left (y=256..512, x=0..256): Brushed satin silver front grille header bar
    silver_rgb = np.array([0.78, 0.80, 0.82], dtype=np.float32)[None, None, :]
    liv_base[256:512, 0:256] = np.clip(silver_rgb * (0.90 + 0.14 * mp_d5[256:512, 0:256]), 0.60, 0.96)
    liv_orm[256:512, 0:256, 0] = 0.95
    liv_orm[256:512, 0:256, 1] = 0.20 + 0.08 * mp_a5[256:512, 0:256, 1]
    liv_orm[256:512, 0:256, 2] = 0.85

    # Bottom-right (y=256..512, x=256..512): EU license plate & orange reflector trim
    plate_img = Image.new("RGB", (256, 256), (30, 32, 36))
    pdraw = ImageDraw.Draw(plate_img)
    pdraw.rectangle([12, 24, 244, 116], fill=(235, 238, 242), outline=(25, 28, 32), width=4)
    pdraw.rectangle([16, 28, 46, 112], fill=(18, 52, 148))
    for bx in [62, 86, 122, 146, 178, 202]:
        pdraw.rectangle([bx, 46, bx + 16, 94], fill=(24, 26, 30))
    pdraw.rectangle([16, 148, 240, 236], fill=(225, 118, 16), outline=(120, 58, 8), width=4)
    liv_base[256:512, 256:512] = np.asarray(plate_img, dtype=np.float32) / 255.0
    liv_orm[256:512, 256:512, 0] = 0.92
    liv_orm[256:512, 256:512, 1] = 0.26
    liv_orm[256:512, 256:512, 2] = 0.10

    save_np_jpg(liv_base, os.path.join(TEX_DIR, "src_livery_base.jpg"), quality=86)
    save_np_jpg(liv_orm,  os.path.join(TEX_DIR, "src_livery_orm.jpg"), quality=84)

    # --- 3. Timber source maps (Horizontal weathered wood planks matching sheet.jpg Weathered Timber swatch) ---
    plank_bands = np.floor(yy * 6.0)
    plank_tone = 0.94 + 0.10 * (np.sin(plank_bands * 2.3 + 0.4) * 0.5 + 0.5)[:, :, None]
    seam_line = (np.abs(((yy * 6.0) % 1.0) - 0.5) > 0.475).astype(np.float32)[:, :, None]
    grain_streak = 0.96 + 0.06 * np.sin(yy * 2.0 * math.pi * 48.0 + wp_diff[:, :, 0] * 3.0)[:, :, None]

    timber_col = np.zeros_like(wp_diff)
    timber_col[:, :, 0] = (wp_diff[:, :, 0] * 0.78 + 0.14) * plank_tone[:, :, 0] * grain_streak[:, :, 0]
    timber_col[:, :, 1] = (wp_diff[:, :, 1] * 0.76 + 0.12) * plank_tone[:, :, 0] * grain_streak[:, :, 0]
    timber_col[:, :, 2] = (wp_diff[:, :, 2] * 0.72 + 0.09) * plank_tone[:, :, 0] * grain_streak[:, :, 0]
    timber_col = timber_col * (1.0 - seam_line * 0.45)
    save_np_jpg(np.clip(timber_col, 0.10, 0.88), os.path.join(TEX_DIR, "src_timber_base.jpg"), quality=86)

    save_np_jpg(wp_nor, os.path.join(TEX_DIR, "src_timber_nor.jpg"), quality=85)

    timber_ao = np.clip(wp_arm[:, :, 0] * (1.0 - seam_line[:, :, 0] * 0.45), 0.35, 1.0)
    timber_rough = np.clip(0.72 + 0.22 * wp_arm[:, :, 1], 0.55, 0.96)
    timber_metal = np.full((1024, 1024), 0.02, dtype=np.float32)
    timber_orm = np.stack([timber_ao, timber_rough, timber_metal], axis=2)
    save_np_jpg(r512(timber_orm), os.path.join(TEX_DIR, "src_timber_orm.jpg"), quality=84)

    # --- 4. Aluminium source maps (Anodized silver extruded drop-sides, stake posts, side bars, steel wheel rims) ---
    rib_wave = 0.5 + 0.5 * np.cos(yy * 2.0 * math.pi * 16.0)
    brush_x = 0.5 + 0.5 * np.sin(xx * 2.0 * math.pi * 90.0 + mp_diff[:, :, 0] * 4.0)
    alu_val = 0.72 + 0.16 * mp_diff + 0.03 * rib_wave[:, :, None] + 0.015 * brush_x[:, :, None] - 0.04 * (1.0 - rm_lum)
    alu_col = np.zeros_like(mp_diff)
    alu_col[:, :, 0] = alu_val[:, :, 0] * 0.98
    alu_col[:, :, 1] = alu_val[:, :, 1] * 0.99
    alu_col[:, :, 2] = alu_val[:, :, 2] * 1.01
    save_np_jpg(np.clip(alu_col, 0.42, 0.96), os.path.join(TEX_DIR, "src_aluminium_base.jpg"), quality=86)

    alu_nor = flat_nor * 0.60 + mp_nor * 0.40
    rib_slope = np.sin(yy * 2.0 * math.pi * 16.0) * 0.08
    alu_nor[:, :, 1] = np.clip(alu_nor[:, :, 1] + rib_slope, 0.0, 1.0)
    save_np_jpg(alu_nor, os.path.join(TEX_DIR, "src_aluminium_nor.jpg"), quality=85)

    alu_ao = np.clip(0.86 + 0.14 * mp_arm[:, :, 0] - (1.0 - rib_wave) * 0.06, 0.60, 1.0)
    alu_rough = np.clip(0.28 + 0.16 * mp_arm[:, :, 1], 0.20, 0.48)
    alu_metal = np.full((1024, 1024), 0.78, dtype=np.float32)
    alu_orm = np.stack([alu_ao, alu_rough, alu_metal], axis=2)
    save_np_jpg(r512(alu_orm), os.path.join(TEX_DIR, "src_aluminium_orm.jpg"), quality=84)

    # --- 5. Chassis source maps (Black steel frame rails, fuel tank, toolbox, dark grey fender plastic & grille) ---
    ch_base = np.zeros((1024, 1024, 3), dtype=np.float32)
    ch_nor  = np.zeros((1024, 1024, 3), dtype=np.float32)
    ch_orm  = np.zeros((1024, 1024, 3), dtype=np.float32)

    rust_mask = np.clip((rm_diff[:, :, 0] - rm_diff[:, :, 2]) * 1.6, 0.0, 1.0)[:, :, None]
    steel_black = (0.032 + 0.045 * mp_diff) * (1.0 - rust_mask * 0.28) + rust_mask * np.array([0.11, 0.06, 0.04])[None, None, :]
    ch_base[0:512, :] = np.clip(steel_black[0:512, :], 0.018, 0.22)
    ch_nor[0:512, :]  = mp_nor[0:512, :] * 0.55 + rm_nor[0:512, :] * 0.45
    ch_orm[0:512, :, 0] = np.clip(0.76 + 0.24 * rm_arm[0:512, :, 0], 0.48, 1.0)
    ch_orm[0:512, :, 1] = np.clip(0.36 + 0.26 * rm_arm[0:512, :, 1], 0.24, 0.75)
    ch_orm[0:512, :, 2] = 0.55

    slat_wave = 0.5 + 0.5 * np.cos(yy[512:1024, :] * 2.0 * math.pi * 24.0)[:, :, None]
    plastic_slate = np.zeros((512, 1024, 3), dtype=np.float32)
    plastic_slate[:, :, 0] = 0.095 + 0.055 * mp_diff[512:1024, :, 0] + 0.015 * slat_wave[:, :, 0]
    plastic_slate[:, :, 1] = 0.105 + 0.058 * mp_diff[512:1024, :, 1] + 0.015 * slat_wave[:, :, 0]
    plastic_slate[:, :, 2] = 0.120 + 0.062 * mp_diff[512:1024, :, 2] + 0.015 * slat_wave[:, :, 0]
    ch_base[512:1024, :] = np.clip(plastic_slate, 0.04, 0.26)
    ch_nor[512:1024, :]  = flat_nor[512:1024, :] * 0.68 + mp_nor[512:1024, :] * 0.32
    ch_orm[512:1024, :, 0] = np.clip(0.84 + 0.16 * mp_arm[512:1024, :, 0], 0.58, 1.0)
    ch_orm[512:1024, :, 1] = np.clip(0.45 + 0.16 * mp_arm[512:1024, :, 1], 0.34, 0.70)
    ch_orm[512:1024, :, 2] = 0.06

    save_np_jpg(ch_base, os.path.join(TEX_DIR, "src_chassis_base.jpg"), quality=85)
    save_np_jpg(ch_nor,  os.path.join(TEX_DIR, "src_chassis_nor.jpg"), quality=85)
    save_np_jpg(r512(ch_orm), os.path.join(TEX_DIR, "src_chassis_orm.jpg"), quality=84)

    # --- 6. Rubber source maps (Commercial truck tyre tread & sidewall from rubber_tiles) ---
    rt_d5 = r512(rt_diff)
    rt_n5 = r512(rt_nor)
    rt_a5 = r512(rt_arm)
    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 * 36.0 + np.sign(tx - 0.5) * tx * 6.0) * 2.0 * math.pi)
    tread_mask = np.clip(tread_ribs * 0.65 + tread_lugs * 0.35, 0.0, 1.0)[:, :, None]
    rub_base = (0.042 + 0.050 * rt_d5) * (0.76 + 0.24 * tread_mask)
    save_np_jpg(np.clip(rub_base, 0.02, 0.16), os.path.join(TEX_DIR, "src_rubber_base.jpg"), quality=85)

    rub_nor = rt_n5.copy()
    rub_nor[:, :, 0] = np.clip(rub_nor[:, :, 0] + np.sin(tx * 2.0 * math.pi * 10.0) * 0.16, 0.0, 1.0)
    rub_nor[:, :, 1] = np.clip(rub_nor[:, :, 1] + np.cos(ty * 2.0 * math.pi * 36.0) * 0.12, 0.0, 1.0)
    save_np_jpg(rub_nor, os.path.join(TEX_DIR, "src_rubber_nor.jpg"), quality=85)

    rub_orm = np.zeros((512, 512, 3), dtype=np.float32)
    rub_orm[:, :, 0] = np.clip(0.68 + 0.30 * rt_a5[:, :, 0] * tread_mask[:, :, 0], 0.40, 1.0)
    rub_orm[:, :, 1] = np.clip(0.68 + 0.18 * rt_a5[:, :, 1], 0.50, 0.92)
    rub_orm[:, :, 2] = 0.02
    save_np_jpg(rub_orm, os.path.join(TEX_DIR, "src_rubber_orm.jpg"), quality=84)

    # --- 7. Lamps source map (512x512): Headlights, Taillights, Amber Side Markers ---
    lamp_img = Image.new("RGB", (512, 512), (24, 26, 30))
    ldraw = ImageDraw.Draw(lamp_img)

    ldraw.rectangle([0, 0, 512, 256], fill=(175, 182, 192))
    ldraw.rectangle([14, 18, 498, 238], fill=(215, 222, 230), outline=(95, 102, 112), width=6)
    ldraw.ellipse([34, 34, 224, 222], fill=(255, 250, 236), outline=(170, 178, 190), width=8)
    ldraw.ellipse([96, 94, 162, 162], fill=(255, 255, 248), outline=(140, 148, 160), width=4)
    ldraw.ellipse([236, 44, 396, 212], fill=(252, 246, 230), outline=(170, 178, 190), width=7)
    ldraw.ellipse([288, 96, 344, 158], fill=(255, 252, 242), outline=(140, 148, 160), width=4)
    ldraw.rectangle([410, 38, 484, 218], fill=(255, 190, 75), outline=(165, 120, 40), width=5)

    ldraw.rectangle([0, 256, 256, 512], fill=(35, 12, 14))
    ldraw.rectangle([12, 272, 152, 496], fill=(235, 22, 26), outline=(120, 12, 16), width=5)
    ldraw.rectangle([156, 272, 206, 496], fill=(255, 148, 18), outline=(140, 75, 10), width=4)
    ldraw.rectangle([210, 272, 246, 496], fill=(242, 245, 250), outline=(150, 155, 165), width=4)

    ldraw.rectangle([256, 256, 512, 512], fill=(180, 92, 10))
    ldraw.rectangle([276, 286, 492, 482], fill=(255, 158, 18), outline=(210, 110, 12), width=6)
    ldraw.ellipse([316, 326, 452, 442], fill=(255, 196, 55))

    lamp_blur = lamp_img.filter(ImageFilter.GaussianBlur(radius=1.2))
    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.")

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

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

    bake_jobs = [
        ("paint",     "src_paint_base.jpg",     "src_paint_nor.jpg",     "src_paint_orm.jpg",     512,  512, 0.02),
        ("livery",    "src_livery_base.jpg",    None,                    "src_livery_orm.jpg",    512,  512, 0.00),
        ("timber",    "src_timber_base.jpg",    "src_timber_nor.jpg",    "src_timber_orm.jpg",    1024, 512, 0.08),
        ("aluminium", "src_aluminium_base.jpg", "src_aluminium_nor.jpg", "src_aluminium_orm.jpg", 1024, 512, 0.05),
        ("chassis",   "src_chassis_base.jpg",   "src_chassis_nor.jpg",   "src_chassis_orm.jpg",   1024, 512, 0.06),
        ("rubber",    "src_rubber_base.jpg",    "src_rubber_nor.jpg",    "src_rubber_orm.jpg",    512,  512, 0.07),
        ("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 = 42.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.035 if prefix not in ('lamps', 'paint') else 0.015
        links.new(tex_base.outputs['Color'], mix_col.inputs['A'])
        links.new(noise_node.outputs['Color'], mix_col.inputs['B'])

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

bake_all_materials()

# ---------------------------------------------------------------------------
# 5. CREATE FINAL GLTF-READY PBR MATERIALS (8 MATERIALS <= 8 BUDGET)
# ---------------------------------------------------------------------------
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')

    def make_standard_pbr(mat_name, prefix, nor_strength=0.65, coat_weight=0.0, coat_rough=0.05):
        mat = bpy.data.materials.new(name=mat_name)
        mat.use_nodes = True
        nt = mat.node_tree
        nt.nodes.clear()
        out = nt.nodes.new('ShaderNodeOutputMaterial')
        bsdf = nt.nodes.new('ShaderNodeBsdfPrincipled')
        nt.links.new(bsdf.outputs['BSDF'], out.inputs['Surface'])

        tex_base = nt.nodes.new('ShaderNodeTexImage')
        tex_base.image = load_img(os.path.join(BAKE_DIR, f"{prefix}_base.jpg"), 'sRGB')
        nt.links.new(tex_base.outputs['Color'], bsdf.inputs['Base Color'])

        orm_path = os.path.join(BAKE_DIR, f"{prefix}_orm.jpg")
        if os.path.exists(orm_path):
            tex_orm = nt.nodes.new('ShaderNodeTexImage')
            tex_orm.image = load_img(orm_path, 'Non-Color')
            sep = nt.nodes.new('ShaderNodeSeparateColor')
            nt.links.new(tex_orm.outputs['Color'], sep.inputs['Color'])
            nt.links.new(sep.outputs['Green'], bsdf.inputs['Roughness'])
            nt.links.new(sep.outputs['Blue'], bsdf.inputs['Metallic'])
            occ_node = nt.nodes.new('ShaderNodeGroup')
            occ_node.node_tree = gltf_occ_group
            nt.links.new(sep.outputs['Red'], occ_node.inputs['Occlusion'])

        nor_path = os.path.join(BAKE_DIR, f"{prefix}_nor.jpg")
        if os.path.exists(nor_path):
            tex_nor = nt.nodes.new('ShaderNodeTexImage')
            tex_nor.image = load_img(nor_path, 'Non-Color')
            nmap = nt.nodes.new('ShaderNodeNormalMap')
            nmap.inputs['Strength'].default_value = nor_strength
            nt.links.new(tex_nor.outputs['Color'], nmap.inputs['Color'])
            nt.links.new(nmap.outputs['Normal'], bsdf.inputs['Normal'])

        if coat_weight > 0.0:
            if 'Coat Weight' in bsdf.inputs:
                bsdf.inputs['Coat Weight'].default_value = coat_weight
                bsdf.inputs['Coat Roughness'].default_value = coat_rough
            elif 'Clearcoat' in bsdf.inputs:
                bsdf.inputs['Clearcoat'].default_value = coat_weight
                bsdf.inputs['Clearcoat Roughness'].default_value = coat_rough
        return mat

    # --- 1. Paint (Recolourable cab shell, baseColorFactor=[0.12, 0.28, 0.52], clearcoat=1.0) ---
    mat_paint = bpy.data.materials.new(name="Paint")
    mat_paint.use_nodes = True
    nt = mat_paint.node_tree
    nt.nodes.clear()
    out = nt.nodes.new('ShaderNodeOutputMaterial')
    bsdf = nt.nodes.new('ShaderNodeBsdfPrincipled')
    nt.links.new(bsdf.outputs['BSDF'], out.inputs['Surface'])

    tex_p_base = nt.nodes.new('ShaderNodeTexImage')
    tex_p_base.image = load_img(os.path.join(BAKE_DIR, "paint_base.jpg"), 'sRGB')
    mix_p = nt.nodes.new('ShaderNodeMix')
    mix_p.data_type = 'RGBA'
    mix_p.blend_type = 'MULTIPLY'
    mix_p.inputs['Factor'].default_value = 1.0
    mix_p.inputs['B'].default_value = (0.12, 0.28, 0.52, 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_node_p = nt.nodes.new('ShaderNodeGroup')
    occ_node_p.node_tree = gltf_occ_group
    nt.links.new(sep_p.outputs['Red'], occ_node_p.inputs['Occlusion'])

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

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

    # --- 2. Livery (Cream upper cab two-tone panel, brushed silver grille header, plates) ---
    mats["Livery"] = make_standard_pbr("Livery", "livery", nor_strength=0.0, coat_weight=0.85, coat_rough=0.06)

    # --- 3. Timber (Weathered wooden planks on deck floor, upper drop-sides, tailgate & headache rack) ---
    mats["Timber"] = make_standard_pbr("Timber", "timber", nor_strength=0.95, coat_weight=0.0)

    # --- 4. Aluminium (Anodized extruded drop-sides, stake posts, latches, side guard bars, rims) ---
    mats["Aluminium"] = make_standard_pbr("Aluminium", "aluminium", nor_strength=0.65, coat_weight=0.15, coat_rough=0.18)

    # --- 5. Chassis (Black steel frame rails, toolbox, fuel tank, axles, dark grille & fender trim) ---
    mats["Chassis"] = make_standard_pbr("Chassis", "chassis", nor_strength=0.55, coat_weight=0.0)

    # --- 6. Rubber (Tyres, mudflaps, window weatherstrip seals) ---
    mats["Rubber"] = make_standard_pbr("Rubber", "rubber", nor_strength=0.80, coat_weight=0.0)

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

    # --- 8. Lamps (Emissive headlights, taillights, amber indicators & side markers) ---
    mat_lamps = bpy.data.materials.new(name="Lamps")
    mat_lamps.use_nodes = True
    nt = mat_lamps.node_tree
    nt.nodes.clear()
    out = nt.nodes.new('ShaderNodeOutputMaterial')
    bsdf = nt.nodes.new('ShaderNodeBsdfPrincipled')
    nt.links.new(bsdf.outputs['BSDF'], out.inputs['Surface'])

    tex_e_base = nt.nodes.new('ShaderNodeTexImage')
    tex_e_base.image = load_img(os.path.join(BAKE_DIR, "lamps_base.jpg"), 'sRGB')
    nt.links.new(tex_e_base.outputs['Color'], bsdf.inputs['Base Color'])
    nt.links.new(tex_e_base.outputs['Color'], bsdf.inputs['Emission Color'])
    bsdf.inputs['Emission Strength'].default_value = 1.4
    bsdf.inputs['Roughness'].default_value = 0.06
    if 'Coat Weight' in bsdf.inputs:
        bsdf.inputs['Coat Weight'].default_value = 1.0
        bsdf.inputs['Coat Roughness'].default_value = 0.03
    mats["Lamps"] = mat_lamps

    return mats

MATS = build_gltf_materials()

# ---------------------------------------------------------------------------
# 6. COORDINATE & BMesh GEOMETRY BUILDER HELPERS
# ---------------------------------------------------------------------------
def g2b(x, y, z):
    """Convert glTF (X=left, Y=up, Z=front) to Blender (X=left, Y=-front, Z=up)."""
    return Vector((float(x), -float(z), float(y)))

def b2g_vec(v):
    """Convert Blender Vector to glTF (x, y, z) tuple."""
    return (round(v.x, 4), round(v.z, 4), round(-v.y, 4))

UV_REGIONS = {
    "full":          (0.04, 0.96, 0.04, 0.96),
    "livery_cream":  (0.05, 0.95, 0.55, 0.95),
    "livery_silver": (0.05, 0.45, 0.05, 0.45),
    "livery_plate":  (0.54, 0.96, 0.24, 0.44),
    "livery_refl":   (0.54, 0.96, 0.04, 0.18),
    "chassis_steel": (0.04, 0.96, 0.54, 0.96),
    "chassis_plast": (0.04, 0.96, 0.04, 0.46),
    "lamps_head":    (0.04, 0.96, 0.54, 0.96),
    "lamps_tail":    (0.04, 0.46, 0.04, 0.46),
    "lamps_amber":   (0.54, 0.96, 0.04, 0.46),
}

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

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

def apply_planar_uv(bm, faces, mat_idx, uv_layer, uv_region, axis='Z', flip_u=False, smooth=True):
    u0, u1, v0, v1 = uv_region
    valid_faces = [f for f in faces if f.is_valid]
    us, vs = [], []
    for f in valid_faces:
        f.material_index = mat_idx
        f.smooth = smooth
        for loop in f.loops:
            gx, gy, gz = loop.vert.co.x, loop.vert.co.z, -loop.vert.co.y
            if axis == 'Z':
                u, v = gx, gy
            elif axis == 'X':
                u, v = gz, gy
            else:
                u, v = gx, gz
            us.append(u)
            vs.append(v)
    if not us:
        return
    min_u, max_u = min(us), max(us)
    min_v, max_v = min(vs), max(vs)
    span_u = max(max_u - min_u, 1e-5)
    span_v = max(max_v - min_v, 1e-5)
    for f in valid_faces:
        for loop in f.loops:
            gx, gy, gz = loop.vert.co.x, loop.vert.co.z, -loop.vert.co.y
            if axis == 'Z':
                ru, rv = (gx - min_u) / span_u, (gy - min_v) / span_v
            elif axis == 'X':
                ru, rv = (gz - min_u) / span_u, (gy - min_v) / span_v
            else:
                ru, rv = (gx - min_u) / span_u, (gz - min_v) / span_v
            if flip_u:
                ru = 1.0 - ru
            loop[uv_layer].uv = (u0 + ru * (u1 - u0), v0 + rv * (v1 - v0))

class PartBuilder:
    def __init__(self, name, origin_gltf=(0.0, 0.0, 0.0)):
        self.name = name
        self.origin_gltf = origin_gltf
        self.origin_b = g2b(*origin_gltf)
        self.mesh = bpy.data.meshes.new(name)
        self.obj = bpy.data.objects.new(name, self.mesh)
        self.obj.location = self.origin_b
        bpy.context.collection.objects.link(self.obj)
        self.bm = bmesh.new()
        self.uv_layer = self.bm.loops.layers.uv.new("UVMap")

    def _to_local(self, x, y, z):
        return g2b(x, y, z) - self.origin_b

    def mat_idx(self, mat_name):
        return ensure_mat_slot(self.obj, mat_name)

    def add_bevel_box(self, x0, x1, y0, y1, z0, z1, mat_name, bevel=0.012, segs=2, uv_reg="full", scale_uv=0.45, smooth=True):
        prev_faces = set(self.bm.faces)
        cx, cy, cz = 0.5 * (x0 + x1), 0.5 * (y0 + y1), 0.5 * (z0 + z1)
        sx, sy, sz = abs(x1 - x0), abs(y1 - y0), abs(z1 - z0)
        mat_xform = Matrix.Translation(self._to_local(cx, cy, cz)) @ Matrix.Diagonal((sx, sz, sy, 1.0))
        ret = bmesh.ops.create_cube(self.bm, size=1.0, matrix=mat_xform)
        verts = ret['verts']
        cube_faces = list({f for v in verts for f in v.link_faces})
        if bevel > 1e-4 and segs > 0:
            edges = list({e for f in cube_faces for e in f.edges})
            bmesh.ops.bevel(
                self.bm,
                geom=edges,
                offset=min(bevel, 0.35 * min(sx, sy, sz)),
                offset_type='OFFSET',
                segments=segs,
                profile=0.5,
                affect='EDGES',
            )
        faces = [f for f in self.bm.faces if f not in prev_faces and f.is_valid]
        bmesh.ops.recalc_face_normals(self.bm, faces=faces)
        apply_box_uv(self.bm, faces, self.mat_idx(mat_name), self.uv_layer, UV_REGIONS.get(uv_reg, UV_REGIONS["full"]), scale_uv, smooth=smooth)
        return faces

    def add_cylinder(self, p0_g, p1_g, rad0, rad1, segments, mat_name, cap_ends=True, bevel=0.005, uv_reg="full", scale_uv=0.5, smooth=True):
        prev_faces = set(self.bm.faces)
        v0 = self._to_local(*p0_g)
        v1 = self._to_local(*p1_g)
        delta = v1 - v0
        length = delta.length
        if length < 1e-5:
            return []
        mid = 0.5 * (v0 + v1)
        rot = delta.to_track_quat('Z', 'Y').to_matrix().to_4x4()
        mat_xform = Matrix.Translation(mid) @ rot
        ret = bmesh.ops.create_cone(
            self.bm,
            cap_ends=cap_ends,
            cap_tris=False,
            segments=segments,
            radius1=rad0,
            radius2=rad1,
            depth=length,
            matrix=mat_xform,
        )
        verts = ret['verts']
        cone_faces = list({f for v in verts for f in v.link_faces})
        if bevel > 1e-4 and cap_ends:
            cap_edges = [e for f in cone_faces if abs(f.normal.dot(delta.normalized())) > 0.85 for e in f.edges]
            if cap_edges:
                bmesh.ops.bevel(
                    self.bm,
                    geom=list(set(cap_edges)),
                    offset=min(bevel, 0.25 * min(rad0, rad1, length)),
                    offset_type='OFFSET',
                    segments=2,
                    profile=0.5,
                    affect='EDGES',
                )
        faces = [f for f in self.bm.faces if f not in prev_faces and f.is_valid]
        bmesh.ops.recalc_face_normals(self.bm, faces=faces)
        apply_box_uv(self.bm, faces, self.mat_idx(mat_name), self.uv_layer, UV_REGIONS.get(uv_reg, UV_REGIONS["full"]), scale_uv, smooth=smooth)
        return faces

    def add_loft(self, rings_gltf, mat_name, cap_start=True, cap_end=True, uv_reg="full", scale_uv=0.4, smooth=True, planar_axis=None, flip_u=False):
        ring_verts = []
        for r in rings_gltf:
            rv = [self.bm.verts.new(self._to_local(*pt)) for pt in r]
            ring_verts.append(rv)
        faces = []
        n_pts = len(ring_verts[0])
        for i in range(len(ring_verts) - 1):
            r0 = ring_verts[i]
            r1 = ring_verts[i + 1]
            for j in range(n_pts):
                jn = (j + 1) % n_pts
                try:
                    f = self.bm.faces.new([r0[j], r0[jn], r1[jn], r1[j]])
                    faces.append(f)
                except ValueError:
                    pass
        if cap_start and n_pts >= 3:
            try:
                faces.append(self.bm.faces.new(list(reversed(ring_verts[0]))))
            except ValueError:
                pass
        if cap_end and n_pts >= 3:
            try:
                faces.append(self.bm.faces.new(ring_verts[-1]))
            except ValueError:
                pass
        bmesh.ops.recalc_face_normals(self.bm, faces=faces)
        if planar_axis:
            apply_planar_uv(self.bm, faces, self.mat_idx(mat_name), self.uv_layer, UV_REGIONS.get(uv_reg, UV_REGIONS["full"]), axis=planar_axis, flip_u=flip_u, smooth=smooth)
        else:
            apply_box_uv(self.bm, faces, self.mat_idx(mat_name), self.uv_layer, UV_REGIONS.get(uv_reg, UV_REGIONS["full"]), scale_uv, smooth=smooth)
        return faces

    def add_tube_path(self, pts_gltf, radius, segments, mat_name, uv_reg="full", cap_ends=True):
        if len(pts_gltf) < 2:
            return []
        b_pts = [self._to_local(*p) for p in pts_gltf]
        ring_verts = []
        for i, p in enumerate(b_pts):
            if i == 0:
                t = (b_pts[1] - b_pts[0]).normalized()
            elif i == len(b_pts) - 1:
                t = (b_pts[-1] - b_pts[-2]).normalized()
            else:
                t = ((b_pts[i + 1] - b_pts[i]).normalized() + (b_pts[i] - b_pts[i - 1]).normalized()).normalized()
            up = Vector((0, 0, 1)) if abs(t.z) < 0.9 else Vector((1, 0, 0))
            n1 = t.cross(up).normalized()
            n2 = t.cross(n1).normalized()
            rv = []
            for s in range(segments):
                ang = 2.0 * math.pi * s / segments
                v = p + (math.cos(ang) * n1 + math.sin(ang) * n2) * radius
                rv.append(self.bm.verts.new(v))
            ring_verts.append(rv)
        faces = []
        for i in range(len(ring_verts) - 1):
            r0, r1 = ring_verts[i], ring_verts[i + 1]
            for j in range(segments):
                jn = (j + 1) % segments
                faces.append(self.bm.faces.new([r0[j], r0[jn], r1[jn], r1[j]]))
        if cap_ends:
            faces.append(self.bm.faces.new(list(reversed(ring_verts[0]))))
            faces.append(self.bm.faces.new(ring_verts[-1]))
        bmesh.ops.recalc_face_normals(self.bm, faces=faces)
        apply_box_uv(self.bm, faces, self.mat_idx(mat_name), self.uv_layer, UV_REGIONS.get(uv_reg, UV_REGIONS["full"]), 0.6, smooth=True)
        return faces

    def finish(self):
        bmesh.ops.remove_doubles(self.bm, verts=self.bm.verts, dist=0.0002)
        bmesh.ops.recalc_face_normals(self.bm, faces=self.bm.faces)
        self.bm.to_mesh(self.mesh)
        self.bm.free()
        self.mesh.update()
        return self.obj

# ---------------------------------------------------------------------------
# 7. BUILD COMMERCIAL TRUCK WHEELS (Wheel_FL, Wheel_FR, Wheel_RL, Wheel_RR)
# ---------------------------------------------------------------------------
def build_truck_wheel(name, center_g, is_left=True, is_rear_twin=False):
    pb = PartBuilder(name, origin_gltf=center_g)
    cx, cy, cz = center_g
    sgn = 1.0 if is_left else -1.0

    r_outer = 0.435
    r_shoulder = 0.418
    r_sidewall = 0.355
    r_bead = 0.258

    def add_tyre_and_rim(x_center, dish_sign, include_outer_holes=True):
        hw = 0.118
        profile = [
            (-hw * 0.78, r_bead),
            (-hw * 0.96, r_sidewall * 0.88),
            (-hw * 1.02, r_sidewall),
            (-hw * 0.96, r_shoulder),
            (-hw * 0.78, r_outer),
            (-hw * 0.48, r_outer * 1.001),
            (-hw * 0.44, r_outer - 0.008),
            (-hw * 0.18, r_outer * 1.002),
            (-hw * 0.14, r_outer - 0.008),
            ( hw * 0.14, r_outer - 0.008),
            ( hw * 0.18, r_outer * 1.002),
            ( hw * 0.44, r_outer - 0.008),
            ( hw * 0.48, r_outer * 1.001),
            ( hw * 0.78, r_outer),
            ( hw * 0.96, r_shoulder),
            ( hw * 1.02, r_sidewall),
            ( hw * 0.96, r_sidewall * 0.88),
            ( hw * 0.78, r_bead),
        ]
        n_circ = 64
        rings = []
        for dx, rad in profile:
            ring = []
            for k in range(n_circ):
                ang = 2.0 * math.pi * k / n_circ
                py = cy + rad * math.cos(ang)
                pz = cz + rad * math.sin(ang)
                ring.append((x_center + dx, py, pz))
            rings.append(ring)
        pb.add_loft(rings, "Rubber", cap_start=False, cap_end=False, uv_reg="full", scale_uv=0.85, smooth=True)

        # 3D Shoulder Tread Lug Blocks around tyre circumference (36 lugs per side)
        n_lugs = 36
        for k in range(n_lugs):
            ang = 2.0 * math.pi * k / n_lugs
            ca, sa = math.cos(ang), math.sin(ang)
            ta_y, ta_z = -sa, ca
            for side_s in (-1.0, 1.0):
                lx0 = x_center + side_s * (hw * 0.56)
                lx1 = x_center + side_s * (hw * 0.88)
                r0, r1 = r_outer - 0.008, r_outer + 0.003
                w_lug = 0.015
                r_b = [
                    (lx0, cy + r0 * ca - w_lug * ta_y, cz + r0 * sa - w_lug * ta_z),
                    (lx1, cy + r0 * ca - w_lug * ta_y, cz + r0 * sa - w_lug * ta_z),
                    (lx1, cy + r0 * ca + w_lug * ta_y, cz + r0 * sa + w_lug * ta_z),
                    (lx0, cy + r0 * ca + w_lug * ta_y, cz + r0 * sa + w_lug * ta_z),
                ]
                r_t = [
                    (lx0, cy + r1 * ca - w_lug * 0.8 * ta_y, cz + r1 * sa - w_lug * 0.8 * ta_z),
                    (lx1 * 0.98 + x_center * 0.02, cy + r1 * ca - w_lug * 0.8 * ta_y, cz + r1 * sa - w_lug * 0.8 * ta_z),
                    (lx1 * 0.98 + x_center * 0.02, cy + r1 * ca + w_lug * 0.8 * ta_y, cz + r1 * sa + w_lug * 0.8 * ta_z),
                    (lx0, cy + r1 * ca + w_lug * 0.8 * ta_y, cz + r1 * sa + w_lug * 0.8 * ta_z),
                ]
                pb.add_loft([r_b, r_t], "Rubber", cap_start=False, cap_end=True, uv_reg="full", scale_uv=1.0, smooth=False)

        # Steel / Alloy Commercial Rim Barrel & Stepped Dish
        rim_profile = [
            (-hw * 0.82, r_bead + 0.008),
            (-hw * 0.76, r_bead - 0.012),
            (-hw * 0.45, r_bead - 0.026),
            ( hw * 0.45, r_bead - 0.026),
            ( hw * 0.76, r_bead - 0.012),
            ( hw * 0.82, r_bead + 0.008),
            ( dish_sign * hw * 0.68, 0.215),
            ( dish_sign * hw * 0.82, 0.165),
            ( dish_sign * hw * 0.52, 0.125),
            ( dish_sign * hw * 0.48, 0.085),
        ]
        rim_rings = []
        for dx, rad in rim_profile:
            ring = []
            for k in range(n_circ):
                ang = 2.0 * math.pi * k / n_circ
                ring.append((x_center + dx, cy + rad * math.cos(ang), cz + rad * math.sin(ang)))
            rim_rings.append(ring)
        pb.add_loft(rim_rings, "Aluminium", cap_start=True, cap_end=True, uv_reg="full", scale_uv=0.7, smooth=True)

        # Internal Brake Drum / Disc behind wheel dish
        pb.add_cylinder(
            (x_center - sgn * 0.06, cy, cz),
            (x_center + sgn * 0.03, cy, cz),
            0.195, 0.195, 28, "Chassis", cap_ends=True, bevel=0.005, uv_reg="chassis_steel"
        )

        # 10 Ventilation Holes / Recesses & 10 Bolt-Circle Lug Nuts
        if include_outer_holes:
            x_dish = x_center + dish_sign * hw * 0.72
            for k in range(10):
                ang = 2.0 * math.pi * k / 10.0
                vy = cy + 0.185 * math.cos(ang)
                vz = cz + 0.185 * math.sin(ang)
                pb.add_cylinder(
                    (x_dish - sgn * 0.008, vy, vz),
                    (x_dish + sgn * 0.010, vy, vz),
                    0.022, 0.019, 14, "Chassis", cap_ends=True, bevel=0.002, uv_reg="chassis_steel"
                )
                ly = cy + 0.138 * math.cos(ang + math.pi / 10.0)
                lz = cz + 0.138 * math.sin(ang + math.pi / 10.0)
                x_bolt = x_center + dish_sign * hw * 0.52
                pb.add_cylinder(
                    (x_bolt, ly, lz),
                    (x_bolt + sgn * 0.024, ly, lz),
                    0.013, 0.011, 8, "Aluminium", cap_ends=True, bevel=0.002
                )

    if not is_rear_twin:
        add_tyre_and_rim(cx, dish_sign=sgn * 1.0, include_outer_holes=True)
        pb.add_cylinder(
            (cx - sgn * 0.04, cy, cz),
            (cx + sgn * 0.115, cy, cz),
            0.092, 0.078, 24, "Chassis", cap_ends=True, bevel=0.008, uv_reg="chassis_steel"
        )
        pb.add_cylinder(
            (cx + sgn * 0.115, cy, cz),
            (cx + sgn * 0.132, cy, cz),
            0.062, 0.052, 20, "Aluminium", cap_ends=True, bevel=0.006
        )
    else:
        add_tyre_and_rim(cx - sgn * 0.136, dish_sign=sgn * 0.6, include_outer_holes=False)
        add_tyre_and_rim(cx + sgn * 0.136, dish_sign=-sgn * 0.75, include_outer_holes=True)
        pb.add_cylinder(
            (cx - sgn * 0.18, cy, cz),
            (cx + sgn * 0.245, cy, cz),
            0.105, 0.092, 24, "Chassis", cap_ends=True, bevel=0.010, uv_reg="chassis_steel"
        )
        for k in range(10):
            ang = 2.0 * math.pi * k / 10.0
            by = cy + 0.068 * math.cos(ang)
            bz = cz + 0.068 * math.sin(ang)
            pb.add_cylinder(
                (cx + sgn * 0.242, by, bz),
                (cx + sgn * 0.256, by, bz),
                0.009, 0.008, 8, "Aluminium", cap_ends=True, bevel=0.001
            )

    return pb.finish()

# ---------------------------------------------------------------------------
# 8. BUILD STEERING WHEEL NODE (SteeringWheel)
# ---------------------------------------------------------------------------
def build_steering_wheel():
    sw_origin = (+0.520, 1.445, +2.585)
    pb = PartBuilder("SteeringWheel", origin_gltf=sw_origin)
    ox, oy, oz = sw_origin

    tilt = math.radians(28.0)
    ct, st = math.cos(tilt), math.sin(tilt)

    def sw_pt(lx, ly_in_plane, lz_in_plane, h_offset=0.0):
        gx = ox + lx
        gy = oy + ly_in_plane * st + h_offset * ct
        gz = oz + ly_in_plane * ct - h_offset * st
        return (gx, gy, gz)

    n_seg = 36
    r_wheel = 0.215
    rim_pts = []
    for i in range(n_seg + 1):
        ang = 2.0 * math.pi * i / n_seg
        rim_pts.append(sw_pt(r_wheel * math.cos(ang), r_wheel * math.sin(ang), 0.0, 0.0))
    pb.add_tube_path(rim_pts, 0.018, 14, "Chassis", uv_reg="chassis_plast", cap_ends=False)

    boss_rings = []
    for h, rad in [(-0.045, 0.055), (-0.010, 0.078), (0.018, 0.072), (0.025, 0.045)]:
        ring = []
        for i in range(24):
            ang = 2.0 * math.pi * i / 24
            ring.append(sw_pt(rad * math.cos(ang), rad * math.sin(ang), 0.0, h))
        boss_rings.append(ring)
    pb.add_loft(boss_rings, "Chassis", cap_start=True, cap_end=True, uv_reg="chassis_plast", smooth=True)

    for ang_deg in [20, 160, 215, 325]:
        ang = math.radians(ang_deg)
        p0 = sw_pt(0.062 * math.cos(ang), 0.062 * math.sin(ang), 0.0, 0.0)
        p1 = sw_pt(0.205 * math.cos(ang), 0.205 * math.sin(ang), 0.0, 0.0)
        pb.add_tube_path([p0, p1], 0.014, 10, "Chassis", uv_reg="chassis_plast", cap_ends=True)

    return pb.finish()

# ---------------------------------------------------------------------------
# 9. BUILD CAB DOORS (Door_FL, Door_FR) WITH HINGE PIVOTS & MIRRORS
# ---------------------------------------------------------------------------
def build_cab_door(is_left=True):
    sgn = 1.0 if is_left else -1.0
    name = "Door_FL" if is_left else "Door_FR"
    hinge_origin = (sgn * 1.065, 1.350, +2.820)
    pb = PartBuilder(name, origin_gltf=hinge_origin)

    x_out = sgn * 1.072
    x_in  = sgn * 1.005

    z_steps      = [1.64, 1.74, 1.84, 1.94, 2.04, 2.14, 2.24, 2.34, 2.44, 2.54, 2.68, 2.82]
    y_bot_steps  = [0.98, 1.04, 1.08, 1.10, 1.11, 1.10, 1.07, 1.01, 0.92, 0.82, 0.78, 0.78]
    y_sill_steps = [1.49, 1.49, 1.49, 1.48, 1.46, 1.42, 1.38, 1.35, 1.34, 1.34, 1.34, 1.35]

    # 1. Lower Door Panel ('Paint' municipal utility blue, Y = y_bot to 1.135)
    lower_rings = []
    for z, yb in zip(z_steps, y_bot_steps):
        yt = 1.135
        x_bulge = sgn * 1.084
        ring = [
            (x_in, yb, z),
            (x_out, yb, z),
            (x_bulge, 0.5 * (yb + yt), z),
            (x_out, yt, z),
            (x_in, yt, z),
        ]
        if not is_left:
            ring.reverse()
        lower_rings.append(ring)
    pb.add_loft(lower_rings, "Paint", cap_start=True, cap_end=True, uv_reg="full", scale_uv=0.45, smooth=True)

    # 2. Upper Door Panel below window sill (Cream 'Livery' two-tone panel matching sheet.jpg, Y = 1.135 to y_sill)
    mid_rings = []
    for z, ys in zip(z_steps, y_sill_steps):
        yb = 1.135
        x_bulge = sgn * 1.082
        ring = [
            (x_in, yb, z),
            (x_out, yb, z),
            (x_bulge, 0.5 * (yb + ys), z),
            (x_out * 0.992, ys, z),
            (x_in, ys, z),
        ]
        if not is_left:
            ring.reverse()
        mid_rings.append(ring)
    pb.add_loft(mid_rings, "Livery", cap_start=True, cap_end=True, uv_reg="livery_cream", scale_uv=0.45, smooth=True)

    # 3. Smooth Continuous Rubber Window Sill Molding
    sill_rings = []
    for z, ys in zip(z_steps[1:-1], y_sill_steps[1:-1]):
        ring = [
            (sgn * 1.025, ys - 0.015, z),
            (sgn * 1.076, ys - 0.015, z),
            (sgn * 1.078, ys + 0.018, z),
            (sgn * 1.025, ys + 0.018, z),
        ]
        if not is_left:
            ring.reverse()
        sill_rings.append(ring)
    pb.add_loft(sill_rings, "Rubber", cap_start=True, cap_end=True, smooth=True)

    # 4. Upper Door Frame Pillars & Top Header around Side Window (Cream 'Livery')
    pb.add_bevel_box(
        min(x_in, x_out * 0.985), max(x_in, x_out * 0.985),
        1.47, 2.075, 1.64, 1.76,
        "Livery", bevel=0.010, segs=2, uv_reg="livery_cream"
    )
    pb.add_bevel_box(
        min(x_in, x_out * 0.975), max(x_in, x_out * 0.975),
        2.075, 2.16, 1.64, 2.68,
        "Livery", bevel=0.010, segs=2, uv_reg="livery_cream"
    )
    ap_rings = [
        [(x_in, 1.34, 2.70), (x_out * 0.99, 1.34, 2.70), (x_out * 0.99, 1.34, 2.82), (x_in, 1.34, 2.82)],
        [(x_in, 2.075, 2.57), (x_out * 0.975, 2.075, 2.57), (x_out * 0.975, 2.075, 2.68), (x_in, 2.075, 2.68)],
    ]
    if not is_left:
        ap_rings = [list(reversed(r)) for r in ap_rings]
    pb.add_loft(ap_rings, "Livery", cap_start=True, cap_end=True, uv_reg="livery_cream", smooth=True)

    # Quarter-light vertical rubber divider & perimeter window rubber seal (non-overlapping!)
    pb.add_bevel_box(
        min(sgn * 1.03, sgn * 1.068), max(sgn * 1.03, sgn * 1.068),
        1.355, 2.055, 2.52, 2.555,
        "Rubber", bevel=0.004, segs=1
    )
    pb.add_bevel_box(
        min(sgn * 1.032, sgn * 1.065), max(sgn * 1.032, sgn * 1.065),
        2.055, 2.082, 1.755, 2.60,
        "Rubber", bevel=0.004, segs=1
    )

    # 5. Door Window Glass ('Glass' material, alpha BLEND)
    glass_x0 = sgn * 1.038
    glass_x1 = sgn * 1.046
    glass_rings = []
    for z, ys in zip(z_steps[1:-1], y_sill_steps[1:-1]):
        yt = 2.060
        r = [(glass_x0, ys + 0.01, z), (glass_x1, ys + 0.01, z), (glass_x1, yt, z), (glass_x0, yt, z)]
        if not is_left:
            r.reverse()
        glass_rings.append(r)
    pb.add_loft(glass_rings, "Glass", cap_start=True, cap_end=True, smooth=True)

    # 6. Recessed Black Pocket Door Handle (at rear of door, Z = +1.76 to +1.90, Y = 1.15 to 1.29)
    pb.add_bevel_box(
        min(sgn * 1.055, sgn * 1.085), max(sgn * 1.055, sgn * 1.085),
        1.14, 1.28, 1.75, 1.90,
        "Chassis", bevel=0.010, segs=2, uv_reg="chassis_plast"
    )
    pb.add_bevel_box(
        min(sgn * 1.072, sgn * 1.090), max(sgn * 1.072, sgn * 1.090),
        1.16, 1.26, 1.79, 1.84,
        "Chassis", bevel=0.004, segs=1, uv_reg="chassis_steel"
    )

    # 7. Interior Door Trim Card & Armrest
    pb.add_bevel_box(
        min(sgn * 0.975, sgn * 1.010), max(sgn * 0.975, sgn * 1.010),
        0.85, 1.36, 1.68, 2.75,
        "Chassis", bevel=0.012, segs=1, uv_reg="chassis_plast"
    )
    pb.add_bevel_box(
        min(sgn * 0.935, sgn * 0.985), max(sgn * 0.935, sgn * 0.985),
        1.18, 1.25, 1.85, 2.42,
        "Chassis", bevel=0.010, segs=1, uv_reg="chassis_plast"
    )

    # 8. Twin Exterior Side Mirror Assembly on Tubular C-Bracket (extends to X = ±1.19 m = 2.38 m width)
    arm_pts = [
        (sgn * 1.050, 2.02, 2.66),
        (sgn * 1.155, 2.00, 2.72),
        (sgn * 1.168, 1.92, 2.73),
        (sgn * 1.168, 1.46, 2.73),
        (sgn * 1.150, 1.40, 2.71),
        (sgn * 1.055, 1.38, 2.64),
    ]
    pb.add_tube_path(arm_pts, 0.013, 12, "Chassis", uv_reg="chassis_plast", cap_ends=True)

    mx0, mx1 = min(sgn * 1.100, sgn * 1.190), max(sgn * 1.100, sgn * 1.190)
    pb.add_bevel_box(mx0, mx1, 1.66, 1.97, 2.68, 2.77, "Chassis", bevel=0.018, segs=3, uv_reg="chassis_plast")
    pb.add_bevel_box(mx0 + 0.008, mx1 - 0.008, 1.68, 1.95, 2.675, 2.685, "Aluminium", bevel=0.003, segs=1)

    pb.add_bevel_box(mx0 + 0.004, mx1 - 0.002, 1.45, 1.63, 2.68, 2.77, "Chassis", bevel=0.016, segs=3, uv_reg="chassis_plast")
    pb.add_bevel_box(mx0 + 0.010, mx1 - 0.008, 1.47, 1.61, 2.675, 2.685, "Aluminium", bevel=0.003, segs=1)

    return pb.finish()

# ---------------------------------------------------------------------------
# 10. BUILD MAIN BODY NODE (Cab, Interior, Headache Rack, Flatbed, Drop-Sides, Chassis)
# ---------------------------------------------------------------------------
def build_body_node():
    pb = PartBuilder("Body", origin_gltf=(0.0, 0.0, 0.0))

    # =========================================================================
    # A. CAB SHELL, FRONT BUMPER, GRILLE, STEPS, FENDERS & ROOF BROW
    # =========================================================================
    # 1. Sculpted Front Bumper ('Paint' municipal utility blue, recessed in middle for lower grille)
    bumper_sections = [
        (+1.08, 2.92, 3.00),
        (+1.06, 3.12, 3.16),
        (+1.01, 3.22, 3.25),
        (+0.86, 3.27, 3.29),
        (+0.50, 3.31, 3.31),
        (+0.42, 3.28, 3.31),
        ( 0.00, 3.28, 3.32),
        (-0.42, 3.28, 3.31),
        (-0.50, 3.31, 3.31),
        (-0.86, 3.27, 3.29),
        (-1.01, 3.22, 3.25),
        (-1.06, 3.12, 3.16),
        (-1.08, 2.92, 3.00),
    ]
    b_rings = []
    for bx, bz_mid, bz_low in bumper_sections:
        dx, dz = bx - 0.0, bz_mid - 2.75
        dl = math.hypot(dx, dz)
        nx, nz = dx / dl, dz / dl
        ring = [
            (bx - nx * 0.08, 0.35, bz_low - nz * 0.08),
            (bx,             0.38, bz_low),
            (bx + nx * 0.010, 0.56, bz_mid),
            (bx,             0.75, bz_mid),
            (bx - nx * 0.09, 0.75, bz_mid - nz * 0.09),
            (bx - nx * 0.09, 0.37, bz_low - nz * 0.09),
        ]
        b_rings.append(ring)
    pb.add_loft(b_rings, "Paint", cap_start=True, cap_end=True, uv_reg="full", scale_uv=0.45, smooth=True)

    # Central lower air dam intake recess in front bumper (Y = 0.39 to 0.46, X = -0.44 to +0.44)
    pb.add_bevel_box(-0.44, 0.44, 0.385, 0.465, 3.26, 3.328, "Chassis", bevel=0.008, segs=2, uv_reg="chassis_plast")

    # 2. Bumper Headlight Clusters with 3D Parabolic Reflector Bowls & Bulbs (Z = +3.20 to +3.32, Y = 0.49 to 0.66)
    for sgn in (-1.0, 1.0):
        hx0, hx1 = min(sgn * 0.56, sgn * 0.98), max(sgn * 0.56, sgn * 0.98)
        pb.add_bevel_box(hx0, hx1, 0.485, 0.665, 3.16, 3.28, "Chassis", bevel=0.010, segs=2, uv_reg="chassis_plast")
        pb.add_bevel_box(hx0 + 0.012, hx1 - 0.012, 0.50, 0.65, 3.22, 3.27, "Aluminium", bevel=0.006, segs=1)
        for rx_off, r_bowl in [(0.67, 0.058), (0.81, 0.052)]:
            rcx = sgn * rx_off
            rcy = 0.575
            pb.add_cylinder((rcx, rcy, 3.23), (rcx, rcy, 3.295), r_bowl * 0.55, r_bowl, 20, "Aluminium", cap_ends=True, bevel=0.003)
            pb.add_cylinder((rcx, rcy, 3.255), (rcx, rcy, 3.298), 0.022, 0.014, 14, "Lamps", cap_ends=True, bevel=0.003, uv_reg="lamps_head")
        pb.add_cylinder((sgn * 0.92, 0.575, 3.235), (sgn * 0.92, 0.575, 3.292), 0.032, 0.042, 16, "Lamps", cap_ends=True, bevel=0.003, uv_reg="lamps_amber")
        pb.add_bevel_box(hx0 + 0.008, hx1 - 0.008, 0.498, 0.652, 3.290, 3.316, "Glass", bevel=0.008, segs=2)

    # 3. Cab Entry Footstep Wells & Perforated Aluminium Step Treads (Left & Right, Z = +2.52 to +2.94)
    for sgn in (-1.0, 1.0):
        sx0, sx1 = min(sgn * 0.92, sgn * 1.095), max(sgn * 0.92, sgn * 1.095)
        pb.add_bevel_box(sx0, sx1, 0.36, 0.78, 2.52, 2.94, "Chassis", bevel=0.016, segs=2, uv_reg="chassis_plast")
        pb.add_bevel_box(min(sgn * 0.95, sgn * 1.105), max(sgn * 0.95, sgn * 1.105), 0.38, 0.415, 2.55, 2.91, "Aluminium", bevel=0.005, segs=1)
        pb.add_bevel_box(min(sgn * 0.95, sgn * 1.100), max(sgn * 0.95, sgn * 1.100), 0.60, 0.635, 2.57, 2.89, "Aluminium", bevel=0.005, segs=1)

    # 4. Moulded Slate-Grey Thermoplastic Front Wheel Arch Fenders & Mudflaps
    n_arch = 28
    r_arch_in = 0.468
    r_arch_out = 0.610
    for sgn in (-1.0, 1.0):
        arch_rings = []
        for i in range(n_arch + 1):
            ang = math.radians(-12.0 + 204.0 * i / n_arch)
            ca, sa = math.cos(ang), math.sin(ang)
            y_in  = 0.435 + r_arch_in * sa
            z_in  = 2.075 + r_arch_in * ca
            y_out = 0.435 + r_arch_out * sa
            z_out = 2.075 + r_arch_out * ca
            x_base = sgn * 0.95
            x_lip  = sgn * 1.132
            ring = [
                (x_base, y_in, z_in),
                (x_lip,  y_in, z_in),
                (x_lip * 1.006, 0.5 * (y_in + y_out), 0.5 * (z_in + z_out)),
                (x_lip * 0.980, y_out, z_out),
                (x_base, y_out, z_out),
            ]
            if sgn < 0:
                ring.reverse()
            arch_rings.append(ring)
        pb.add_loft(arch_rings, "Chassis", cap_start=True, cap_end=True, uv_reg="chassis_plast", scale_uv=0.5, smooth=True)
        pb.add_bevel_box(
            min(sgn * 0.96, sgn * 1.10), max(sgn * 0.96, sgn * 1.10),
            0.52, 0.98, 1.42, 1.62,
            "Chassis", bevel=0.015, segs=2, uv_reg="chassis_plast"
        )
        pb.add_bevel_box(
            min(sgn * 0.82, sgn * 1.11), max(sgn * 0.82, sgn * 1.11),
            0.16, 0.44, 1.48, 1.515,
            "Rubber", bevel=0.004, segs=1
        )
        pb.add_bevel_box(
            min(sgn * 1.12, sgn * 1.14), max(sgn * 1.12, sgn * 1.14),
            0.60, 0.65, 1.56, 1.62,
            "Lamps", bevel=0.004, segs=1, uv_reg="lamps_amber"
        )

    # 5. Front Cab Mask & Flush Aerodynamic Cheek Contours ('Paint' municipal utility blue, Y = 0.75 to 1.42)
    mask_y = [0.75, 0.90, 1.08, 1.25, 1.42]
    mask_z = [3.27, 3.295, 3.29, 3.27, 3.23]
    mask_rings = []
    for y, zf in zip(mask_y, mask_z):
        ring = [
            (+1.065, y, 2.82),
            (+1.055, y, 3.08),
            (+0.985, y, zf - 0.04),
            (+0.620, y, zf),
            (-0.620, y, zf),
            (-0.985, y, zf - 0.04),
            (-1.055, y, 3.08),
            (-1.065, y, 2.82),
            (-0.980, y, 2.82),
            (-0.960, y, 3.05),
            (-0.580, y, zf - 0.08),
            (+0.580, y, zf - 0.08),
            (+0.960, y, 3.05),
            (+0.980, y, 2.82),
        ]
        mask_rings.append(ring)
    pb.add_loft(mask_rings, "Paint", cap_start=True, cap_end=True, uv_reg="full", scale_uv=0.45, smooth=True)

    # Flush body-colored aerodynamic corner cheek vanes on front mask (matching sheet.jpg!)
    for sgn in (-1.0, 1.0):
        pb.add_bevel_box(
            min(sgn * 0.92, sgn * 1.03), max(sgn * 0.92, sgn * 1.03),
            0.82, 1.22, 3.14, 3.245,
            "Paint", bevel=0.015, segs=2
        )

    # 6. DAF LF-Style Two-Tier Trapezoidal Front Grille & Brushed Silver Header Bar (Y = 0.50 to 1.32, Z = +3.24 to +3.328)
    grille_rings = [
        [(-0.42, 0.50, 3.22), (+0.42, 0.50, 3.22), (+0.52, 0.78, 3.22), (+0.59, 1.06, 3.21), (+0.63, 1.32, 3.20), (-0.63, 1.32, 3.20), (-0.59, 1.06, 3.21), (-0.52, 0.78, 3.22)],
        [(-0.40, 0.51, 3.318), (+0.40, 0.51, 3.318), (+0.50, 0.78, 3.322), (+0.57, 1.06, 3.315), (+0.61, 1.31, 3.292), (-0.61, 1.31, 3.292), (-0.57, 1.06, 3.315), (-0.50, 0.78, 3.322)],
    ]
    pb.add_loft(grille_rings, "Chassis", cap_start=False, cap_end=True, uv_reg="chassis_plast", smooth=True)

    # Prominent Brushed Silver Upper Grille Bar (Y = 1.09 to 1.27, X = -0.56 to +0.56, Z = +3.27 to +3.325)
    pb.add_bevel_box(-0.56, 0.56, 1.09, 1.27, 3.27, 3.325, "Livery", bevel=0.015, segs=3, uv_reg="livery_silver")

    # Upper & Lower Louvred Grille Mesh Slats
    for ly in [0.54, 0.60, 0.66, 0.84, 0.91, 0.98]:
        hw_l = 0.36 + (ly - 0.50) * 0.28
        pb.add_bevel_box(-hw_l, hw_l, ly, ly + 0.025, 3.285, 3.332, "Chassis", bevel=0.005, segs=2, uv_reg="chassis_plast")
    pb.add_bevel_box(-0.49, 0.49, 0.72, 0.80, 3.28, 3.334, "Chassis", bevel=0.012, segs=2, uv_reg="chassis_plast")

    # 7. Windscreen Frame, Curved Panoramic Windscreen Glass, Wipers & Overhead Kerb Mirror
    for sgn in (-1.0, 1.0):
        ap_f = [
            [(sgn * 0.92, 1.38, 3.12), (sgn * 1.055, 1.38, 3.06), (sgn * 1.055, 1.38, 2.82), (sgn * 0.92, 1.38, 2.82)],
            [(sgn * 0.89, 1.78, 3.04), (sgn * 1.035, 1.78, 2.98), (sgn * 1.035, 1.78, 2.74), (sgn * 0.89, 1.78, 2.74)],
            [(sgn * 0.85, 2.16, 2.94), (sgn * 0.995, 2.16, 2.88), (sgn * 0.995, 2.16, 2.66), (sgn * 0.85, 2.16, 2.66)],
        ]
        if sgn < 0:
            ap_f = [list(reversed(r)) for r in ap_f]
        pb.add_loft(ap_f, "Paint", cap_start=True, cap_end=True, uv_reg="full", smooth=True)

    pb.add_bevel_box(-0.94, 0.94, 1.35, 1.43, 3.12, 3.24, "Rubber", bevel=0.010, segs=2)

    ws_x = np.linspace(-0.90, 0.90, 15)
    ws_rings = []
    for yg, zb_center in [(1.41, 3.22), (1.65, 3.13), (1.90, 3.04), (2.14, 2.95)]:
        front_pts = []
        back_pts = []
        for x in ws_x:
            curve_z = -0.095 * (x / 0.90) ** 2
            zf = zb_center + curve_z
            front_pts.append((x, yg, zf))
            back_pts.append((x, yg, zf - 0.012))
        ws_rings.append(front_pts + list(reversed(back_pts)))
    pb.add_loft(ws_rings, "Glass", cap_start=True, cap_end=True, smooth=True)

    for wx_pivot in (+0.52, -0.18):
        pb.add_cylinder((wx_pivot, 1.41, 3.18), (wx_pivot, 1.43, 3.25), 0.016, 0.012, 10, "Chassis", uv_reg="chassis_plast")
        arm_path = [
            (wx_pivot, 1.43, 3.245),
            (wx_pivot - 0.22, 1.47, 3.23),
            (wx_pivot - 0.42, 1.49, 3.21),
        ]
        pb.add_tube_path(arm_path, 0.008, 8, "Chassis", uv_reg="chassis_plast")
        pb.add_bevel_box(wx_pivot - 0.62, wx_pivot - 0.14, 1.475, 1.505, 3.20, 3.235, "Rubber", bevel=0.003, segs=1)

    # 8. Upper Cab Roof Brow ('Paint' blue) & Rear/Side Cab Shell (Two-tone 'Paint' lower + 'Livery' cream upper)
    pb.add_bevel_box(-1.072, 1.072, 0.96, 1.135, 1.42, 1.65, "Paint", bevel=0.022, segs=2)
    pb.add_bevel_box(-1.065, 1.065, 1.135, 2.24, 1.42, 1.65, "Livery", bevel=0.028, segs=3, uv_reg="livery_cream")
    pb.add_bevel_box(-0.64, 0.64, 1.50, 1.98, 1.41, 1.44, "Rubber", bevel=0.008, segs=1)
    pb.add_bevel_box(-0.61, 0.61, 1.52, 1.96, 1.408, 1.425, "Glass", bevel=0.004, segs=1)

    roof_z = [1.44, 1.70, 2.10, 2.55, 2.82, 2.96]
    roof_y_top = [2.33, 2.365, 2.37, 2.35, 2.28, 2.16]
    roof_hw = [1.01, 1.02, 1.02, 1.00, 0.96, 0.91]
    roof_rings = []
    for rz, ryt, rhw in zip(roof_z, roof_y_top, roof_hw):
        yb = 2.14
        ring = [
            (-rhw * 1.03, yb, rz),
            (-rhw, 0.5 * (yb + ryt), rz),
            (-rhw * 0.85, ryt, rz),
            ( 0.0, ryt + 0.012, rz),
            ( rhw * 0.85, ryt, rz),
            ( rhw, 0.5 * (yb + ryt), rz),
            ( rhw * 1.03, yb, rz),
            ( rhw * 0.90, yb - 0.02, rz),
            (-rhw * 0.90, yb - 0.02, rz),
        ]
        roof_rings.append(ring)
    pb.add_loft(roof_rings, "Paint", cap_start=True, cap_end=True, uv_reg="full", scale_uv=0.45, smooth=True)

    kerb_arm = [
        (0.00, 2.28, 2.88),
        (0.04, 2.31, 3.06),
        (0.10, 2.24, 3.22),
        (0.16, 2.15, 3.28),
    ]
    pb.add_tube_path(kerb_arm, 0.018, 12, "Chassis", uv_reg="chassis_plast", cap_ends=True)
    pb.add_bevel_box(0.04, 0.34, 2.09, 2.18, 3.22, 3.35, "Chassis", bevel=0.022, segs=3, uv_reg="chassis_plast")

    pb.add_bevel_box(-0.025, 0.025, 2.35, 2.39, 2.66, 2.72, "Chassis", bevel=0.005, segs=1, uv_reg="chassis_plast")
    pb.add_tube_path([(0.0, 2.38, 2.69), (0.0, 2.47, 2.58), (0.0, 2.55, 2.46)], 0.004, 8, "Chassis", uv_reg="chassis_plast")

    # =========================================================================
    # B. CAB INTERIOR (Visible through Windscreen & Door Windows)
    # =========================================================================
    pb.add_bevel_box(-0.98, 0.98, 0.78, 0.85, 1.46, 3.14, "Chassis", bevel=0.010, segs=1, uv_reg="chassis_plast")
    pb.add_bevel_box(-0.28, 0.28, 0.84, 0.98, 1.52, 2.85, "Chassis", bevel=0.025, segs=2, uv_reg="chassis_plast")
    pb.add_bevel_box(-0.96, 0.96, 0.92, 1.38, 2.72, 3.16, "Chassis", bevel=0.030, segs=3, uv_reg="chassis_plast")
    pb.add_bevel_box(0.32, 0.72, 1.35, 1.48, 2.68, 2.98, "Chassis", bevel=0.020, segs=2, uv_reg="chassis_plast")
    pb.add_cylinder((0.52, 1.18, 2.82), (0.52, 1.43, 2.60), 0.042, 0.034, 12, "Chassis", uv_reg="chassis_plast")
    pb.add_tube_path([(0.16, 0.96, 2.42), (0.18, 1.15, 2.45), (0.18, 1.24, 2.48)], 0.012, 8, "Chassis", uv_reg="chassis_plast")
    pb.add_bevel_box(0.155, 0.205, 1.22, 1.28, 2.45, 2.51, "Chassis", bevel=0.012, segs=2, uv_reg="chassis_plast")

    for sx0, sx1 in [(0.26, 0.78), (-0.82, -0.08)]:
        pb.add_bevel_box(sx0 + 0.04, sx1 - 0.04, 0.84, 1.06, 1.78, 2.22, "Chassis", bevel=0.015, segs=1, uv_reg="chassis_steel")
        pb.add_bevel_box(sx0, sx1, 1.05, 1.18, 1.74, 2.28, "Chassis", bevel=0.028, segs=2, uv_reg="chassis_plast")
        pb.add_bevel_box(sx0 + 0.02, sx1 - 0.02, 1.16, 1.72, 1.62, 1.80, "Chassis", bevel=0.030, segs=2, uv_reg="chassis_plast")
        if sx0 > 0:
            pb.add_bevel_box(0.38, 0.66, 1.71, 1.90, 1.63, 1.76, "Chassis", bevel=0.022, segs=2, uv_reg="chassis_plast")
        else:
            pb.add_bevel_box(-0.74, -0.48, 1.71, 1.90, 1.63, 1.76, "Chassis", bevel=0.022, segs=2, uv_reg="chassis_plast")
            pb.add_bevel_box(-0.40, -0.14, 1.71, 1.90, 1.63, 1.76, "Chassis", bevel=0.022, segs=2, uv_reg="chassis_plast")

    pb.add_bevel_box(-0.12, 0.12, 1.98, 2.05, 2.86, 2.91, "Chassis", bevel=0.008, segs=1, uv_reg="chassis_plast")

    # =========================================================================
    # C. HEADACHE RACK / BULKHEAD GRILLE & FLATBED DECK WITH DROP-SIDES
    # =========================================================================
    bed_z_front = 1.28
    bed_z_mid   = -0.76
    bed_z_rear  = -3.70

    # 1. Perimeter Anodized Aluminium Rave Rails & Tie-Down Hardware
    for sgn in (-1.0, 1.0):
        rx0, rx1 = min(sgn * 1.08, sgn * 1.14), max(sgn * 1.08, sgn * 1.14)
        pb.add_bevel_box(rx0, rx1, 0.90, 1.00, bed_z_rear - 0.02, bed_z_front + 0.02, "Aluminium", bevel=0.006, segs=2)
        for kz in np.linspace(bed_z_rear + 0.22, bed_z_front - 0.22, 12):
            pb.add_bevel_box(
                min(sgn * 1.09, sgn * 1.125), max(sgn * 1.09, sgn * 1.125),
                0.855, 0.905, kz - 0.018, kz + 0.018,
                "Aluminium", bevel=0.003, segs=1
            )
        for mz in [1.05, -0.76, -3.42]:
            pb.add_bevel_box(
                min(sgn * 1.10, sgn * 1.135), max(sgn * 1.10, sgn * 1.135),
                0.84, 0.89, mz - 0.04, mz + 0.04,
                "Chassis", bevel=0.004, segs=1, uv_reg="chassis_plast"
            )
            pb.add_bevel_box(
                min(sgn * 1.125, sgn * 1.138), max(sgn * 1.125, sgn * 1.138),
                0.848, 0.882, mz - 0.032, mz + 0.032,
                "Lamps", bevel=0.002, segs=1, uv_reg="lamps_amber"
            )

    pb.add_bevel_box(-1.078, 1.078, 0.90, 1.00, bed_z_front - 0.06, bed_z_front + 0.02, "Aluminium", bevel=0.006, segs=2)
    pb.add_bevel_box(-1.078, 1.078, 0.90, 1.00, bed_z_rear - 0.02, bed_z_rear + 0.06, "Aluminium", bevel=0.006, segs=2)

    # 2. Flatbed Cargo Deck Floor: 16 Lengthwise Weathered Timber Planks (Y = 0.965 to 1.005)
    n_floor_planks = 16
    plank_w = (2.16 / n_floor_planks) - 0.004
    for i in range(n_floor_planks):
        px0 = -1.08 + i * (2.16 / n_floor_planks) + 0.002
        px1 = px0 + plank_w
        pb.add_bevel_box(
            px0, px1, 0.965, 1.005, bed_z_rear + 0.04, bed_z_front - 0.04,
            "Timber", bevel=0.004, segs=1, uv_reg="full", scale_uv=0.35
        )
    pb.add_bevel_box(-1.09, 1.09, 0.945, 0.966, bed_z_rear + 0.02, bed_z_front - 0.02, "Chassis", bevel=0.003, segs=1, uv_reg="chassis_steel")

    # 3. Tall Anodized Aluminium Headache Rack & Weathered Timber Bulkhead (Z = +1.24 to +1.30, Y = 0.92 to 2.48)
    for sgn in (-1.0, 1.0):
        px0, px1 = min(sgn * 1.07, sgn * 1.14), max(sgn * 1.07, sgn * 1.14)
        pb.add_bevel_box(px0, px1, 0.92, 2.48, bed_z_front - 0.05, bed_z_front + 0.02, "Aluminium", bevel=0.007, segs=2)
        pb.add_bevel_box(sgn * 0.43 - 0.025, sgn * 0.43 + 0.025, 0.96, 2.435, bed_z_front - 0.045, bed_z_front - 0.01, "Aluminium", bevel=0.005, segs=1)
    # Top horizontal header bar spanning cleanly between the two vertical posts (-1.068 to +1.068 — zero z-fighting!)
    pb.add_bevel_box(-1.068, 1.068, 2.44, 2.48, bed_z_front - 0.048, bed_z_front + 0.018, "Aluminium", bevel=0.006, segs=2)
    pb.add_bevel_box(-1.068, 1.068, 0.98, 1.37, bed_z_front - 0.035, bed_z_front + 0.005, "Aluminium", bevel=0.006, segs=1)
    for ry in [1.06, 1.17, 1.28]:
        pb.add_bevel_box(-1.065, 1.065, ry, ry + 0.032, bed_z_front - 0.042, bed_z_front + 0.012, "Aluminium", bevel=0.004, segs=1)
    # Upper Headache Rack: 7 Horizontal Weathered Timber Planks (Y = 1.38 to 2.435)
    n_hr_planks = 7
    hr_y0, hr_y1 = 1.385, 2.435
    hr_step = (hr_y1 - hr_y0) / n_hr_planks
    for i in range(n_hr_planks):
        py0 = hr_y0 + i * hr_step + 0.003
        py1 = py0 + hr_step - 0.007
        f_hr = pb.add_bevel_box(
            -1.068, 1.068, py0, py1, bed_z_front - 0.032, bed_z_front + 0.004,
            "Timber", bevel=0.005, segs=2, uv_reg="full", scale_uv=0.42
        )
        apply_planar_uv(pb.bm, f_hr, pb.mat_idx("Timber"), pb.uv_layer, (0.04, 0.96, 0.10 + 0.11 * i, 0.20 + 0.11 * i), axis='Z')

    # 4. Left & Right Two-Bay Fold-Down Drop-Sides (Lower Anodized Aluminium + Upper 3 Weathered Timber Planks)
    for sgn in (-1.0, 1.0):
        sx0, sx1 = min(sgn * 1.085, sgn * 1.142), max(sgn * 1.085, sgn * 1.142)
        pb.add_bevel_box(sx0, sx1, 0.92, 1.79, bed_z_mid - 0.045, bed_z_mid + 0.045, "Aluminium", bevel=0.006, segs=2)
        pb.add_bevel_box(sx0, sx1, 0.92, 1.79, bed_z_rear - 0.02, bed_z_rear + 0.055, "Aluminium", bevel=0.006, segs=2)

        for bz0, bz1 in [(-0.712, 1.225), (-3.642, -0.808)]:
            px_in  = min(sgn * 1.095, sgn * 1.132)
            px_out = max(sgn * 1.095, sgn * 1.132)
            pb.add_bevel_box(px_in, px_out, 0.985, 1.375, bz0, bz1, "Aluminium", bevel=0.006, segs=2)
            for ry in [0.99, 1.10, 1.22, 1.34]:
                pb.add_bevel_box(
                    min(sgn * 1.088, sgn * 1.138), max(sgn * 1.088, sgn * 1.138),
                    ry, ry + 0.032, bz0 + 0.005, bz1 - 0.005,
                    "Aluminium", bevel=0.004, segs=1
                )
            for hz in np.linspace(bz0 + 0.28, bz1 - 0.28, 3):
                pb.add_bevel_box(
                    min(sgn * 1.125, sgn * 1.148), max(sgn * 1.125, sgn * 1.148),
                    0.925, 1.035, hz - 0.022, hz + 0.022,
                    "Aluminium", bevel=0.004, segs=2
                )
                pb.add_cylinder(
                    (sgn * 1.142, 0.96, hz - 0.035),
                    (sgn * 1.142, 0.96, hz + 0.035),
                    0.010, 0.010, 10, "Aluminium"
                )
            for lz in [bz0 + 0.035, bz1 - 0.035]:
                pb.add_bevel_box(
                    min(sgn * 1.128, sgn * 1.150), max(sgn * 1.128, sgn * 1.150),
                    1.08, 1.35, lz - 0.018, lz + 0.018,
                    "Aluminium", bevel=0.004, segs=2
                )

            # Upper 3 Horizontal Weathered Timber Side Planks with horizontal grain (axis='X')
            for p_idx in range(3):
                ty0 = 1.385 + p_idx * 0.130 + 0.002
                ty1 = ty0 + 0.123
                f_sp = pb.add_bevel_box(
                    min(sgn * 1.096, sgn * 1.130), max(sgn * 1.096, sgn * 1.130),
                    ty0, ty1, bz0, bz1,
                    "Timber", bevel=0.005, segs=2, uv_reg="full", scale_uv=0.40
                )
                apply_planar_uv(pb.bm, f_sp, pb.mat_idx("Timber"), pb.uv_layer, (0.04, 0.96, 0.15 + 0.25 * p_idx, 0.35 + 0.25 * p_idx), axis='X')
                for b_z in [bz0 + 0.05, 0.5 * (bz0 + bz1), bz1 - 0.05]:
                    pb.add_cylinder(
                        (sgn * 1.128, 0.5 * (ty0 + ty1), b_z),
                        (sgn * 1.136, 0.5 * (ty0 + ty1), b_z),
                        0.008, 0.007, 8, "Aluminium"
                    )

    # 5. Rear Fold-Down Tailgate (Z = -3.715 to -3.680, X = -1.080 to +1.080)
    pb.add_bevel_box(-1.080, 1.080, 0.985, 1.375, bed_z_rear - 0.018, bed_z_rear + 0.018, "Aluminium", bevel=0.006, segs=2)
    for ry in [0.99, 1.10, 1.22, 1.34]:
        pb.add_bevel_box(-1.078, 1.078, ry, ry + 0.032, bed_z_rear - 0.024, bed_z_rear + 0.022, "Aluminium", bevel=0.004, segs=1)
    for hx in [-0.82, -0.28, 0.28, 0.82]:
        pb.add_bevel_box(hx - 0.022, hx + 0.022, 0.925, 1.035, bed_z_rear - 0.030, bed_z_rear - 0.005, "Aluminium", bevel=0.004, segs=2)
    for p_idx in range(3):
        ty0 = 1.385 + p_idx * 0.130 + 0.002
        ty1 = ty0 + 0.123
        f_tg = pb.add_bevel_box(
            -1.080, 1.080, ty0, ty1, bed_z_rear - 0.016, bed_z_rear + 0.016,
            "Timber", bevel=0.005, segs=2, uv_reg="full", scale_uv=0.40
        )
        apply_planar_uv(pb.bm, f_tg, pb.mat_idx("Timber"), pb.uv_layer, (0.04, 0.96, 0.15 + 0.25 * p_idx, 0.35 + 0.25 * p_idx), axis='Z')

    # =========================================================================
    # D. EXPOSED BLACK STEEL CHASSIS FRAME, TOOLBOX, FUEL TANK, AXLES & BUMPERS
    # =========================================================================
    for sgn in (-1.0, 1.0):
        cx = sgn * 0.43
        pb.add_bevel_box(cx - 0.022, cx + 0.022, 0.58, 0.78, -3.65, 3.05, "Chassis", bevel=0.005, segs=1, uv_reg="chassis_steel")
        pb.add_bevel_box(cx - 0.055, cx + 0.055, 0.75, 0.785, -3.65, 3.05, "Chassis", bevel=0.004, segs=1, uv_reg="chassis_steel")
        pb.add_bevel_box(cx - 0.055, cx + 0.055, 0.575, 0.61, -3.65, 3.05, "Chassis", bevel=0.004, segs=1, uv_reg="chassis_steel")
        pb.add_bevel_box(cx - 0.035, cx + 0.035, 0.78, 0.885, -3.62, 1.25, "Chassis", bevel=0.004, segs=1, uv_reg="chassis_steel")
        for rz in np.linspace(-3.45, 1.20, 22):
            pb.add_cylinder(
                (cx + sgn * 0.020, 0.68, rz),
                (cx + sgn * 0.030, 0.68, rz),
                0.009, 0.008, 8, "Chassis", uv_reg="chassis_steel"
            )

    for cz in np.linspace(-3.50, 2.80, 9):
        pb.add_bevel_box(-0.40, 0.40, 0.60, 0.76, cz - 0.04, cz + 0.04, "Chassis", bevel=0.005, segs=1, uv_reg="chassis_steel")
    for bz in np.linspace(-3.55, 1.20, 12):
        pb.add_bevel_box(-1.09, 1.09, 0.88, 0.945, bz - 0.03, bz + 0.03, "Chassis", bevel=0.004, segs=1, uv_reg="chassis_steel")

    # 2. Side Underrun Protection / Cyclist Guard Rails (Anodized Aluminium Bar + Brackets)
    for sgn in (-1.0, 1.0):
        gx0, gx1 = min(sgn * 1.03, sgn * 1.075), max(sgn * 1.03, sgn * 1.075)
        pb.add_bevel_box(gx0, gx1, 0.43, 0.53, -1.28, 1.22, "Aluminium", bevel=0.008, segs=2)
        for bkz in [1.05, -0.05, -1.10]:
            pb.add_bevel_box(
                min(sgn * 0.98, sgn * 1.03), max(sgn * 0.98, sgn * 1.03),
                0.44, 0.89, bkz - 0.03, bkz + 0.03,
                "Aluminium", bevel=0.005, segs=1
            )

    # 3. Underbody Fuel Tank, Lockable Steel Toolbox, Air Brake Tanks & Battery Box
    for sgn in (-1.0, 1.0):
        tx_in  = sgn * 0.48
        tx_out = sgn * 0.96
        tx0, tx1 = min(tx_in, tx_out), max(tx_in, tx_out)
        pb.add_bevel_box(tx0, tx1, 0.43, 0.78, -0.68, 0.34, "Chassis", bevel=0.065, segs=4, uv_reg="chassis_steel")
        for sz in [-0.42, 0.08]:
            pb.add_bevel_box(tx0 - 0.008, tx1 + 0.008, 0.422, 0.788, sz - 0.022, sz + 0.022, "Chassis", bevel=0.015, segs=2, uv_reg="chassis_steel")
        pb.add_cylinder(
            (sgn * 0.88, 0.72, 0.22),
            (sgn * 0.98, 0.82, 0.26),
            0.035, 0.038, 14, "Chassis", uv_reg="chassis_steel"
        )

        pb.add_bevel_box(
            min(sgn * 0.52, sgn * 0.98), max(sgn * 0.52, sgn * 0.98),
            0.46, 0.79, 0.46, 0.96,
            "Chassis", bevel=0.018, segs=2, uv_reg="chassis_steel"
        )
        pb.add_bevel_box(
            min(sgn * 0.96, sgn * 0.992), max(sgn * 0.96, sgn * 0.992),
            0.50, 0.75, 0.50, 0.92,
            "Chassis", bevel=0.008, segs=1, uv_reg="chassis_plast"
        )
        pb.add_bevel_box(
            min(sgn * 0.985, sgn * 1.002), max(sgn * 0.985, sgn * 1.002),
            0.59, 0.66, 0.68, 0.74,
            "Aluminium", bevel=0.003, segs=1
        )
        pb.add_cylinder(
            (sgn * 0.82, 0.45, 1.08),
            (sgn * 0.82, 0.79, 1.08),
            0.085, 0.085, 18, "Chassis", uv_reg="chassis_steel"
        )

    pb.add_bevel_box(-0.34, 0.34, 0.48, 0.82, 1.45, 2.55, "Chassis", bevel=0.035, segs=2, uv_reg="chassis_steel")
    pb.add_cylinder((0.0, 0.56, 1.45), (0.0, 0.435, -2.075), 0.048, 0.048, 16, "Chassis", uv_reg="chassis_steel")
    for ax in (-0.24, 0.24):
        pb.add_cylinder((ax, 0.64, -1.32), (ax, 0.64, -0.72), 0.11, 0.11, 18, "Chassis", uv_reg="chassis_steel")
    pb.add_cylinder((0.28, 0.52, 0.95), (0.28, 0.52, 0.35), 0.12, 0.12, 18, "Chassis", uv_reg="chassis_steel")
    pb.add_tube_path([(0.28, 0.52, 0.35), (0.36, 0.48, -0.35), (0.68, 0.42, -0.65)], 0.034, 12, "Chassis", uv_reg="chassis_steel")

    # 4. Front & Rear Axles, Differential, Parabolic Leaf Springs & Shock Absorbers
    pb.add_bevel_box(-0.86, 0.86, 0.365, 0.425, 2.04, 2.11, "Chassis", bevel=0.012, segs=2, uv_reg="chassis_steel")
    for sgn in (-1.0, 1.0):
        pb.add_bevel_box(sgn * 0.43 - 0.04, sgn * 0.43 + 0.04, 0.43, 0.51, 1.55, 2.58, "Chassis", bevel=0.010, segs=2, uv_reg="chassis_steel")
        pb.add_cylinder((sgn * 0.52, 0.40, 2.02), (sgn * 0.48, 0.72, 1.96), 0.032, 0.028, 12, "Chassis", uv_reg="chassis_steel")

    pb.add_cylinder((-0.82, 0.435, -2.075), (0.82, 0.435, -2.075), 0.072, 0.072, 20, "Chassis", uv_reg="chassis_steel")
    pb.add_bevel_box(-0.22, 0.22, 0.28, 0.59, -2.26, -1.89, "Chassis", bevel=0.065, segs=3, uv_reg="chassis_steel")
    for sgn in (-1.0, 1.0):
        pb.add_bevel_box(sgn * 0.45 - 0.045, sgn * 0.45 + 0.045, 0.48, 0.58, -2.68, -1.46, "Chassis", bevel=0.012, segs=2, uv_reg="chassis_steel")
        pb.add_bevel_box(sgn * 0.45 - 0.04, sgn * 0.45 + 0.04, 0.55, 0.68, -2.68, -2.56, "Chassis", bevel=0.008, segs=1, uv_reg="chassis_steel")
        pb.add_bevel_box(sgn * 0.45 - 0.04, sgn * 0.45 + 0.04, 0.55, 0.68, -1.58, -1.46, "Chassis", bevel=0.008, segs=1, uv_reg="chassis_steel")
        pb.add_cylinder((sgn * 0.53, 0.39, -2.18), (sgn * 0.49, 0.74, -2.26), 0.035, 0.030, 12, "Chassis", uv_reg="chassis_steel")

    # 5. Curved Black Thermoplastic Rear Dual-Wheel Fenders / Mudguards & Mudflaps
    r_fend_in  = 0.475
    r_fend_out = 0.515
    for sgn in (-1.0, 1.0):
        fend_rings = []
        for i in range(n_arch + 1):
            ang = math.radians(-10.0 + 200.0 * i / n_arch)
            ca, sa = math.cos(ang), math.sin(ang)
            yi = 0.435 + r_fend_in * sa
            zi = -2.075 + r_fend_in * ca
            yo = 0.435 + r_fend_out * sa
            zo = -2.075 + r_fend_out * ca
            x_in  = sgn * 0.56
            x_out = sgn * 1.125
            ring = [
                (x_in,  yi, zi),
                (x_out, yi, zi),
                (x_out * 1.008, 0.5 * (yi + yo), 0.5 * (zi + zo)),
                (x_out, yo, zo),
                (x_in,  yo, zo),
            ]
            if sgn < 0:
                ring.reverse()
            fend_rings.append(ring)
        pb.add_loft(fend_rings, "Chassis", cap_start=True, cap_end=True, uv_reg="chassis_plast", scale_uv=0.5, smooth=True)
        pb.add_bevel_box(
            min(sgn * 0.58, sgn * 1.115), max(sgn * 0.58, sgn * 1.115),
            0.16, 0.44, -2.60, -2.565,
            "Rubber", bevel=0.004, segs=1
        )
        for sz in [-1.68, -2.46]:
            pb.add_cylinder((sgn * 0.44, 0.68, sz), (sgn * 1.08, 0.72, sz), 0.018, 0.018, 10, "Chassis", uv_reg="chassis_steel")

    # 6. Rear Underrun Protection Bumper Bars & Multi-Chamber Commercial Taillights
    for sgn in (-1.0, 1.0):
        pb.add_bevel_box(sgn * 0.45 - 0.04, sgn * 0.45 + 0.04, 0.39, 0.76, -3.68, -3.48, "Chassis", bevel=0.008, segs=1, uv_reg="chassis_steel")
    pb.add_bevel_box(-1.04, 1.04, 0.40, 0.48, -3.75, -3.64, "Chassis", bevel=0.010, segs=2, uv_reg="chassis_steel")
    pb.add_bevel_box(-1.06, 1.06, 0.58, 0.73, -3.73, -3.58, "Chassis", bevel=0.012, segs=2, uv_reg="chassis_steel")
    pb.add_bevel_box(-0.10, 0.10, 0.56, 0.72, -3.75, -3.62, "Chassis", bevel=0.012, segs=2, uv_reg="chassis_steel")
    pb.add_bevel_box(-0.26, 0.26, 0.49, 0.59, -3.74, -3.71, "Livery", bevel=0.004, segs=1, uv_reg="livery_plate")

    for sgn in (-1.0, 1.0):
        tx0, tx1 = min(sgn * 0.62, sgn * 1.02), max(sgn * 0.62, sgn * 1.02)
        pb.add_bevel_box(tx0 - 0.01, tx1 + 0.01, 0.59, 0.72, -3.735, -3.65, "Chassis", bevel=0.008, segs=1, uv_reg="chassis_plast")
        f_tl = pb.add_bevel_box(tx0, tx1, 0.605, 0.705, -3.745, -3.725, "Lamps", bevel=0.005, segs=1, uv_reg="lamps_tail")
        apply_planar_uv(pb.bm, f_tl, pb.mat_idx("Lamps"), pb.uv_layer, UV_REGIONS["lamps_tail"], axis='Z', flip_u=(sgn > 0))

    return pb.finish()

# ---------------------------------------------------------------------------
# 11. ASSEMBLE ALL MESH NODES & CALIBRATE EXACT CONTRACT BOUNDS
# ---------------------------------------------------------------------------
ALL_MESH_OBJS = [
    build_body_node(),
    build_truck_wheel("Wheel_FL", (+0.950, 0.435, +2.075), is_left=True,  is_rear_twin=False),
    build_truck_wheel("Wheel_FR", (-0.950, 0.435, +2.075), is_left=False, is_rear_twin=False),
    build_truck_wheel("Wheel_RL", (+0.835, 0.435, -2.075), is_left=True,  is_rear_twin=True),
    build_truck_wheel("Wheel_RR", (-0.835, 0.435, -2.075), is_left=False, is_rear_twin=True),
    build_steering_wheel(),
    build_cab_door(is_left=True),
    build_cab_door(is_left=False),
]

def count_total_tris():
    tot = 0
    for o in ALL_MESH_OBJS:
        for p in o.data.polygons:
            tot += len(p.vertices) - 2
    return tot

def calibrate_exact_bounds():
    """Ensure exact overall dimensions: 2.38 m wide (X), 2.55 m high (Y), 7.10 m long (Z), tyres touching Y = 0, Body at (0,0,0)."""
    bpy.context.view_layer.update()
    min_x, max_x = 1e9, -1e9
    min_y, max_y = 1e9, -1e9
    min_z, max_z = 1e9, -1e9
    for o in ALL_MESH_OBJS:
        mw = o.matrix_world
        for v in o.data.vertices:
            vw = mw @ 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)

    sx = 2.380 / (max_x - min_x)
    sy = 2.550 / (max_y - min_y)
    sz = 7.100 / (max_z - min_z)
    cx = 0.5 * (min_x + max_x)

    for o in ALL_MESH_OBJS:
        ox, oy, oz = o.location.x, o.location.z, -o.location.y
        if o.name == "Body":
            # Keep Body origin at exact (0.0, 0.0, 0.0) on ground plane midway between axles
            o.location = g2b(0.0, 0.0, 0.0)
            for v in o.data.vertices:
                gx, gy, gz = v.co.x + ox, v.co.z + oy, -v.co.y + oz
                ngx = (gx - cx) * sx
                ngy = (gy - min_y) * sy
                ngz = gz * sz
                v.co = g2b(ngx, ngy, ngz)
        else:
            nox = (ox - cx) * sx
            noy = (oy - min_y) * sy
            noz = oz * 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()
    bpy.context.view_layer.update()

calibrate_exact_bounds()
print(f"Total triangles across all mesh nodes: {count_total_tris()}")

# ---------------------------------------------------------------------------
# 12. CREATE REQUIRED EMPTY NODES (Seat_Driver, Door_L/R, Exhaust, Light_Head/Tail_L/R)
# ---------------------------------------------------------------------------
EMPTY_DEFS = {
    "Seat_Driver":  (+0.520, 1.220, +1.980),
    "Door_L":       (+1.080, 1.220, +2.220),
    "Door_R":       (-1.080, 1.220, +2.220),
    "Exhaust":      (+0.680, 0.420, -0.650),
    "Light_Head_L": (+0.780, 0.580, +3.300),
    "Light_Head_R": (-0.780, 0.580, +3.300),
    "Light_Tail_L": (+0.820, 0.655, -3.740),
    "Light_Tail_R": (-0.820, 0.655, -3.740),
}

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)
bpy.context.view_layer.update()

# ---------------------------------------------------------------------------
# 13. EXPORT GLB (flatbed-truck.glb <= 1.5 MB) & WRITE NODES.JSON
# ---------------------------------------------------------------------------
GLB_PATH = os.path.join(WORK_DIR, "flatbed-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.export_scene.gltf(
    filepath=GLB_PATH,
    export_format='GLB',
    export_yup=True,
    export_apply=True,
    export_texcoords=True,
    export_normals=True,
    export_materials='EXPORT',
    export_cameras=False,
    export_lights=False,
    export_image_format='JPEG',
    export_jpeg_quality=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,
)
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.12, 0.28, 0.52],  # Default: Municipal Utility Blue (matching sheet.jpg)
    [0.72, 0.14, 0.04],  # Highway Works Safety Orange-Red
    [0.04, 0.25, 0.11],  # Forestry & Municipal Deep Pine Green
    [0.76, 0.48, 0.03],  # Public Works Signal Amber-Yellow
    [0.44, 0.04, 0.05],  # Heavy Haulage Crimson Burgundy
    [0.16, 0.18, 0.21],  # Industrial Fleet Anthracite Slate
]

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

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

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

write_nodes_json()

# ---------------------------------------------------------------------------
# 14. RENDER FINAL.PNG, SIDE.PNG, CLAY.PNG & PAINTS.PNG
# ---------------------------------------------------------------------------
def setup_studio_environment():
    bpy.ops.mesh.primitive_plane_add(size=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.72, 0.73, 1.0)
    bsdf_g.inputs['Roughness'].default_value = 0.58
    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.77, 0.79, 1.0)
    bg.inputs['Strength'].default_value = 0.95

    lights = []
    for lname, loc_g, energy, size_x, size_y, col in [
        ("_KeyLight",  (+6.5, 6.8, +6.2), 950.0, 6.5, 4.0, (1.00, 0.98, 0.95)),
        ("_FillLight", (-6.8, 4.8, +4.2), 480.0, 6.0, 3.8, (0.93, 0.96, 1.00)),
        ("_RimLight",  (-2.8, 6.5, -6.5), 620.0, 7.0, 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.15, 0.0) - lobj.location
        lobj.rotation_euler = direction.to_track_quat('-Z', 'Y').to_euler()
        lights.append(lobj)

    return ground, lights

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

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

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

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

    set_paint_factor(PAINTS_LIST[0])

    hero_cam_loc = (+6.55, 2.05, +7.25)
    hero_target  = (-0.05, 1.12, -0.25)

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

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

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

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

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

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

    set_paint_factor(PAINTS_LIST[0])

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