#!/usr/bin/env python3
"""
Build script for modern European box-body emergency ambulance on heavy van chassis (French SAMU / SMUR style).
Follows Forge Garage contract v2.
Dimensions: 6.40 m long, 2.20 m wide, 2.85 m high, wheelbase 3.95 m.
"""

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

def rot_mat_euler(e, order='XYZ'):
    return Euler(e, order).to_matrix().to_4x4()

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

POLYHAVEN_IDS = ["metal_plate", "rubber_tiles", "fabric_leather_02"]

def ensure_polyhaven_textures():
    for pid in POLYHAVEN_IDS:
        for m in ["diff", "nor_gl", "arm"]:
            fname = f"{pid}_{m}_1k.jpg"
            fpath = os.path.join(TEX_DIR, fname)
            if not os.path.exists(fpath):
                url = f"https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{pid}/{fname}"
                print(f"Downloading {url}...")
                try:
                    req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
                    with urllib.request.urlopen(req, timeout=20) as resp, open(fpath, 'wb') as out:
                        out.write(resp.read())
                except Exception as e:
                    print(f"Warning: could not download {fname}: {e}")

ensure_polyhaven_textures()

def generate_livery_source():
    W = 1024
    img_liv = Image.new("RGB", (W, W), (236, 239, 244))
    draw = ImageDraw.Draw(img_liv)
    
    c_white  = (236, 239, 244)
    c_blue   = (10, 68, 182)     # Reflective SAMU blue
    c_yellow = (244, 222, 12)    # High-vis euro yellow
    c_red    = (212, 22, 26)     # High-vis reflective red
    
    font_paths = [
        "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
        "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
        "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf"
    ]
    font_path = next((p for p in font_paths if os.path.exists(p)), None)
    def get_font(sz):
        if font_path:
            return ImageFont.truetype(font_path, sz)
        return ImageFont.load_default()

    # Band 1: rows 768..1024 (UV v = 0.0 to 0.25) -> Side main beltline stripe + "AMBULANCE"
    draw.rectangle([0, 768, W, 1024], fill=c_white)
    draw.rectangle([0, 778, W, 902], fill=c_blue)
    draw.rectangle([0, 912, W, 944], fill=c_red)
    draw.rectangle([0, 944, W, 1012], fill=c_yellow)
    
    f_side = get_font(86)
    txt = "AMBULANCE"
    bbox = draw.textbbox((0, 0), txt, font=f_side)
    tw = bbox[2] - bbox[0]
    draw.text(((W - tw) // 2, 794), txt, fill=(248, 250, 255), font=f_side)

    # Band 2: rows 563..768 (UV v = 0.25 to 0.45) -> Solid reflective color swatches
    # u in [0.00, 0.25]: Yellow reflective
    # u in [0.25, 0.50]: Blue reflective
    # u in [0.50, 0.75]: Red reflective
    # u in [0.75, 1.00]: Silver / brushed aluminum
    draw.rectangle([0, 563, 256, 768], fill=c_yellow)
    draw.rectangle([256, 563, 512, 768], fill=c_blue)
    draw.rectangle([512, 563, 768, 768], fill=c_red)
    draw.rectangle([768, 563, 1024, 768], fill=(195, 200, 206))

    # Band 3: rows 358..563 (UV v = 0.45 to 0.65) -> Front Hood "AMBULANCE" on White + Yellow lower lip
    # Draw Band 3 on a sub-image and paste flipped vertically so UV v=0.65 (+Z front lip) is at the bottom of the text!
    b3_h = 563 - 358
    img_b3 = Image.new("RGB", (W, b3_h), c_white)
    dr_b3 = ImageDraw.Draw(img_b3)
    dr_b3.rectangle([0, b3_h - 42, W, b3_h], fill=c_yellow)
    dr_b3.polygon([(0, 0), (95, 0), (55, b3_h), (0, b3_h)], fill=c_blue)
    dr_b3.polygon([(55, 0), (130, 0), (90, b3_h), (55, b3_h)], fill=c_yellow)
    dr_b3.polygon([(W, 0), (W-95, 0), (W-55, b3_h), (W, b3_h)], fill=c_blue)
    dr_b3.polygon([(W-55, 0), (W-130, 0), (W-90, b3_h), (W-55, b3_h)], fill=c_yellow)
    f_hood = get_font(76)
    bbox_h = dr_b3.textbbox((0, 0), txt, font=f_hood)
    twh = bbox_h[2] - bbox_h[0]
    dr_b3.text(((W - twh) // 2, 54), txt, fill=c_blue, font=f_hood)
    img_liv.paste(img_b3, (0, 358))

    # Band 4: rows 205..358 (UV v = 0.65 to 0.80) -> Cab Aerodynamic Roof Scoop Livery
    draw.rectangle([0, 205, W, 358], fill=c_white)
    draw.rectangle([0, 322, W, 358], fill=c_yellow)
    draw.polygon([(0, 205), (140, 205), (90, 358), (0, 358)], fill=c_red)
    draw.polygon([(140, 205), (210, 205), (155, 358), (90, 358)], fill=c_blue)
    draw.polygon([(210, 205), (260, 205), (205, 358), (155, 358)], fill=c_yellow)
    draw.polygon([(W, 205), (W-140, 205), (W-90, 358), (W, 358)], fill=c_red)
    draw.polygon([(W-140, 205), (W-210, 205), (W-155, 358), (W-90, 358)], fill=c_blue)
    draw.polygon([(W-210, 205), (W-260, 205), (W-205, 358), (W-155, 358)], fill=c_yellow)

    # Band 5: rows 102..205 (UV v = 0.80 to 0.90) -> License plate (left half) + Star of Life (right half)
    img_pl = Image.new("RGB", (512, 103), (245, 247, 250))
    dr_pl = ImageDraw.Draw(img_pl)
    dr_pl.rectangle([8, 6, 504, 96], outline=(30, 35, 40), width=4)
    dr_pl.rectangle([12, 10, 68, 92], fill=c_blue)
    f_plate_sm = get_font(28)
    dr_pl.text((28, 50), "F", fill=c_white, font=f_plate_sm)
    f_plate = get_font(54)
    dr_pl.text((92, 22), "SM-112-UR", fill=(20, 22, 25), font=f_plate)
    img_liv.paste(img_pl, (0, 102))

    # Right half of Band 5 & Band 6 (x=512..1024, y=0..205): Star of Life on White for Box Roof
    draw.rectangle([512, 0, 1024, 205], fill=c_white)
    cx, cy = 768, 102
    for angle in [0, 60, 120]:
        rad = math.radians(angle)
        dx, dy = math.cos(rad) * 75, math.sin(rad) * 75
        nx, ny = -math.sin(rad) * 20, math.cos(rad) * 20
        pts = [(cx - dx + nx, cy - dy + ny), (cx + dx + nx, cy + dy + ny),
               (cx + dx - nx, cy + dy - ny), (cx - dx - nx, cy - dy - ny)]
        draw.polygon(pts, fill=c_blue)

    # Band 6 left half (x=0..512, y=0..102, UV v = 0.90 to 1.00): Cab Door & Rear Door Stripe (Blue top, white gap, red, yellow)
    draw.rectangle([0, 0, 512, 102], fill=c_white)
    draw.rectangle([0, 6, 512, 52], fill=c_blue)
    draw.rectangle([0, 58, 512, 74], fill=c_red)
    draw.rectangle([0, 74, 512, 98], fill=c_yellow)

    liv_path = os.path.join(TEX_DIR, "livery_source.png")
    img_liv.save(liv_path)
    return liv_path

LIV_SOURCE_PATH = generate_livery_source()

# Clear scene
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

# Configure Cycles GPU
scene.render.engine = 'CYCLES'
prefs = bpy.context.preferences.addons['cycles'].preferences
prefs.refresh_devices()
for dev_type in ['OPTIX', 'CUDA']:
    try:
        prefs.compute_device_type = dev_type
        for d in prefs.devices:
            d.use = True
        break
    except Exception:
        pass
scene.cycles.device = 'GPU'

# ==============================================================================
# MATERIALS SETUP (6 Materials: Paint, Livery, Chassis_Trim, Rubber_Tires, Glass, Lamps)
# ==============================================================================

def create_materials():
    mats = {}
    for mname in ["Paint", "Livery", "Chassis_Trim", "Rubber_Tires", "Glass", "Lamps"]:
        m = bpy.data.materials.new(mname)
        m.use_nodes = True
        mats[mname] = m

    # Glass (alpha BLEND, tinted, low roughness)
    m_glass = mats["Glass"]
    if hasattr(m_glass, "blend_method"):
        m_glass.blend_method = 'BLEND'
    if hasattr(m_glass, "surface_render_method"):
        m_glass.surface_render_method = 'BLENDED'
    nt = m_glass.node_tree
    nt.nodes.clear()
    out = nt.nodes.new("ShaderNodeOutputMaterial")
    bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
    bsdf.inputs["Base Color"].default_value = (0.09, 0.14, 0.17, 1.0)
    bsdf.inputs["Roughness"].default_value = 0.05
    bsdf.inputs["Metallic"].default_value = 0.10
    bsdf.inputs["Alpha"].default_value = 0.45
    if "IOR" in bsdf.inputs:
        bsdf.inputs["IOR"].default_value = 1.50
    nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])

    # Lamps (Emissive headlights, tail lights, amber markers, blue/red emergency strobes)
    m_lamps = mats["Lamps"]
    nt = m_lamps.node_tree
    nt.nodes.clear()
    out = nt.nodes.new("ShaderNodeOutputMaterial")
    bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
    vcol_l = nt.nodes.new("ShaderNodeVertexColor")
    vcol_l.layer_name = "Col"
    nt.links.new(vcol_l.outputs["Color"], bsdf.inputs["Base Color"])
    if "Emission Color" in bsdf.inputs:
        nt.links.new(vcol_l.outputs["Color"], bsdf.inputs["Emission Color"])
        bsdf.inputs["Emission Strength"].default_value = 3.2
    bsdf.inputs["Roughness"].default_value = 0.10
    nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])

    return mats

MATS = create_materials()
ALL_MAT_NAMES = ["Paint", "Livery", "Chassis_Trim", "Rubber_Tires", "Glass", "Lamps"]

# ==============================================================================
# GEOMETRY HELPERS (WITH SUBDIVIDE BEFORE BEVEL FOR CRISP CAD-LIKE CHAMFERS)
# ==============================================================================

def set_face_uv_box(bm, faces, uv_layer, scale=0.4):
    for f in faces:
        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:
                u, v = co.z * scale, co.y * scale
            elif ay >= ax and ay >= az:
                u, v = co.x * scale, co.z * scale
            else:
                u, v = co.x * scale, co.y * scale
            loop[uv_layer].uv = (u, v)

def set_face_uv_rect(bm, faces, uv_layer, axis, bounds_u, bounds_v, uv_rect, flip_u=False, flip_v=False):
    u0, v0, u1, v1 = uv_rect
    du = bounds_u[1] - bounds_u[0] if abs(bounds_u[1] - bounds_u[0]) > 1e-6 else 1.0
    dv = bounds_v[1] - bounds_v[0] if abs(bounds_v[1] - bounds_v[0]) > 1e-6 else 1.0
    for f in faces:
        for loop in f.loops:
            co = loop.vert.co
            if axis == 'X': # Side view: Z is horizontal, Y is vertical
                su = (co.z - bounds_u[0]) / du
                sv = (co.y - bounds_v[0]) / dv
            elif axis == 'Z': # Front/Rear view: X is horizontal, Y is vertical
                su = (co.x - bounds_u[0]) / du
                sv = (co.y - bounds_v[0]) / dv
            elif axis == 'Y': # Top view: X is horizontal, Z is vertical
                su = (co.x - bounds_u[0]) / du
                sv = (co.z - bounds_v[0]) / dv
            su = max(0.0, min(1.0, su))
            sv = max(0.0, min(1.0, sv))
            if flip_u:
                su = 1.0 - su
            if flip_v:
                sv = 1.0 - sv
            loop[uv_layer].uv = (u0 + su * (u1 - u0), v0 + sv * (v1 - v0))

def add_box_part(bm, uv_layer, col_layer, mat_idx, center, size, bevel=0.015, segs=2, col=(0.1, 0.1, 0.1, 1.0),
                 uv_rect=None, uv_axis='X', flip_u=False, flip_v=False, subdiv_cuts=0):
    ret = bmesh.ops.create_cube(bm, size=1.0)
    verts = ret['verts']
    bmesh.ops.scale(bm, vec=size, verts=verts)
    bmesh.ops.translate(bm, vec=center, verts=verts)
    edges = list({e for v in verts for e in v.link_edges})
    if subdiv_cuts > 0:
        s_ret = bmesh.ops.subdivide_edges(bm, edges=edges, cuts=subdiv_cuts, use_grid_fill=True, smooth=0.0)
        verts = [v for v in (s_ret.get('geom', []) + verts) if isinstance(v, bmesh.types.BMVert) and v.is_valid]
    if bevel > 0.0:
        sharp_edges = [e for e in {e for v in verts if v.is_valid for e in v.link_edges}
                       if len(e.link_faces) == 2 and e.link_faces[0].normal.dot(e.link_faces[1].normal) < 0.8]
        if sharp_edges:
            b_ret = bmesh.ops.bevel(bm, geom=sharp_edges, offset=bevel, segments=segs, profile=0.5, affect='EDGES')
            verts = [v for v in (b_ret.get('verts', []) + verts) if isinstance(v, bmesh.types.BMVert) and v.is_valid]
    faces = list({f for v in verts if v.is_valid for f in v.link_faces if f.is_valid})
    for f in faces:
        f.material_index = mat_idx
        f.smooth = True
        for loop in f.loops:
            loop[col_layer] = col
    if uv_rect is not None:
        if uv_axis == 'X':
            bu = (center[2] - size[2]*0.5, center[2] + size[2]*0.5)
            bv = (center[1] - size[1]*0.5, center[1] + size[1]*0.5)
        elif uv_axis == 'Z':
            bu = (center[0] - size[0]*0.5, center[0] + size[0]*0.5)
            bv = (center[1] - size[1]*0.5, center[1] + size[1]*0.5)
        else:
            bu = (center[0] - size[0]*0.5, center[0] + size[0]*0.5)
            bv = (center[2] - size[2]*0.5, center[2] + size[2]*0.5)
        set_face_uv_rect(bm, faces, uv_layer, uv_axis, bu, bv, uv_rect, flip_u=flip_u, flip_v=flip_v)
    else:
        set_face_uv_box(bm, faces, uv_layer, scale=0.4)
    return faces

def add_cyl_part(bm, uv_layer, col_layer, mat_idx, center, radius, depth, rot_euler=(0,0,0), segs=24, bevel=0.008, col=(0.15, 0.15, 0.15, 1.0)):
    ret = bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=segs, radius1=radius, radius2=radius, depth=depth)
    verts = ret['verts']
    rot_mat = rot_mat_euler(rot_euler, 'XYZ')
    bmesh.ops.rotate(bm, matrix=rot_mat, verts=verts)
    bmesh.ops.translate(bm, vec=center, verts=verts)
    faces = list({f for v in verts for f in v.link_faces})
    if bevel > 0.0:
        cap_edges = [e for e in {e for v in verts if v.is_valid for e in v.link_edges} if len(e.link_faces) == 2 and abs(e.link_faces[0].normal.dot(e.link_faces[1].normal)) < 0.5]
        if cap_edges:
            b_ret = bmesh.ops.bevel(bm, geom=cap_edges, offset=bevel, segments=2, profile=0.5, affect='EDGES')
            verts = [v for v in (b_ret.get('verts', []) + verts) if isinstance(v, bmesh.types.BMVert) and v.is_valid]
            faces = list({f for v in verts for f in v.link_faces if f.is_valid})
    for f in faces:
        f.material_index = mat_idx
        f.smooth = True
        for loop in f.loops:
            loop[col_layer] = col
    set_face_uv_box(bm, faces, uv_layer, scale=1.0)
    return faces

def add_open_wheel_arch_flare(bm, uv_layer, col_layer, mat_idx, z_center, y_center, r_in, r_out, x_in, x_out, col=(0.10, 0.11, 0.12, 1.0)):
    """Build an OPEN U-shaped wheel arch fender lip (open at the bottom so the wheel & hub are 100% visible!)."""
    arch_segs = 18
    arch_rings = []
    for i in range(arch_segs + 1):
        ang = math.pi * i / arch_segs
        ca, sa = math.cos(ang), math.sin(ang)
        p0 = Vector((x_in,  y_center + sa * r_in,  z_center + ca * r_in))
        p1 = Vector((x_out, y_center + sa * r_in,  z_center + ca * r_in))
        p2 = Vector((x_out, y_center + sa * r_out, z_center + ca * r_out))
        p3 = Vector((x_in,  y_center + sa * r_out, z_center + ca * r_out))
        arch_rings.append([bm.verts.new(p) for p in [p0, p1, p2, p3]])
    faces = []
    for i in range(arch_segs):
        r0, r1 = arch_rings[i], arch_rings[i+1]
        for j in range(4):
            jn = (j + 1) % 4
            f = bm.faces.new([r0[j], r0[jn], r1[jn], r1[j]])
            f.material_index = mat_idx
            f.smooth = True
            for lp in f.loops:
                lp[col_layer] = col
            faces.append(f)
    set_face_uv_box(bm, faces, uv_layer)
    return faces

def assign_materials_to_obj(obj, mat_names):
    obj.data.materials.clear()
    for name in mat_names:
        obj.data.materials.append(MATS[name])

# ==============================================================================
# 1. BUILD BODY NODE (Cab + Medical Box Body + Lockers + Strobe Lamps + Interior)
# ==============================================================================

def build_body_node():
    mesh = bpy.data.meshes.new("Body_Mesh")
    obj = bpy.data.objects.new("Body", mesh)
    bpy.context.collection.objects.link(obj)
    assign_materials_to_obj(obj, ALL_MAT_NAMES)

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

    # --------------------------------------------------------------------------
    # A. MEDICAL BOX BODY (Z from -3.12 to +0.58, X from -1.07 to +1.07, Y from 0.42 to 2.81)
    # Built as 3 modular sections around the rear wheel arch (Z in [-2.46, -1.49], Y in [0.42, 0.90])
    # so the rear wheel well is 100% open and never covers the rear wheels!
    # --------------------------------------------------------------------------
    # 1. Upper main medical box body (full length Z from -3.12 to +0.58, Y from 0.90 to 2.81)
    add_box_part(bm, uv_layer, col_layer, 0, (0.0, 1.855, -1.27), (2.14, 1.91, 3.70), bevel=0.035, segs=3, col=(0.95, 0.95, 0.96, 1.0), subdiv_cuts=3)
    # 2. Lower forward box skirt ahead of rear wheel arch (Z from -1.49 to +0.58, Y from 0.42 to 0.92)
    add_box_part(bm, uv_layer, col_layer, 0, (0.0, 0.67, -0.455), (2.14, 0.50, 2.07), bevel=0.02, segs=2, col=(0.95, 0.95, 0.96, 1.0), subdiv_cuts=2)
    # 3. Lower rear box skirt behind rear wheel arch (Z from -3.12 to -2.46, Y from 0.42 to 0.92)
    add_box_part(bm, uv_layer, col_layer, 0, (0.0, 0.67, -2.79), (2.14, 0.50, 0.66), bevel=0.02, segs=2, col=(0.95, 0.95, 0.96, 1.0), subdiv_cuts=1)
    # 4. Inboard recessed wheel well backing wall between the rear wheels (X from -0.62 to +0.62, Z from -2.48 to -1.47, Y from 0.42 to 0.92)
    add_box_part(bm, uv_layer, col_layer, 2, (0.0, 0.67, -1.975), (1.24, 0.50, 1.01), bevel=0.01, segs=1, col=(0.06, 0.06, 0.07, 1.0))

    # Extruded corner aluminum structural profiles (vertical pillars & top/bottom rails)
    for sx in [-1.0, 1.0]:
        add_box_part(bm, uv_layer, col_layer, 0, (sx * 1.072, 1.615,  0.56), (0.03, 2.39, 0.06), bevel=0.008, segs=2, subdiv_cuts=1)
        add_box_part(bm, uv_layer, col_layer, 0, (sx * 1.072, 1.615, -3.10), (0.03, 2.39, 0.06), bevel=0.008, segs=2, subdiv_cuts=1)
        add_box_part(bm, uv_layer, col_layer, 0, (sx * 1.072, 2.795, -1.27), (0.03, 0.045, 3.70), bevel=0.008, segs=2, subdiv_cuts=2)
        # Bottom rail split into forward & aft sections around the rear wheel arch!
        add_box_part(bm, uv_layer, col_layer, 0, (sx * 1.068, 0.445, -0.455), (0.035, 0.06, 2.07), bevel=0.008, segs=2, subdiv_cuts=1)
        add_box_part(bm, uv_layer, col_layer, 0, (sx * 1.068, 0.445, -2.79),  (0.035, 0.06, 0.66), bevel=0.008, segs=2, subdiv_cuts=1)

    # Open U-shaped Rear Wheel Arch Fender Flares + Deep recessed inner wheel housing
    for sx in [-1.0, 1.0]:
        add_open_wheel_arch_flare(bm, uv_layer, col_layer, 2, -1.975, 0.41, 0.43, 0.495, sx * 1.03, sx * 1.095, col=(0.10, 0.11, 0.12, 1.0))
        add_cyl_part(bm, uv_layer, col_layer, 2, (sx * 0.58, 0.41, -1.975), 0.44, 0.16, rot_euler=(0, math.pi*0.5, 0), segs=20, bevel=0.0, col=(0.05, 0.05, 0.06, 1.0))
        add_cyl_part(bm, uv_layer, col_layer, 2, (sx * 0.58, 0.41,  1.975), 0.44, 0.16, rot_euler=(0, math.pi*0.5, 0), segs=20, bevel=0.0, col=(0.05, 0.05, 0.06, 1.0))
        add_box_part(bm, uv_layer, col_layer, 2, (sx * 0.94, 0.28, -2.47), (0.26, 0.26, 0.02), bevel=0.004, segs=1, col=(0.06, 0.06, 0.07, 1.0))

    # --------------------------------------------------------------------------
    # B. SIDE EQUIPMENT LOCKERS (Matching sheet.jpg!)
    # --------------------------------------------------------------------------
    for sx in [-1.0, 1.0]:
        x_gasket = sx * 1.072
        x_panel  = sx * 1.079
        x_handle = sx * 1.087

        def add_locker(z0, z1, y0, y1, has_top_hinge=False):
            zc, yc = 0.5 * (z0 + z1), 0.5 * (y0 + y1)
            zw, yh = (z1 - z0), (y1 - y0)
            add_box_part(bm, uv_layer, col_layer, 2, (x_gasket, yc, zc), (0.012, yh + 0.020, zw + 0.020), bevel=0.003, segs=1, col=(0.14, 0.15, 0.17, 1.0), subdiv_cuts=1)
            add_box_part(bm, uv_layer, col_layer, 0, (x_panel, yc, zc), (0.014, yh - 0.008, zw - 0.008), bevel=0.005, segs=2, col=(0.95, 0.95, 0.96, 1.0), subdiv_cuts=1)
            lz = z0 + 0.09 if not has_top_hinge else zc
            ly = yc if not has_top_hinge else y0 + 0.09
            add_box_part(bm, uv_layer, col_layer, 2, (x_handle, ly, lz), (0.012, 0.13, 0.07), bevel=0.004, segs=1, col=(0.08, 0.09, 0.10, 1.0))
            add_box_part(bm, uv_layer, col_layer, 2, (x_handle + sx * 0.004, ly, lz), (0.008, 0.075, 0.032), bevel=0.002, segs=1, col=(0.68, 0.71, 0.75, 1.0))
            hz = z1 - 0.01 if not has_top_hinge else zc
            for hy in [y0 + 0.14, y1 - 0.14]:
                add_box_part(bm, uv_layer, col_layer, 2, (x_handle, hy, hz), (0.012, 0.05, 0.022), bevel=0.003, segs=1, col=(0.58, 0.61, 0.65, 1.0))

        add_locker(-0.28, 0.34, 0.76, 1.86, has_top_hinge=False)
        add_locker(-1.42, 0.34, 0.49, 0.72, has_top_hinge=True)
        add_locker(-2.96, -2.38, 0.52, 2.08, has_top_hinge=False)

    # --------------------------------------------------------------------------
    # C. INTEGRATED CORNER EMERGENCY STROBE LAMPS ON BOX BODY (Matching sheet.jpg!)
    # --------------------------------------------------------------------------
    def add_strobe_pod(pos, size, lamp_col, push_dir):
        # Black bezel housing backing plate
        add_box_part(bm, uv_layer, col_layer, 2, pos, (size[0]+0.024, size[1]+0.024, size[2]+0.024), bevel=0.006, segs=2, col=(0.08, 0.08, 0.09, 1.0))
        # Colored emissive lens protruding 18 mm past the bezel so the blue/red LED lens is 100% visible!
        lpos = (pos[0] + push_dir[0]*0.018, pos[1] + push_dir[1]*0.018, pos[2] + push_dir[2]*0.018)
        add_box_part(bm, uv_layer, col_layer, 5, lpos, size, bevel=0.008, segs=2, col=lamp_col)

    c_lamp_blue  = (0.03, 0.38, 1.00, 1.0)
    c_lamp_red   = (1.00, 0.04, 0.04, 1.0)
    c_lamp_amber = (1.00, 0.55, 0.02, 1.0)
    c_lamp_white = (1.00, 0.96, 0.88, 1.0)

    for sx in [-1.0, 1.0]:
        # Side upper front dual pods (Blue at Z=+0.34, Red at Z=+0.12, Y=2.65)
        add_strobe_pod((sx * 1.084, 2.65,  0.34), (0.028, 0.11, 0.16), c_lamp_blue, (sx, 0, 0))
        add_strobe_pod((sx * 1.084, 2.65,  0.12), (0.028, 0.11, 0.16), c_lamp_red,  (sx, 0, 0))
        # Side upper rear dual pods (Blue at Z=-2.72, Red at Z=-2.94, Y=2.65)
        add_strobe_pod((sx * 1.084, 2.65, -2.72), (0.028, 0.11, 0.16), c_lamp_blue, (sx, 0, 0))
        add_strobe_pod((sx * 1.084, 2.65, -2.94), (0.028, 0.11, 0.16), c_lamp_red,  (sx, 0, 0))
        # Front upper face of box (above cab roof): Red outer strobe at X=±0.90, Y=2.65, Z=+0.585
        add_strobe_pod((sx * 0.90, 2.65, 0.585), (0.16, 0.11, 0.028), c_lamp_red, (0, 0, 1))
        # Rear upper face of box (above twin rear doors): Outer Red + Middle Amber + Inner Blue pods at Y=2.67, Z=-3.125
        add_strobe_pod((sx * 0.90, 2.67, -3.125), (0.15, 0.10, 0.028), c_lamp_red,   (0, 0, -1))
        add_strobe_pod((sx * 0.70, 2.67, -3.125), (0.13, 0.09, 0.028), c_lamp_amber, (0, 0, -1))
        add_strobe_pod((sx * 0.50, 2.67, -3.125), (0.13, 0.09, 0.028), c_lamp_blue,  (0, 0, -1))
        # Side lower amber marker reflectors along bottom rail
        for zm in [0.42, -1.25, -2.98]:
            add_box_part(bm, uv_layer, col_layer, 5, (sx * 1.085, 0.445, zm), (0.014, 0.028, 0.075), bevel=0.003, segs=1, col=c_lamp_amber)

    # Roof circular ventilator dome & Star of Life roof decal panel
    add_cyl_part(bm, uv_layer, col_layer, 0, (0.0, 2.825, -0.85), 0.24, 0.05, rot_euler=(math.pi*0.5, 0, 0), segs=24, bevel=0.010, col=(0.95, 0.95, 0.96, 1.0))
    add_box_part(bm, uv_layer, col_layer, 1, (0.0, 2.812, -1.85), (1.30, 0.008, 1.30), bevel=0.002, segs=1,
                 uv_rect=(0.52, 0.80, 0.98, 0.99), uv_axis='Y')

    # --------------------------------------------------------------------------
    # D. BOX LIVERY STRIPES & CHEVRONS (Material 1: Livery)
    # --------------------------------------------------------------------------
    for sx in [-1.0, 1.0]:
        x_liv = sx * 1.092
        flip_side = (sx > 0)
        # 1. Main horizontal beltline stripe with "AMBULANCE" lettering across middle of box (Z from -2.35 to +0.54, Y from 0.94 to 1.38)
        add_box_part(bm, uv_layer, col_layer, 1, (x_liv, 1.16, -0.90), (0.008, 0.42, 2.86), bevel=0.002, segs=1,
                     uv_rect=(0.02, 0.01, 0.98, 0.24), uv_axis='X', flip_u=flip_side, subdiv_cuts=2)
        # Continuation of beltline stripe across rear locker door (solid Blue/Red/Yellow stripe Band 6 without cut-off letters!)
        add_box_part(bm, uv_layer, col_layer, 1, (x_liv, 1.16, -2.725), (0.008, 0.42, 0.69), bevel=0.002, segs=1,
                     uv_rect=(0.02, 0.905, 0.48, 0.995), uv_axis='X', subdiv_cuts=1)

        # 2. Upper yellow horizontal fascia stripe along top of box (Z from -2.18 to +0.55, Y from 2.57 to 2.74)
        add_box_part(bm, uv_layer, col_layer, 1, (x_liv, 2.655, -0.815), (0.006, 0.17, 2.73), bevel=0.001, segs=1,
                     uv_rect=(0.02, 0.28, 0.23, 0.42), uv_axis='X', subdiv_cuts=2)

        # 3. Upper rear Blue block behind diagonal chevron (Z from -3.08 to -2.35, Y from 2.28 to 2.74)
        add_box_part(bm, uv_layer, col_layer, 1, (x_liv, 2.51, -2.715), (0.006, 0.46, 0.73), bevel=0.001, segs=1,
                     uv_rect=(0.27, 0.28, 0.48, 0.42), uv_axis='X', subdiv_cuts=1)

        # 4. Iconic SAMU diagonal 3-color slanted stripe (Yellow / Red / Blue) built as solid 4-face extruded prisms so subdivide never tears!
        stripes_diag = [
            (-1.60, -2.16, (0.02, 0.28, 0.23, 0.42)), # Yellow front diagonal
            (-1.70, -2.26, (0.52, 0.28, 0.73, 0.42)), # Red middle diagonal
            (-1.80, -2.36, (0.27, 0.28, 0.48, 0.42)), # Blue rear diagonal
        ]
        for z_bot, z_top, uvr in stripes_diag:
            w_s = 0.095
            y_bot, y_top = 1.37, 2.74
            x0, x1 = x_liv - sx*0.002, x_liv + sx*0.004
            v_front = [
                bm.verts.new(Vector((x1, y_bot, z_bot))),
                bm.verts.new(Vector((x1, y_bot, z_bot - w_s))),
                bm.verts.new(Vector((x1, y_top, z_top - w_s))),
                bm.verts.new(Vector((x1, y_top, z_top))),
            ]
            v_back = [
                bm.verts.new(Vector((x0, y_bot, z_bot))),
                bm.verts.new(Vector((x0, y_bot, z_bot - w_s))),
                bm.verts.new(Vector((x0, y_top, z_top - w_s))),
                bm.verts.new(Vector((x0, y_top, z_top))),
            ]
            if sx < 0:
                v_front.reverse()
                v_back.reverse()
            f_out = bm.faces.new(v_front)
            f_in  = bm.faces.new(list(reversed(v_back)))
            s_faces = [f_out, f_in]
            for k in range(4):
                kn = (k + 1) % 4
                s_faces.append(bm.faces.new([v_front[k], v_front[kn], v_back[kn], v_back[k]]))
            for f in s_faces:
                f.material_index = 1
                f.smooth = True
                for lp in f.loops:
                    lp[col_layer] = (1, 1, 1, 1)
                    lp[uv_layer].uv = (0.5*(uvr[0]+uvr[2]), 0.5*(uvr[1]+uvr[3]))

    # --------------------------------------------------------------------------
    # E. AERODYNAMIC VAN CAB (Sprinter-style nose, Z from +0.58 to +3.20)
    # --------------------------------------------------------------------------
    for sx in [-1.0, 1.0]:
        add_box_part(bm, uv_layer, col_layer, 2, (sx * 0.52, 0.36, 0.0), (0.12, 0.16, 5.85), bevel=0.01, segs=1, col=(0.12, 0.13, 0.14, 1.0), subdiv_cuts=2)

    # 2. Front engine bay / fender cheeks & sculpted aerodynamic hood (Z from +1.84 to +3.12)
    # Notice: Over the front wheel arch (Z in [1.84, 2.48]), the lower side cheek stops at Y = 0.88 so the front wheel is completely open!
    hood_stations = [
        (1.84, 0.88, 1.38, 1.33, 0.94, 0.91),
        (2.15, 0.88, 1.31, 1.26, 0.93, 0.89),
        (2.48, 0.72, 1.22, 1.17, 0.92, 0.86),
        (2.78, 0.52, 1.12, 1.07, 0.89, 0.81),
        (3.02, 0.48, 0.99, 0.95, 0.84, 0.74),
        (3.12, 0.48, 0.86, 0.82, 0.78, 0.68),
    ]
    ring_verts = []
    for (z, yb, ytc, yts, hwb, hwt) in hood_stations:
        pts = [
            Vector((-hwb, yb, z)),
            Vector((-hwb*1.01, 0.5*(yb+yts), z)),
            Vector((-hwt, yts, z)),
            Vector((-hwt*0.45, ytc, z)),
            Vector(( hwt*0.45, ytc, z)),
            Vector(( hwt, yts, z)),
            Vector(( hwb*1.01, 0.5*(yb+yts), z)),
            Vector(( hwb, yb, z)),
        ]
        ring_verts.append([bm.verts.new(p) for p in pts])

    for i in range(len(ring_verts) - 1):
        r0, r1 = ring_verts[i], ring_verts[i + 1]
        for j in range(len(r0) - 1):
            f = bm.faces.new([r0[j], r0[j+1], r1[j+1], r1[j]])
            f.material_index = 0
            f.smooth = True
            for lp in f.loops:
                lp[col_layer] = (0.95, 0.95, 0.96, 1.0)
            set_face_uv_box(bm, [f], uv_layer, scale=0.4)

    # 3. Hood "AMBULANCE" Livery panel (proud by 8 mm; flip_u=True, flip_v=True so it reads right-side-up left-to-right from the front!)
    hood_liv_rings = []
    for (z, yb, ytc, yts, hwb, hwt) in hood_stations[1:5]:
        pts = [
            Vector((-hwt*0.92, yts + 0.008, z)),
            Vector((-hwt*0.42, ytc + 0.008, z)),
            Vector(( hwt*0.42, ytc + 0.008, z)),
            Vector(( hwt*0.92, yts + 0.008, z)),
        ]
        hood_liv_rings.append([bm.verts.new(p) for p in pts])
    hood_liv_faces = []
    for i in range(len(hood_liv_rings) - 1):
        r0, r1 = hood_liv_rings[i], hood_liv_rings[i + 1]
        for j in range(len(r0) - 1):
            f = bm.faces.new([r0[j], r0[j+1], r1[j+1], r1[j]])
            f.material_index = 1
            f.smooth = True
            for lp in f.loops:
                lp[col_layer] = (1, 1, 1, 1)
            hood_liv_faces.append(f)
    set_face_uv_rect(bm, hood_liv_faces, uv_layer, 'Y', (-0.80, 0.80), (2.15, 2.78), (0.04, 0.46, 0.96, 0.64), flip_u=False, flip_v=True)

    # Hood twin air intake vents below windshield
    for sx in [-1.0, 1.0]:
        add_box_part(bm, uv_layer, col_layer, 2, (sx * 0.52, 1.368, 1.96), (0.34, 0.02, 0.13), bevel=0.005, segs=1, col=(0.08, 0.08, 0.09, 1.0))

    # 4. Open U-shaped Front Wheel Arch Flares (around Z = +1.975, Y = 0.41)
    for sx in [-1.0, 1.0]:
        add_open_wheel_arch_flare(bm, uv_layer, col_layer, 2, 1.975, 0.41, 0.43, 0.49, sx * 0.91, sx * 0.985, col=(0.09, 0.095, 0.105, 1.0))

    # 5. Front Wrap-Around Dark Charcoal Bumper, Skid Plate, Fog Lights & Radiator Grille
    add_box_part(bm, uv_layer, col_layer, 2, (0.0, 0.46, 3.03), (1.82, 0.42, 0.32), bevel=0.055, segs=3, col=(0.08, 0.085, 0.095, 1.0), subdiv_cuts=2)
    for sx in [-1.0, 1.0]:
        add_box_part(bm, uv_layer, col_layer, 2, (sx * 0.89, 0.46, 2.74), (0.18, 0.40, 0.58), bevel=0.04, segs=2, col=(0.08, 0.085, 0.095, 1.0), subdiv_cuts=1)
        add_box_part(bm, uv_layer, col_layer, 2, (sx * 0.70, 0.36, 3.15), (0.24, 0.11, 0.06), bevel=0.015, segs=2, col=(0.05, 0.05, 0.06, 1.0))
        add_cyl_part(bm, uv_layer, col_layer, 5, (sx * 0.72, 0.36, 3.175), 0.038, 0.03, rot_euler=(0,0,0), segs=16, bevel=0.004, col=c_lamp_white)

    add_box_part(bm, uv_layer, col_layer, 2, (0.0, 0.35, 3.155), (0.92, 0.14, 0.05), bevel=0.012, segs=1, col=(0.04, 0.04, 0.05, 1.0))
    # Front Euro License Plate ("SM-112-UR") at Y=0.29, Z=3.194
    add_box_part(bm, uv_layer, col_layer, 1, (0.0, 0.29, 3.194), (0.52, 0.11, 0.012), bevel=0.003, segs=1,
                 uv_rect=(0.02, 0.805, 0.49, 0.895), uv_axis='Z', flip_u=False, flip_v=False)

    # Radiator Grille with chrome surround, horizontal slats, and integrated Blue/Red grill strobe LEDs
    add_box_part(bm, uv_layer, col_layer, 2, (0.0, 0.82, 3.10), (1.04, 0.32, 0.08), bevel=0.025, segs=2, col=(0.55, 0.58, 0.62, 1.0), subdiv_cuts=1)
    add_box_part(bm, uv_layer, col_layer, 2, (0.0, 0.82, 3.115), (0.96, 0.27, 0.07), bevel=0.018, segs=2, col=(0.05, 0.05, 0.06, 1.0), subdiv_cuts=1)
    for gy in [0.74, 0.82, 0.90]:
        add_box_part(bm, uv_layer, col_layer, 2, (0.0, gy, 3.145), (0.92, 0.024, 0.03), bevel=0.005, segs=1, col=(0.14, 0.15, 0.17, 1.0))
    add_box_part(bm, uv_layer, col_layer, 5, ( 0.28, 0.85, 3.158), (0.14, 0.028, 0.02), bevel=0.003, segs=1, col=c_lamp_red)
    add_box_part(bm, uv_layer, col_layer, 5, (-0.28, 0.85, 3.158), (0.14, 0.028, 0.02), bevel=0.003, segs=1, col=c_lamp_blue)

    # 6. Swept Angular LED Headlights (X=±0.71, Y=0.89, Z=3.01)
    for sx in [-1.0, 1.0]:
        add_box_part(bm, uv_layer, col_layer, 2, (sx * 0.71, 0.89, 2.99), (0.34, 0.16, 0.22), bevel=0.025, segs=2, col=(0.12, 0.13, 0.15, 1.0))
        add_box_part(bm, uv_layer, col_layer, 5, (sx * 0.71, 0.89, 3.01), (0.31, 0.13, 0.21), bevel=0.02, segs=2, col=c_lamp_white)

    # 7. Cab Roof, A-Pillars, B-Pillars, Windshield & Aerodynamic Roof Scoop
    add_box_part(bm, uv_layer, col_layer, 0, (0.0, 1.42, 0.72), (1.92, 1.95, 0.32), bevel=0.03, segs=2, col=(0.95, 0.95, 0.96, 1.0), subdiv_cuts=2)
    # Solid Cab Roof Header Beam right above the top of the windshield (eliminates any dark gap under the scoop!)
    add_box_part(bm, uv_layer, col_layer, 0, (0.0, 2.04, 1.12), (1.64, 0.08, 0.62), bevel=0.015, segs=2, col=(0.95, 0.95, 0.96, 1.0), subdiv_cuts=1)

    # Aerodynamic Cab Roof Fairing / Scoop above windshield (Z from 0.58 to 1.42, Y from 2.03 to 2.56)
    scoop_stations = [
        (0.58, 2.03, 2.56, 2.50, 0.92, 0.82),
        (0.90, 2.03, 2.50, 2.44, 0.89, 0.78),
        (1.18, 2.03, 2.34, 2.28, 0.85, 0.74),
        (1.42, 2.02, 2.07, 2.04, 0.81, 0.72),
    ]
    sc_rings = []
    for (z, yb, ytc, yts, hwb, hwt) in scoop_stations:
        pts = [
            Vector((-hwb, yb, z)),
            Vector((-hwt, yts, z)),
            Vector((-hwt*0.4, ytc, z)),
            Vector(( hwt*0.4, ytc, z)),
            Vector(( hwt, yts, z)),
            Vector(( hwb, yb, z)),
        ]
        sc_rings.append([bm.verts.new(p) for p in pts])
    sc_faces = []
    for i in range(len(sc_rings) - 1):
        r0, r1 = sc_rings[i], sc_rings[i + 1]
        for j in range(len(r0) - 1):
            f = bm.faces.new([r0[j], r0[j+1], r1[j+1], r1[j]])
            f.material_index = 1
            f.smooth = True
            for lp in f.loops:
                lp[col_layer] = (1, 1, 1, 1)
            sc_faces.append(f)
    set_face_uv_rect(bm, sc_faces, uv_layer, 'Y', (-0.88, 0.88), (0.58, 1.42), (0.02, 0.66, 0.98, 0.79), flip_u=True, flip_v=False)

    # Slanted A-Pillars left and right
    for sx in [-1.0, 1.0]:
        ap_stations = [
            (1.87, 1.35, sx * 0.89),
            (1.71, 1.58, sx * 0.86),
            (1.55, 1.81, sx * 0.83),
            (1.39, 2.04, sx * 0.80),
        ]
        ap_rings = []
        hw_x, hw_y, hw_z = 0.042, 0.038, 0.045
        for (az, ay, ax) in ap_stations:
            pts = [
                Vector((ax - hw_x, ay - hw_y, az + hw_z)),
                Vector((ax + hw_x, ay - hw_y, az + hw_z)),
                Vector((ax + hw_x, ay + hw_y, az - hw_z)),
                Vector((ax - hw_x, ay + hw_y, az - hw_z)),
            ]
            ap_rings.append([bm.verts.new(p) for p in pts])
        for i in range(len(ap_rings) - 1):
            r0, r1 = ap_rings[i], ap_rings[i+1]
            for j in range(4):
                jn = (j + 1) % 4
                f = bm.faces.new([r0[j], r0[jn], r1[jn], r1[j]])
                f.material_index = 0
                f.smooth = True
                for lp in f.loops:
                    lp[col_layer] = (0.95, 0.95, 0.96, 1.0)
                set_face_uv_box(bm, [f], uv_layer)

    # Curved Front Windshield Glass (Material 4: Glass)
    ws_bot_z, ws_bot_y, ws_bot_w = 1.87, 1.37, 0.86
    ws_top_z, ws_top_y, ws_top_w = 1.39, 2.04, 0.79
    ws_segs = 10
    ws_r0, ws_r1 = [], []
    for i in range(ws_segs + 1):
        t = (i / ws_segs) * 2.0 - 1.0
        arch = (1.0 - t*t) * 0.08
        ws_r0.append(bm.verts.new(Vector((t * ws_bot_w, ws_bot_y, ws_bot_z + arch))))
        ws_r1.append(bm.verts.new(Vector((t * ws_top_w, ws_top_y, ws_top_z + arch * 0.8))))
    for i in range(ws_segs):
        f = bm.faces.new([ws_r0[i], ws_r0[i+1], ws_r1[i+1], ws_r1[i]])
        f.material_index = 4 # Glass
        f.smooth = True
        for lp in f.loops:
            lp[col_layer] = (0.1, 0.15, 0.18, 1.0)
            lp[uv_layer].uv = (i / ws_segs, 0.5)

    # Black rubber windshield seal frame + dual windshield wipers
    add_box_part(bm, uv_layer, col_layer, 2, (0.0, 1.36, 1.90), (1.70, 0.035, 0.05), bevel=0.005, segs=1, col=(0.06, 0.06, 0.07, 1.0))
    for wx in [-0.35, 0.25]:
        add_box_part(bm, uv_layer, col_layer, 2, (wx, 1.39, 1.89), (0.55, 0.018, 0.022), bevel=0.003, segs=1, col=(0.05, 0.05, 0.06, 1.0))

    # 8. Cab Interior (Visible through front windshield & side windows!)
    add_box_part(bm, uv_layer, col_layer, 2, (0.0, 0.50, 1.25), (1.72, 0.08, 1.15), bevel=0.01, segs=1, col=(0.08, 0.08, 0.09, 1.0))
    add_box_part(bm, uv_layer, col_layer, 2, (0.0, 1.18, 1.66), (1.64, 0.34, 0.42), bevel=0.03, segs=2, col=(0.11, 0.12, 0.14, 1.0), subdiv_cuts=1)
    add_box_part(bm, uv_layer, col_layer, 2, (0.42, 1.36, 1.62), (0.38, 0.12, 0.24), bevel=0.02, segs=2, col=(0.09, 0.10, 0.11, 1.0))
    for sx_seat in [-0.42, 0.42]:
        add_box_part(bm, uv_layer, col_layer, 2, (sx_seat, 0.72, 1.05), (0.48, 0.32, 0.50), bevel=0.03, segs=2, col=(0.14, 0.15, 0.17, 1.0), subdiv_cuts=1)
        add_box_part(bm, uv_layer, col_layer, 2, (sx_seat, 1.18, 0.86), (0.46, 0.64, 0.14), bevel=0.035, segs=2, col=(0.15, 0.16, 0.18, 1.0), subdiv_cuts=1)
        add_box_part(bm, uv_layer, col_layer, 2, (sx_seat, 1.58, 0.85), (0.24, 0.18, 0.10), bevel=0.025, segs=2, col=(0.13, 0.14, 0.16, 1.0))
    add_box_part(bm, uv_layer, col_layer, 2, (0.0, 1.95, 1.42), (0.22, 0.06, 0.04), bevel=0.005, segs=1, col=(0.08, 0.08, 0.09, 1.0))

    # 9. Rear Bumper, Step Bar, Tail Lights & Rear License Plate
    add_box_part(bm, uv_layer, col_layer, 2, (0.0, 0.40, -3.14), (2.14, 0.14, 0.12), bevel=0.02, segs=2, col=(0.10, 0.11, 0.12, 1.0), subdiv_cuts=1)
    add_box_part(bm, uv_layer, col_layer, 2, (0.0, 0.34, -3.16), (1.40, 0.04, 0.08), bevel=0.008, segs=1, col=(0.35, 0.37, 0.40, 1.0))
    for sx in [-1.0, 1.0]:
        add_box_part(bm, uv_layer, col_layer, 2, (sx * 0.82, 0.41, -3.185), (0.36, 0.10, 0.025), bevel=0.006, segs=1, col=(0.08, 0.08, 0.09, 1.0))
        add_box_part(bm, uv_layer, col_layer, 5, (sx * 0.86, 0.41, -3.195), (0.24, 0.08, 0.015), bevel=0.004, segs=1, col=c_lamp_red)
        add_box_part(bm, uv_layer, col_layer, 5, (sx * 0.70, 0.41, -3.195), (0.08, 0.08, 0.015), bevel=0.004, segs=1, col=c_lamp_amber)
    add_box_part(bm, uv_layer, col_layer, 1, (0.0, 0.41, -3.195), (0.52, 0.11, 0.012), bevel=0.003, segs=1,
                 uv_rect=(0.02, 0.805, 0.49, 0.895), uv_axis='Z', flip_v=False)
    add_cyl_part(bm, uv_layer, col_layer, 2, (0.62, 0.28, -2.85), 0.042, 0.38, rot_euler=(0.15, 0.2, 0), segs=16, bevel=0.004, col=(0.35, 0.36, 0.38, 1.0))

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

# ==============================================================================
# 2. BUILD LIGHTBAR NODE (Roof emergency lightbar over cab, origin at its centre)
# ==============================================================================

def build_lightbar_node():
    center = Vector((0.0, 2.65, 0.68))
    mesh = bpy.data.meshes.new("Lightbar_Mesh")
    obj = bpy.data.objects.new("Lightbar", mesh)
    obj.location = center
    bpy.context.collection.objects.link(obj)
    assign_materials_to_obj(obj, ALL_MAT_NAMES)

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

    add_box_part(bm, uv_layer, col_layer, 2, (0.0, -0.04, 0.0), (1.36, 0.04, 0.18), bevel=0.008, segs=2, col=(0.10, 0.11, 0.12, 1.0), subdiv_cuts=1)
    add_box_part(bm, uv_layer, col_layer, 2, (0.0,  0.00, 0.0), (0.16, 0.095, 0.22), bevel=0.015, segs=2, col=(0.75, 0.77, 0.80, 1.0))
    add_box_part(bm, uv_layer, col_layer, 5, ( 0.38, 0.00, 0.0), (0.62, 0.09, 0.21), bevel=0.025, segs=3, col=(1.00, 0.05, 0.05, 1.0), subdiv_cuts=1)
    add_box_part(bm, uv_layer, col_layer, 5, (-0.38, 0.00, 0.0), (0.62, 0.09, 0.21), bevel=0.025, segs=3, col=(0.04, 0.38, 1.00, 1.0), subdiv_cuts=1)

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

# ==============================================================================
# 3. BUILD FRONT CAB DOORS (Door_FL, Door_FR - origin at front hinge)
# ==============================================================================

def build_cab_door(name, sx):
    hinge = Vector((sx * 0.94, 0.92, 1.84))
    mesh = bpy.data.meshes.new(f"{name}_Mesh")
    obj = bpy.data.objects.new(name, mesh)
    obj.location = hinge
    bpy.context.collection.objects.link(obj)
    assign_materials_to_obj(obj, ALL_MAT_NAMES)

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

    add_box_part(bm, uv_layer, col_layer, 0, (0.0, -0.09, -0.61), (0.070, 1.02, 1.21), bevel=0.02, segs=2, col=(0.95, 0.95, 0.96, 1.0), subdiv_cuts=2)
    add_box_part(bm, uv_layer, col_layer, 2, (sx * 0.032, -0.38, -0.61), (0.020, 0.19, 1.20), bevel=0.006, segs=2, col=(0.09, 0.095, 0.105, 1.0), subdiv_cuts=1)
    add_box_part(bm, uv_layer, col_layer, 1, (sx * 0.036, 0.15, -0.61), (0.008, 0.38, 1.19), bevel=0.002, segs=1,
                 uv_rect=(0.02, 0.905, 0.48, 0.995), uv_axis='X', subdiv_cuts=1)
    add_box_part(bm, uv_layer, col_layer, 2, (sx * 0.045, 0.22, -1.02), (0.024, 0.055, 0.18), bevel=0.006, segs=2, col=(0.08, 0.08, 0.09, 1.0))
    add_box_part(bm, uv_layer, col_layer, 2, (-sx * 0.02, 0.77, -1.16), (0.055, 0.70, 0.11), bevel=0.01, segs=2, col=(0.09, 0.10, 0.11, 1.0), subdiv_cuts=1)
    add_box_part(bm, uv_layer, col_layer, 0, (-sx * 0.06, 1.11, -0.78), (0.055, 0.065, 0.84), bevel=0.01, segs=2, col=(0.95, 0.95, 0.96, 1.0), subdiv_cuts=1)

    g_pts = [
        Vector((-sx * 0.01, 0.42, -0.06)),
        Vector((-sx * 0.01, 0.42, -1.12)),
        Vector((-sx * 0.09, 1.09, -1.12)),
        Vector((-sx * 0.09, 1.09, -0.44)),
    ]
    if sx < 0:
        g_pts.reverse()
    g_vs = [bm.verts.new(p) for p in g_pts]
    fg = bm.faces.new(g_vs)
    fg.material_index = 4
    fg.smooth = True
    for lp in fg.loops:
        lp[col_layer] = (0.1, 0.15, 0.18, 1.0)
        lp[uv_layer].uv = (0.5, 0.5)

    add_box_part(bm, uv_layer, col_layer, 2, (sx * 0.06, 0.46, -0.20), (0.10, 0.035, 0.045), bevel=0.005, segs=1, col=(0.08, 0.08, 0.09, 1.0))
    add_box_part(bm, uv_layer, col_layer, 2, (sx * 0.06, 0.62, -0.20), (0.10, 0.035, 0.045), bevel=0.005, segs=1, col=(0.08, 0.08, 0.09, 1.0))
    add_box_part(bm, uv_layer, col_layer, 2, (sx * 0.115, 0.56, -0.22), (0.08, 0.32, 0.15), bevel=0.018, segs=2, col=(0.08, 0.085, 0.095, 1.0), subdiv_cuts=1)

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

# ==============================================================================
# 4. BUILD TWIN REAR BOX DOORS (Door_Rear_L, Door_Rear_R - origin at outer hinges)
# ==============================================================================

def build_rear_door(name, sx):
    hinge = Vector((sx * 0.96, 1.56, -3.125))
    mesh = bpy.data.meshes.new(f"{name}_Mesh")
    obj = bpy.data.objects.new(name, mesh)
    obj.location = hinge
    bpy.context.collection.objects.link(obj)
    assign_materials_to_obj(obj, ALL_MAT_NAMES)

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

    dx_center = -sx * 0.47
    add_box_part(bm, uv_layer, col_layer, 2, (dx_center, 0.0, 0.0), (0.94, 1.98, 0.020), bevel=0.005, segs=1, col=(0.12, 0.13, 0.15, 1.0), subdiv_cuts=1)
    add_box_part(bm, uv_layer, col_layer, 0, (dx_center, 0.0, -0.010), (0.91, 1.95, 0.024), bevel=0.008, segs=2, col=(0.95, 0.95, 0.96, 1.0), subdiv_cuts=1)
    add_box_part(bm, uv_layer, col_layer, 2, (dx_center, 0.37, -0.020), (0.56, 0.64, 0.016), bevel=0.030, segs=2, col=(0.08, 0.09, 0.10, 1.0))
    add_box_part(bm, uv_layer, col_layer, 4, (dx_center, 0.37, -0.025), (0.50, 0.58, 0.014), bevel=0.020, segs=2, col=(0.75, 0.82, 0.85, 1.0))
    add_box_part(bm, uv_layer, col_layer, 1, (dx_center, -0.40, -0.024), (0.91, 0.40, 0.008), bevel=0.002, segs=1,
                 uv_rect=(0.02, 0.905, 0.48, 0.995), uv_axis='Z', subdiv_cuts=1)
    for hy in [-0.72, 0.0, 0.72]:
        add_box_part(bm, uv_layer, col_layer, 2, (0.0, hy, -0.024), (0.07, 0.06, 0.028), bevel=0.006, segs=1, col=(0.55, 0.58, 0.62, 1.0))
    if sx > 0:
        add_box_part(bm, uv_layer, col_layer, 2, (-0.78, -0.15, -0.028), (0.14, 0.08, 0.028), bevel=0.006, segs=2, col=(0.08, 0.08, 0.09, 1.0))

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

# ==============================================================================
# 5. BUILD STEERING WHEEL (SteeringWheel - origin at its hub)
# ==============================================================================

def build_steering_wheel():
    hub = Vector((0.42, 1.26, 1.48))
    mesh = bpy.data.meshes.new("SteeringWheel_Mesh")
    obj = bpy.data.objects.new("SteeringWheel", mesh)
    obj.location = hub
    bpy.context.collection.objects.link(obj)
    assign_materials_to_obj(obj, ALL_MAT_NAMES)

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

    tilt = (-0.55, 0.0, 0.0)
    add_cyl_part(bm, uv_layer, col_layer, 2, (0.0, -0.04, 0.06), 0.045, 0.18, rot_euler=tilt, segs=16, bevel=0.005, col=(0.10, 0.11, 0.12, 1.0))
    add_cyl_part(bm, uv_layer, col_layer, 2, (0.0, 0.0, 0.0), 0.085, 0.045, rot_euler=tilt, segs=20, bevel=0.008, col=(0.12, 0.13, 0.15, 1.0))
    r_rim = 0.19
    rot_m = rot_mat_euler(tilt, 'XYZ')
    for i in range(24):
        a = 2.0 * math.pi * i / 24.0
        p = rot_m @ Vector((math.cos(a) * r_rim, math.sin(a) * r_rim, 0.0))
        add_box_part(bm, uv_layer, col_layer, 2, p, (0.032, 0.032, 0.032), bevel=0.006, segs=1, col=(0.08, 0.09, 0.10, 1.0))
    for sa in [0.0, math.pi, -math.pi * 0.5]:
        p = rot_m @ Vector((math.cos(sa) * 0.095, math.sin(sa) * 0.095, 0.0))
        add_box_part(bm, uv_layer, col_layer, 2, p, (0.04, 0.04, 0.025), bevel=0.004, segs=1, col=(0.15, 0.16, 0.18, 1.0))

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

# ==============================================================================
# 6. BUILD 4 WHEELS (Wheel_FL, Wheel_FR, Wheel_RL, Wheel_RR - exact hub origin, touching Y=0)
# ==============================================================================

def build_wheel(name, pos, is_rear=False):
    mesh = bpy.data.meshes.new(f"{name}_Mesh")
    obj = bpy.data.objects.new(name, mesh)
    obj.location = Vector(pos)
    bpy.context.collection.objects.link(obj)
    assign_materials_to_obj(obj, ALL_MAT_NAMES)

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

    sx = 1.0 if pos[0] > 0 else -1.0
    r_outer = 0.410
    r_rim   = 0.255
    width   = 0.25 if not is_rear else 0.44

    # 1. Smooth high-segment revolved tire profile with circumferential tread grooves (60 radial segments)
    segs = 60
    hw = width * 0.5
    profile = [
        (-hw * 0.80, r_rim - 0.01),
        (-hw * 0.96, r_rim + 0.05),
        (-hw * 1.00, r_outer - 0.04),
        (-hw * 0.88, r_outer),
        (-hw * 0.45, r_outer),
        (-hw * 0.40, r_outer - 0.012),
        (-hw * 0.35, r_outer),
        (-hw * 0.05, r_outer),
        ( 0.00,      r_outer - 0.012),
        ( hw * 0.05, r_outer),
        ( hw * 0.35, r_outer),
        ( hw * 0.40, r_outer - 0.012),
        ( hw * 0.45, r_outer),
        ( hw * 0.88, r_outer),
        ( hw * 1.00, r_outer - 0.04),
        ( hw * 0.96, r_rim + 0.05),
        ( hw * 0.80, r_rim - 0.01),
    ]
    rings = []
    for i in range(segs):
        ang = 2.0 * math.pi * i / segs
        ca, sa = math.cos(ang), math.sin(ang)
        ring = []
        for (px, pr) in profile:
            ring.append(bm.verts.new(Vector((px, pr * sa, pr * ca))))
        rings.append(ring)

    for i in range(segs):
        r0 = rings[i]
        r1 = rings[(i + 1) % segs]
        for j in range(len(profile) - 1):
            f = bm.faces.new([r0[j], r0[j+1], r1[j+1], r1[j]])
            f.material_index = 3 # Rubber_Tires
            f.smooth = True
            for lp in f.loops:
                lp[col_layer] = (0.09, 0.095, 0.10, 1.0)
            set_face_uv_box(bm, [f], uv_layer, scale=1.5)

    # 2. Heavy-duty Commercial Steel Wheel Rim with 12 ventilation holes & central hub cap (Material 2: Chassis_Trim)
    rim_x = sx * (hw * 0.72)
    add_cyl_part(bm, uv_layer, col_layer, 2, (0.0, 0.0, 0.0), r_rim, width * 0.78, rot_euler=(0, math.pi*0.5, 0), segs=36, bevel=0.008, col=(0.55, 0.58, 0.62, 1.0))
    add_cyl_part(bm, uv_layer, col_layer, 2, (rim_x, 0.0, 0.0), r_rim * 0.78, 0.035, rot_euler=(0, math.pi*0.5, 0), segs=36, bevel=0.006, col=(0.62, 0.65, 0.68, 1.0))
    add_cyl_part(bm, uv_layer, col_layer, 2, (-sx * 0.03, 0.0, 0.0), r_rim * 0.75, 0.04, rot_euler=(0, math.pi*0.5, 0), segs=24, bevel=0.003, col=(0.18, 0.19, 0.20, 1.0))
    add_cyl_part(bm, uv_layer, col_layer, 2, (rim_x + sx * 0.02, 0.0, 0.0), 0.085, 0.045, rot_euler=(0, math.pi*0.5, 0), segs=24, bevel=0.008, col=(0.14, 0.15, 0.17, 1.0))
    for k in range(6):
        la = 2.0 * math.pi * k / 6.0
        add_cyl_part(bm, uv_layer, col_layer, 2, (rim_x + sx * 0.022, math.sin(la)*0.115, math.cos(la)*0.115), 0.014, 0.025, rot_euler=(0, math.pi*0.5, 0), segs=10, bevel=0.002, col=(0.30, 0.32, 0.35, 1.0))
    for k in range(12):
        va = 2.0 * math.pi * k / 12.0
        add_cyl_part(bm, uv_layer, col_layer, 2, (rim_x + sx * 0.016, math.sin(va)*0.175, math.cos(va)*0.175), 0.022, 0.018, rot_euler=(0, math.pi*0.5, 0), segs=12, bevel=0.002, col=(0.06, 0.06, 0.07, 1.0))

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

# ==============================================================================
# ASSEMBLE ALL NODES & EMPTIES (Forge Garage Contract v2)
# ==============================================================================

obj_body   = build_body_node()
obj_lbar   = build_lightbar_node()
obj_dfl    = build_cab_door("Door_FL",  1.0)
obj_dfr    = build_cab_door("Door_FR", -1.0)
obj_drl    = build_rear_door("Door_Rear_L",  1.0)
obj_drr    = build_rear_door("Door_Rear_R", -1.0)
obj_swheel = build_steering_wheel()

obj_wfl = build_wheel("Wheel_FL", ( 0.88, 0.410,  1.975), is_rear=False)
obj_wfr = build_wheel("Wheel_FR", (-0.88, 0.410,  1.975), is_rear=False)
obj_wrl = build_wheel("Wheel_RL", ( 0.86, 0.410, -1.975), is_rear=True)
obj_wrr = build_wheel("Wheel_RR", (-0.86, 0.410, -1.975), is_rear=True)

MESH_OBJS = [obj_body, obj_lbar, obj_dfl, obj_dfr, obj_drl, obj_drr, obj_swheel, obj_wfl, obj_wfr, obj_wrl, obj_wrr]

EMPTIES = {
    "Seat_Driver":  ( 0.42, 0.92,  1.05),
    "Door_L":       ( 1.02, 0.92,  1.25),
    "Door_R":       (-1.02, 0.92,  1.25),
    "Exhaust":      ( 0.62, 0.28, -3.02),
    "Light_Head_L": ( 0.71, 0.89,  3.10),
    "Light_Head_R": (-0.71, 0.89,  3.10),
    "Light_Tail_L": ( 0.86, 0.41, -3.195),
    "Light_Tail_R": (-0.86, 0.41, -3.195),
}
EMPTY_OBJS = []
for ename, epos in EMPTIES.items():
    emp = bpy.data.objects.new(ename, None)
    emp.empty_display_type = 'PLAIN_AXES'
    emp.empty_display_size = 0.15
    emp.location = Vector(epos)
    bpy.context.collection.objects.link(emp)
    EMPTY_OBJS.append(emp)

bpy.context.view_layer.update()

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

print(f"Initial triangle count: {get_total_tris()}")

# Subdivide with smooth=0.0 on Doors and Lightbar so total triangle count sits squarely inside [55,000, 100,000] (~65k tris)
for o in [obj_dfl, obj_dfr, obj_drl, obj_drr, obj_lbar]:
    if get_total_tris() < 90000:
        bm = bmesh.new()
        bm.from_mesh(o.data)
        bmesh.ops.subdivide_edges(bm, edges=bm.edges, cuts=1, use_grid_fill=True, smooth=0.0)
        bm.to_mesh(o.data)
        bm.free()

print(f"Final triangle count (budget 55k-100k): {get_total_tris()}")

# Calibrate exact bounding box to 2.20 m wide, 2.85 m high, 6.40 m long, and tyres touching Y = 0.0000
for o in [obj_wfl, obj_wfr, obj_wrl, obj_wrr]:
    w_min_y = min((o.matrix_world @ v.co).y for v in o.data.vertices)
    if abs(w_min_y) > 1e-6:
        for v in o.data.vertices:
            v.co.y -= w_min_y

cur_min = Vector(( 1e9,  1e9,  1e9))
cur_max = Vector((-1e9, -1e9, -1e9))
for o in MESH_OBJS:
    for v in o.data.vertices:
        wc = o.matrix_world @ v.co
        cur_min.x = min(cur_min.x, wc.x)
        cur_min.y = min(cur_min.y, wc.y)
        cur_min.z = min(cur_min.z, wc.z)
        cur_max.x = max(cur_max.x, wc.x)
        cur_max.y = max(cur_max.y, wc.y)
        cur_max.z = max(cur_max.z, wc.z)

sx_scale = 2.20 / (cur_max.x - cur_min.x)
sy_scale = 2.85 / (cur_max.y - 0.0)
sz_scale = 6.40 / (cur_max.z - cur_min.z)
z_mid = 0.5 * (cur_max.z + cur_min.z)

for o in [obj_body, obj_lbar, obj_dfl, obj_dfr, obj_drl, obj_drr]:
    for v in o.data.vertices:
        wc = o.matrix_world @ v.co
        nx = wc.x * sx_scale
        ny = max(0.18, wc.y * sy_scale)
        nz = (wc.z - z_mid) * sz_scale
        v.co = o.matrix_world.inverted() @ Vector((nx, ny, nz))

# ==============================================================================
# CYCLES TEXTURE BAKING & GLTF PBR MATERIAL WIRING (GAME WEIGHT CONTRACT)
# ==============================================================================

def build_baked_textures():
    mp_diff = Image.open(os.path.join(TEX_DIR, "metal_plate_diff_1k.jpg")).convert("RGB").resize((1024, 1024), Image.Resampling.BILINEAR)
    mp_arm  = Image.open(os.path.join(TEX_DIR, "metal_plate_arm_1k.jpg")).convert("RGB").resize((512, 512), Image.Resampling.BILINEAR)
    fl_diff = Image.open(os.path.join(TEX_DIR, "fabric_leather_02_diff_1k.jpg")).convert("RGB").resize((512, 512), Image.Resampling.BILINEAR)
    fl_nor  = Image.open(os.path.join(TEX_DIR, "fabric_leather_02_nor_gl_1k.jpg")).convert("RGB").resize((512, 512), Image.Resampling.BILINEAR)

    # 1. Paint neutral baseColor (1024x1024): near-white with subtle brushed metal grain and lower road dust
    paint_bc = Image.new("RGB", (1024, 1024), (246, 247, 249))
    paint_bc = Image.blend(paint_bc, mp_diff, 0.03)
    dr = ImageDraw.Draw(paint_bc)
    for y in range(880, 1024):
        alpha = (y - 880) / 144.0
        c = int(246 - alpha * 18)
        dr.line([(0, y), (1024, y)], fill=(c, c, c - 2), width=1)
    paint_bc_path = os.path.join(TEX_DIR, "baked_paint_bc.jpg")
    paint_bc.save(paint_bc_path, quality=86)

    # Shared smooth automotive ORM & Normal (512x512)
    orm_img = Image.new("RGB", (512, 512), (244, 52, 12))
    orm_img = Image.blend(orm_img, mp_arm, 0.08)
    orm_path = os.path.join(TEX_DIR, "baked_shared_orm.jpg")
    orm_img.save(orm_path, quality=84)

    nor_flat = Image.new("RGB", (512, 512), (128, 128, 255))
    nor_img = Image.blend(nor_flat, fl_nor, 0.12)
    nor_path = os.path.join(TEX_DIR, "baked_shared_nor.jpg")
    nor_img.save(nor_path, quality=84)

    # 2. Livery texture (1024x1024) mixed with subtle Poly Haven metal_plate micro-detail
    liv_src = Image.open(LIV_SOURCE_PATH).convert("RGB").resize((1024, 1024), Image.Resampling.BILINEAR)
    liv_bc  = Image.blend(liv_src, mp_diff, 0.03)
    liv_bc_path = os.path.join(TEX_DIR, "baked_livery_bc.jpg")
    liv_bc.save(liv_bc_path, quality=88)

    # 3. Chassis_Trim & Rubber_Tires textures (512x512) from Poly Haven fabric_leather_02 & rubber_tiles
    trim_base = Image.new("RGB", (512, 512), (38, 42, 46))
    trim_bc = Image.blend(trim_base, fl_diff, 0.20)
    trim_bc_path = os.path.join(TEX_DIR, "baked_trim_bc.jpg")
    trim_bc.save(trim_bc_path, quality=84)

    rt_diff = Image.open(os.path.join(TEX_DIR, "rubber_tiles_diff_1k.jpg")).convert("RGB").resize((512, 512), Image.Resampling.BILINEAR)
    rt_nor  = Image.open(os.path.join(TEX_DIR, "rubber_tiles_nor_gl_1k.jpg")).convert("RGB").resize((512, 512), Image.Resampling.BILINEAR)
    rub_base = Image.new("RGB", (512, 512), (24, 26, 28))
    rub_bc = Image.blend(rub_base, rt_diff, 0.28)
    rub_bc_path = os.path.join(TEX_DIR, "baked_rubber_bc.jpg")
    rub_bc.save(rub_bc_path, quality=84)
    rub_nor_path = os.path.join(TEX_DIR, "baked_rubber_nor.jpg")
    rt_nor.save(rub_nor_path, quality=84)

    return {
        "paint_bc": paint_bc_path,
        "livery_bc": liv_bc_path,
        "trim_bc": trim_bc_path,
        "rub_bc": rub_bc_path,
        "rub_nor": rub_nor_path,
        "orm": orm_path,
        "nor": nor_path,
    }

BAKED_PATHS = build_baked_textures()

def wire_gltf_export_materials():
    img_orm = bpy.data.images.load(BAKED_PATHS["orm"])
    img_orm.colorspace_settings.name = 'Non-Color'
    img_nor = bpy.data.images.load(BAKED_PATHS["nor"])
    img_nor.colorspace_settings.name = 'Non-Color'

    # 1. Paint
    m_p = MATS["Paint"]
    nt = m_p.node_tree
    nt.nodes.clear()
    out = nt.nodes.new("ShaderNodeOutputMaterial")
    bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
    bsdf.inputs["Base Color"].default_value = (0.88, 0.89, 0.91, 1.0)
    if "Coat Weight" in bsdf.inputs:
        bsdf.inputs["Coat Weight"].default_value = 1.0
        bsdf.inputs["Coat Roughness"].default_value = 0.05
    nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])

    tex_p = nt.nodes.new("ShaderNodeTexImage")
    tex_p.image = bpy.data.images.load(BAKED_PATHS["paint_bc"])
    mul_p = nt.nodes.new("ShaderNodeMix")
    mul_p.data_type = 'RGBA'
    mul_p.blend_type = 'MULTIPLY'
    mul_p.inputs["Factor"].default_value = 1.0
    mul_p.inputs[6].default_value = (0.88, 0.89, 0.91, 1.0)
    mul_p.name = "PaintFactorNode"
    nt.links.new(tex_p.outputs["Color"], mul_p.inputs[7])
    nt.links.new(mul_p.outputs[2], bsdf.inputs["Base Color"])

    tex_orm = nt.nodes.new("ShaderNodeTexImage")
    tex_orm.image = img_orm
    sep_orm = nt.nodes.new("ShaderNodeSeparateColor")
    nt.links.new(tex_orm.outputs["Color"], sep_orm.inputs["Color"])
    nt.links.new(sep_orm.outputs["Green"], bsdf.inputs["Roughness"])
    nt.links.new(sep_orm.outputs["Blue"], bsdf.inputs["Metallic"])

    tex_nor = nt.nodes.new("ShaderNodeTexImage")
    tex_nor.image = img_nor
    nmap = nt.nodes.new("ShaderNodeNormalMap")
    nmap.inputs["Strength"].default_value = 0.08
    nt.links.new(tex_nor.outputs["Color"], nmap.inputs["Color"])
    nt.links.new(nmap.outputs["Normal"], bsdf.inputs["Normal"])

    # 2. Livery
    m_l = MATS["Livery"]
    nt = m_l.node_tree
    nt.nodes.clear()
    out = nt.nodes.new("ShaderNodeOutputMaterial")
    bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
    if "Coat Weight" in bsdf.inputs:
        bsdf.inputs["Coat Weight"].default_value = 0.85
        bsdf.inputs["Coat Roughness"].default_value = 0.06
    nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])

    tex_l = nt.nodes.new("ShaderNodeTexImage")
    tex_l.image = bpy.data.images.load(BAKED_PATHS["livery_bc"])
    nt.links.new(tex_l.outputs["Color"], bsdf.inputs["Base Color"])

    tex_orm_l = nt.nodes.new("ShaderNodeTexImage")
    tex_orm_l.image = img_orm
    sep_orm_l = nt.nodes.new("ShaderNodeSeparateColor")
    nt.links.new(tex_orm_l.outputs["Color"], sep_orm_l.inputs["Color"])
    nt.links.new(sep_orm_l.outputs["Green"], bsdf.inputs["Roughness"])
    nt.links.new(sep_orm_l.outputs["Blue"], bsdf.inputs["Metallic"])

    tex_nor_l = nt.nodes.new("ShaderNodeTexImage")
    tex_nor_l.image = img_nor
    nmap_l = nt.nodes.new("ShaderNodeNormalMap")
    nmap_l.inputs["Strength"].default_value = 0.08
    nt.links.new(tex_nor_l.outputs["Color"], nmap_l.inputs["Color"])
    nt.links.new(nmap_l.outputs["Normal"], bsdf.inputs["Normal"])

    # 3. Chassis_Trim
    m_t = MATS["Chassis_Trim"]
    nt = m_t.node_tree
    nt.nodes.clear()
    out = nt.nodes.new("ShaderNodeOutputMaterial")
    bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
    bsdf.inputs["Roughness"].default_value = 0.42
    bsdf.inputs["Metallic"].default_value = 0.25
    nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])

    vcol_t = nt.nodes.new("ShaderNodeVertexColor")
    vcol_t.layer_name = "Col"
    tex_t = nt.nodes.new("ShaderNodeTexImage")
    tex_t.image = bpy.data.images.load(BAKED_PATHS["trim_bc"])
    mix_t = nt.nodes.new("ShaderNodeMix")
    mix_t.data_type = 'RGBA'
    mix_t.inputs["Factor"].default_value = 0.18
    nt.links.new(vcol_t.outputs["Color"], mix_t.inputs[6])
    nt.links.new(tex_t.outputs["Color"], mix_t.inputs[7])
    nt.links.new(mix_t.outputs[2], bsdf.inputs["Base Color"])

    tex_nor_t = nt.nodes.new("ShaderNodeTexImage")
    tex_nor_t.image = img_nor
    nmap_t = nt.nodes.new("ShaderNodeNormalMap")
    nmap_t.inputs["Strength"].default_value = 0.20
    nt.links.new(tex_nor_t.outputs["Color"], nmap_t.inputs["Color"])
    nt.links.new(nmap_t.outputs["Normal"], bsdf.inputs["Normal"])

    # 4. Rubber_Tires
    m_r = MATS["Rubber_Tires"]
    nt = m_r.node_tree
    nt.nodes.clear()
    out = nt.nodes.new("ShaderNodeOutputMaterial")
    bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
    bsdf.inputs["Roughness"].default_value = 0.72
    bsdf.inputs["Metallic"].default_value = 0.04
    nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])

    tex_r = nt.nodes.new("ShaderNodeTexImage")
    tex_r.image = bpy.data.images.load(BAKED_PATHS["rub_bc"])
    nt.links.new(tex_r.outputs["Color"], bsdf.inputs["Base Color"])

    tex_nor_r = nt.nodes.new("ShaderNodeTexImage")
    tex_nor_r.image = bpy.data.images.load(BAKED_PATHS["rub_nor"])
    tex_nor_r.image.colorspace_settings.name = 'Non-Color'
    nmap_r = nt.nodes.new("ShaderNodeNormalMap")
    nmap_r.inputs["Strength"].default_value = 0.55
    nt.links.new(tex_nor_r.outputs["Color"], nmap_r.inputs["Color"])
    nt.links.new(nmap_r.outputs["Normal"], bsdf.inputs["Normal"])

wire_gltf_export_materials()

# ==============================================================================
# WRITE nodes.json (with exact positions, bbox, and 6 paint colours) BEFORE EXPORT
# ==============================================================================

PAINTS = [
    {"name": "Ambulance White",      "rgb": [0.88, 0.89, 0.91]},
    {"name": "Euro High-Vis Yellow", "rgb": [0.85, 0.72, 0.05]},
    {"name": "SAMU Medical Blue",    "rgb": [0.06, 0.22, 0.62]},
    {"name": "Fire Brigade Red",     "rgb": [0.72, 0.06, 0.06]},
    {"name": "Emergency Orange",     "rgb": [0.88, 0.28, 0.03]},
    {"name": "Silver Response",      "rgb": [0.58, 0.61, 0.65]},
]

bpy.context.view_layer.update()
all_min = Vector(( 1e9,  1e9,  1e9))
all_max = Vector((-1e9, -1e9, -1e9))
for o in MESH_OBJS:
    for v in o.data.vertices:
        wc = o.matrix_world @ v.co
        all_min.x = min(all_min.x, wc.x)
        all_min.y = min(all_min.y, wc.y)
        all_min.z = min(all_min.z, wc.z)
        all_max.x = max(all_max.x, wc.x)
        all_max.y = max(all_max.y, wc.y)
        all_max.z = max(all_max.z, wc.z)

nodes_data = {
    "nodes": {},
    "bbox": {
        "min": [round(all_min.x, 4), round(all_min.y, 4), round(all_min.z, 4)],
        "max": [round(all_max.x, 4), round(all_max.y, 4), round(all_max.z, 4)],
        "size": [round(all_max.x - all_min.x, 4), round(all_max.y - all_min.y, 4), round(all_max.z - all_min.z, 4)],
    },
    "paints": [p["rgb"] for p in PAINTS],
    "paint_names": [p["name"] for p in PAINTS],
    "triangles": get_total_tris(),
}

for o in MESH_OBJS + EMPTY_OBJS:
    nodes_data["nodes"][o.name] = {
        "type": "MESH" if o in MESH_OBJS else "EMPTY",
        "position": [round(o.location.x, 4), round(o.location.y, 4), round(o.location.z, 4)],
    }

with open(os.path.join(WORK_DIR, "nodes.json"), "w") as f:
    json.dump(nodes_data, f, indent=2)
print("Saved nodes.json:", json.dumps(nodes_data["bbox"]))

# ==============================================================================
# LIGHTING, GROUND SHADOW CATCHER & CAMERAS FOR RENDERS
# ==============================================================================

def look_at_matrix(eye, target, up=Vector((0, 1, 0))):
    eye = Vector(eye)
    target = Vector(target)
    up = Vector(up)
    forward = (target - eye).normalized()
    right = forward.cross(up).normalized()
    true_up = right.cross(forward).normalized()
    m = Matrix((
        (right.x, true_up.x, -forward.x, eye.x),
        (right.y, true_up.y, -forward.y, eye.y),
        (right.z, true_up.z, -forward.z, eye.z),
        (0.0,     0.0,       0.0,        1.0)
    ))
    return m

world = bpy.data.worlds.new("StudioWorld")
scene.world = world
world.use_nodes = True
wnt = world.node_tree
wnt.nodes.clear()
w_out = wnt.nodes.new("ShaderNodeOutputWorld")
w_bg  = wnt.nodes.new("ShaderNodeBackground")
w_bg.inputs["Color"].default_value = (0.78, 0.80, 0.83, 1.0)
w_bg.inputs["Strength"].default_value = 1.15
wnt.links.new(w_bg.outputs["Background"], w_out.inputs["Surface"])

bm_g = bmesh.new()
ret_g = bmesh.ops.create_grid(bm_g, x_segments=4, y_segments=4, size=24.0)
bmesh.ops.rotate(bm_g, matrix=rot_mat_euler((math.pi*0.5, 0, 0), 'XYZ'), verts=ret_g['verts'])
mesh_g = bpy.data.meshes.new("Ground_Mesh")
bm_g.to_mesh(mesh_g)
bm_g.free()
obj_ground = bpy.data.objects.new("Ground_Plane", mesh_g)
bpy.context.collection.objects.link(obj_ground)
m_ground = bpy.data.materials.new("Ground_Mat")
m_ground.use_nodes = True
m_ground.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (0.72, 0.73, 0.75, 1.0)
m_ground.node_tree.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.85
obj_ground.data.materials.append(m_ground)

def add_area_light(name, eye, target, energy, size):
    ld = bpy.data.lights.new(name, type='AREA')
    ld.energy = energy
    ld.size = size
    lo = bpy.data.objects.new(name, ld)
    lo.matrix_world = look_at_matrix(eye, target)
    bpy.context.collection.objects.link(lo)
    return lo

add_area_light("Key_Light",  ( 6.5, 7.5,  6.5), (0.0, 1.4, 0.0), 1650.0, 6.0)
add_area_light("Fill_Light", (-7.0, 5.0,  5.5), (0.0, 1.2, 0.0),  780.0, 7.0)
add_area_light("Rim_Light",  (-4.0, 6.5, -7.0), (0.0, 1.5, 0.0),  920.0, 5.0)

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

scene.cycles.samples = 64
scene.cycles.use_denoising = True
scene.render.image_settings.file_format = 'PNG'

# 1. Render final.png (1920x1080 Cycles render, 3/4 front-left view framing the entire ambulance!)
cam_data.type = 'PERSP'
cam_data.lens = 50.0
cam_obj.matrix_world = look_at_matrix((6.6, 3.1, 7.4), (0.0, 1.32, 0.0))
scene.render.resolution_x = 1920
scene.render.resolution_y = 1080
scene.render.filepath = os.path.join(WORK_DIR, "final.png")
bpy.ops.render.render(write_still=True)

# 2. Render clay.png (same view as final, plain grey clay material)
m_clay = bpy.data.materials.new("Clay_Mat")
m_clay.use_nodes = True
bsdf_c = m_clay.node_tree.nodes["Principled BSDF"]
bsdf_c.inputs["Base Color"].default_value = (0.58, 0.59, 0.60, 1.0)
bsdf_c.inputs["Roughness"].default_value = 0.55
scene.view_layers[0].material_override = m_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

# 3. Render side.png (orthographic side view from +X looking at -X)
cam_data.type = 'ORTHO'
cam_data.ortho_scale = 7.4
cam_obj.matrix_world = look_at_matrix((8.0, 1.45, 0.0), (0.0, 1.45, 0.0))
scene.render.resolution_x = 1920
scene.render.resolution_y = 1080
scene.render.filepath = os.path.join(WORK_DIR, "side.png")
bpy.ops.render.render(write_still=True)

# 4. Render paints.png (3x2 grid of the 6 paints in 3/4 view)
cam_data.type = 'PERSP'
cam_data.lens = 50.0
cam_obj.matrix_world = look_at_matrix((6.6, 3.1, 7.4), (0.0, 1.32, 0.0))
scene.render.resolution_x = 640
scene.render.resolution_y = 360
scene.cycles.samples = 28

def restore_paint_multiply_for_cycles(rgb_factor=(0.88, 0.89, 0.91)):
    m_p = MATS["Paint"]
    nt = m_p.node_tree
    bsdf = next(n for n in nt.nodes if n.type == 'BSDF_PRINCIPLED')
    mul_p = nt.nodes.get("PaintFactorNode")
    mul_p.inputs[6].default_value = (rgb_factor[0], rgb_factor[1], rgb_factor[2], 1.0)
    nt.links.new(mul_p.outputs[2], bsdf.inputs["Base Color"])

grid_img = Image.new("RGB", (640 * 3, 360 * 2))
for idx, pinfo in enumerate(PAINTS):
    restore_paint_multiply_for_cycles(pinfo["rgb"])
    tmp_p = os.path.join(TEX_DIR, f"paint_cell_{idx}.png")
    scene.render.filepath = tmp_p
    bpy.ops.render.render(write_still=True)
    cell = Image.open(tmp_p).convert("RGB")
    gx = (idx % 3) * 640
    gy = (idx // 3) * 360
    grid_img.paste(cell, (gx, gy))

paints_path = os.path.join(WORK_DIR, "paints.png")
grid_img.save(paints_path)
print(f"Saved {paints_path}")

# ==============================================================================
# EXPORT GLB WITH EXACT Y-UP ORIENTATION
# ==============================================================================

def set_paint_for_gltf_export():
    m_p = MATS["Paint"]
    nt = m_p.node_tree
    bsdf = next(n for n in nt.nodes if n.type == 'BSDF_PRINCIPLED')
    tex_p = next(n for n in nt.nodes if n.type == 'TEX_IMAGE' and "paint_bc" in n.image.filepath)
    bsdf.inputs["Base Color"].default_value = (0.88, 0.89, 0.91, 1.0)
    nt.links.new(tex_p.outputs["Color"], bsdf.inputs["Base Color"])

def convert_scene_gltf_to_blender_coords():
    rot_3x3 = Matrix((
        (1.0,  0.0,  0.0),
        (0.0,  0.0, -1.0),
        (0.0,  1.0,  0.0),
    ))
    for o in MESH_OBJS + EMPTY_OBJS:
        o.location = rot_3x3 @ o.location
        if o in MESH_OBJS:
            for v in o.data.vertices:
                v.co = rot_3x3 @ v.co
            o.data.update()

set_paint_for_gltf_export()
convert_scene_gltf_to_blender_coords()
bpy.context.view_layer.update()

for draco_lib in ["/root/blender-spike/blender-5.2.0-linux-x64/lib/libdraco.so.9", "libdraco.so.9"]:
    try:
        ctypes.CDLL(draco_lib, mode=ctypes.RTLD_GLOBAL)
        break
    except Exception:
        pass

glb_path = os.path.join(WORK_DIR, "ambulance-van.glb")
bpy.ops.object.select_all(action='DESELECT')
for o in MESH_OBJS + EMPTY_OBJS:
    o.select_set(True)

bpy.ops.export_scene.gltf(
    filepath=glb_path,
    export_format='GLB',
    use_selection=True,
    export_apply=True,
    export_yup=True,
    export_texcoords=True,
    export_normals=True,
    export_materials='EXPORT',
    export_cameras=False,
    export_lights=False,
    export_draco_mesh_compression_enable=True,
    export_draco_mesh_compression_level=6,
)

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

enforce_glb_paint_factor(glb_path)
print(f"Exported {glb_path} ({os.path.getsize(glb_path)/1024:.1f} KB)")
