#!/usr/bin/env python3
"""
Build script for modern mid-size four-door family saloon / sedan (family-sedan-g4.glb).
Run headless in Blender 5.2:
  blender -b --python-exit-code 1 --python build_family-sedan-g4.py
"""

import os
import sys
import json
import math
import urllib.request
import bpy
import bmesh
from mathutils import Vector, Matrix

# ---------------------------------------------------------------------------
# Paths & Constants
# ---------------------------------------------------------------------------
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
TEX_DIR = os.path.join(SCRIPT_DIR, "textures")
os.makedirs(TEX_DIR, exist_ok=True)

# Dimensions (Forge Garage Contract v2):
# Length = 4.80 m (Z: -2.35 to +2.45)
# Width  = 1.84 m (X: -0.92 to +0.92)
# Height = 1.45 m (Y:  0.00 to +1.45)
# Wheelbase = 2.80 m (Z = +1.40 front axle, Z = -1.40 rear axle)
# Blender internal before export (+Y_up -> -Z_up):
#   Blender X = Left (+X) / Right (-X)
#   Blender Y = Rear (+Y) / Front (-Y)  --> Game +Z is Blender -Y
#   Blender Z = Up (+Z)                 --> Game +Y is Blender +Z

def co(x, y_up, z_fwd):
    return Vector((x, -z_fwd, y_up))

# ---------------------------------------------------------------------------
# 1. Poly Haven Texture Downloader & Procedural/Baked Atlas Generator
# ---------------------------------------------------------------------------
POLYHAVEN_IDS = [
    "metal_plate_02",
    "rusty_metal_03",
    "rubberized_track",
    "fabric_leather_02",
]

def ensure_polyhaven_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):
                url = f"https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{ph_id}/{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}")

def setup_cycles_gpu():
    scene = bpy.context.scene
    scene.render.engine = 'CYCLES'
    prefs = bpy.context.preferences
    cprefs = prefs.addons['cycles'].preferences
    cprefs.RefreshDevices() if hasattr(cprefs, 'RefreshDevices') else cprefs.get_devices()
    for dev_type in ('OPTIX', 'CUDA', 'HIP', 'ONEAPI'):
        try:
            cprefs.compute_device_type = dev_type
            for d in cprefs.devices:
                d.use = True
            scene.cycles.device = 'GPU'
            break
        except Exception:
            continue
    scene.cycles.use_denoising = True

def bake_material_maps():
    print("Baking PBR textures from Poly Haven + procedural nodes via Cycles...")
    setup_cycles_gpu()
    scene = bpy.context.scene
    scene.cycles.samples = 16

    bpy.ops.mesh.primitive_plane_add(size=2.0, location=(0, 0, 0))
    plane = bpy.context.active_object
    plane.name = "_BakePlane"

    def load_ph_image(ph_id, map_type, non_color=False):
        fpath = os.path.join(TEX_DIR, f"{ph_id}_{map_type}_1k.jpg")
        img = bpy.data.images.load(fpath, check_existing=True)
        if non_color:
            img.colorspace_settings.name = 'Non-Color'
        return img

    def do_bake_pass(mat, bake_type, out_path, size, is_non_color=False):
        img = bpy.data.images.new(name="_bake_tmp", width=size, height=size, alpha=False)
        if is_non_color:
            img.colorspace_settings.name = 'Non-Color'
        nodes = mat.node_tree.nodes
        tex_node = nodes.new('ShaderNodeTexImage')
        tex_node.image = img
        for n in nodes:
            n.select = False
        tex_node.select = True
        nodes.active = tex_node

        bpy.context.view_layer.objects.active = plane
        plane.select_set(True)
        if bake_type == 'EMIT':
            bpy.ops.object.bake(type='EMIT')
        elif bake_type == 'NORMAL':
            scene.render.bake.normal_space = 'TANGENT'
            bpy.ops.object.bake(type='NORMAL')

        img.filepath_raw = out_path
        img.file_format = 'JPEG' if out_path.endswith('.jpg') else 'PNG'
        img.save()
        nodes.remove(tex_node)
        bpy.data.images.remove(img)

    def bake_recipe(name, build_fn):
        mat = bpy.data.materials.new(name=f"_Bake_{name}")
        mat.use_nodes = True
        plane.data.materials.clear()
        plane.data.materials.append(mat)
        nt = mat.node_tree
        nt.nodes.clear()
        out_node = nt.nodes.new('ShaderNodeOutputMaterial')
        bsdf = nt.nodes.new('ShaderNodeBsdfPrincipled')
        nt.links.new(bsdf.outputs['BSDF'], out_node.inputs['Surface'])

        emit_col_socket, orm_socket, norm_socket = build_fn(nt, bsdf)

        norm_path = os.path.join(TEX_DIR, f"{name}_normal.png")
        if norm_socket is not None:
            nt.links.new(norm_socket, bsdf.inputs['Normal'])
        nt.links.new(bsdf.outputs['BSDF'], out_node.inputs['Surface'])
        do_bake_pass(mat, 'NORMAL', norm_path, 1024, is_non_color=True)

        col_path = os.path.join(TEX_DIR, f"{name}_basecolor.png")
        emit = nt.nodes.new('ShaderNodeEmission')
        nt.links.new(emit_col_socket, emit.inputs['Color'])
        nt.links.new(emit.outputs['Emission'], out_node.inputs['Surface'])
        do_bake_pass(mat, 'EMIT', col_path, 1024, is_non_color=False)

        orm_path = os.path.join(TEX_DIR, f"{name}_orm.png")
        nt.links.new(orm_socket, emit.inputs['Color'])
        nt.links.new(emit.outputs['Emission'], out_node.inputs['Surface'])
        do_bake_pass(mat, 'EMIT', orm_path, 512, is_non_color=True)

        bpy.data.materials.remove(mat)
        return col_path, norm_path, orm_path

    # --- Recipe 1: Paint (neutral white/light grey with lower-body road grime/salt/dust) ---
    def recipe_paint(nt, bsdf):
        tc = nt.nodes.new('ShaderNodeTexCoord')
        sep = nt.nodes.new('ShaderNodeSeparateXYZ')
        nt.links.new(tc.outputs['UV'], sep.inputs['Vector'])

        tex_m_diff = nt.nodes.new('ShaderNodeTexImage')
        tex_m_diff.image = load_ph_image("metal_plate_02", "diff", False)
        tex_r_diff = nt.nodes.new('ShaderNodeTexImage')
        tex_r_diff.image = load_ph_image("rusty_metal_03", "diff", False)
        tex_m_arm = nt.nodes.new('ShaderNodeTexImage')
        tex_m_arm.image = load_ph_image("metal_plate_02", "arm", True)
        tex_m_nor = nt.nodes.new('ShaderNodeTexImage')
        tex_m_nor.image = load_ph_image("metal_plate_02", "nor_gl", True)

        noise = nt.nodes.new('ShaderNodeTexNoise')
        noise.inputs['Scale'].default_value = 18.0
        noise.inputs['Detail'].default_value = 8.0
        noise.inputs['Roughness'].default_value = 0.65

        ramp_grime = nt.nodes.new('ShaderNodeValToRGB')
        ramp_grime.color_ramp.elements[0].position = 0.04
        ramp_grime.color_ramp.elements[0].color = (0.72, 0.72, 0.73, 1.0)
        ramp_grime.color_ramp.elements[1].position = 0.38
        ramp_grime.color_ramp.elements[1].color = (0.98, 0.98, 0.99, 1.0)
        nt.links.new(sep.outputs['Y'], ramp_grime.inputs['Fac'])

        bw_m = nt.nodes.new('ShaderNodeRGBToBW')
        nt.links.new(tex_m_diff.outputs['Color'], bw_m.inputs['Color'])
        bw_r = nt.nodes.new('ShaderNodeRGBToBW')
        nt.links.new(tex_r_diff.outputs['Color'], bw_r.inputs['Color'])

        mix_ph = nt.nodes.new('ShaderNodeMixRGB')
        mix_ph.blend_type = 'MULTIPLY'
        mix_ph.inputs['Fac'].default_value = 0.14
        nt.links.new(ramp_grime.outputs['Color'], mix_ph.inputs['Color1'])
        nt.links.new(bw_m.outputs['Val'], mix_ph.inputs['Color2'])

        mix_noise = nt.nodes.new('ShaderNodeMixRGB')
        mix_noise.blend_type = 'MULTIPLY'
        mix_noise.inputs['Fac'].default_value = 0.10
        nt.links.new(mix_ph.outputs['Color'], mix_noise.inputs['Color1'])
        nt.links.new(noise.outputs['Fac'], mix_noise.inputs['Color2'])

        # ORM for Paint: R = AO, G = Roughness (0.14 on upper body, 0.34 on lower road-grime strip), B = Metallic (0.85)
        sep_arm = nt.nodes.new('ShaderNodeSeparateColor')
        nt.links.new(tex_m_arm.outputs['Color'], sep_arm.inputs['Color'])

        ramp_rough = nt.nodes.new('ShaderNodeValToRGB')
        ramp_rough.color_ramp.elements[0].position = 0.05
        ramp_rough.color_ramp.elements[0].color = (0.34, 0.34, 0.34, 1.0)
        ramp_rough.color_ramp.elements[1].position = 0.38
        ramp_rough.color_ramp.elements[1].color = (0.14, 0.14, 0.14, 1.0)
        nt.links.new(sep.outputs['Y'], ramp_rough.inputs['Fac'])

        bw_rough = nt.nodes.new('ShaderNodeRGBToBW')
        nt.links.new(ramp_rough.outputs['Color'], bw_rough.inputs['Color'])

        comb_orm = nt.nodes.new('ShaderNodeCombineColor')
        nt.links.new(sep_arm.outputs['Red'], comb_orm.inputs['Red'])
        nt.links.new(bw_rough.outputs['Val'], comb_orm.inputs['Green'])
        comb_orm.inputs['Blue'].default_value = 0.85

        nmap = nt.nodes.new('ShaderNodeNormalMap')
        nmap.inputs['Strength'].default_value = 0.05
        nt.links.new(tex_m_nor.outputs['Color'], nmap.inputs['Color'])

        return mix_noise.outputs['Color'], comb_orm.outputs['Color'], nmap.outputs['Normal']

    # --- Recipe 2: Trim (UV.y > 0.5: Bright Chrome/Alloy, UV.y <= 0.5: Dark Satin Grille/Trim) ---
    def recipe_trim(nt, bsdf):
        tc = nt.nodes.new('ShaderNodeTexCoord')
        sep = nt.nodes.new('ShaderNodeSeparateXYZ')
        nt.links.new(tc.outputs['UV'], sep.inputs['Vector'])

        tex_diff = nt.nodes.new('ShaderNodeTexImage')
        tex_diff.image = load_ph_image("metal_plate_02", "diff", False)
        tex_arm = nt.nodes.new('ShaderNodeTexImage')
        tex_arm.image = load_ph_image("metal_plate_02", "arm", True)
        tex_nor = nt.nodes.new('ShaderNodeTexImage')
        tex_nor.image = load_ph_image("metal_plate_02", "nor_gl", True)

        gt = nt.nodes.new('ShaderNodeMath')
        gt.operation = 'GREATER_THAN'
        nt.links.new(sep.outputs['Y'], gt.inputs[0])
        gt.inputs[1].default_value = 0.5

        mix_col = nt.nodes.new('ShaderNodeMixRGB')
        nt.links.new(gt.outputs['Value'], mix_col.inputs['Fac'])
        mix_col.inputs['Color1'].default_value = (0.045, 0.048, 0.054, 1.0)
        mix_col.inputs['Color2'].default_value = (0.82, 0.84, 0.87, 1.0)

        mix_ph = nt.nodes.new('ShaderNodeMixRGB')
        mix_ph.blend_type = 'MULTIPLY'
        mix_ph.inputs['Fac'].default_value = 0.20
        nt.links.new(mix_col.outputs['Color'], mix_ph.inputs['Color1'])
        nt.links.new(tex_diff.outputs['Color'], mix_ph.inputs['Color2'])

        mix_rough = nt.nodes.new('ShaderNodeMixRGB')
        nt.links.new(gt.outputs['Value'], mix_rough.inputs['Fac'])
        mix_rough.inputs['Color1'].default_value = (0.42, 0.42, 0.42, 1.0)
        mix_rough.inputs['Color2'].default_value = (0.14, 0.14, 0.14, 1.0)

        mix_metal = nt.nodes.new('ShaderNodeMixRGB')
        nt.links.new(gt.outputs['Value'], mix_metal.inputs['Fac'])
        mix_metal.inputs['Color1'].default_value = (0.20, 0.20, 0.20, 1.0)
        mix_metal.inputs['Color2'].default_value = (0.95, 0.95, 0.95, 1.0)

        sep_arm = nt.nodes.new('ShaderNodeSeparateColor')
        nt.links.new(tex_arm.outputs['Color'], sep_arm.inputs['Color'])

        bw_r = nt.nodes.new('ShaderNodeRGBToBW')
        nt.links.new(mix_rough.outputs['Color'], bw_r.inputs['Color'])
        bw_m = nt.nodes.new('ShaderNodeRGBToBW')
        nt.links.new(mix_metal.outputs['Color'], bw_m.inputs['Color'])

        comb_orm = nt.nodes.new('ShaderNodeCombineColor')
        nt.links.new(sep_arm.outputs['Red'], comb_orm.inputs['Red'])
        nt.links.new(bw_r.outputs['Val'], comb_orm.inputs['Green'])
        nt.links.new(bw_m.outputs['Val'], comb_orm.inputs['Blue'])

        nmap = nt.nodes.new('ShaderNodeNormalMap')
        nmap.inputs['Strength'].default_value = 0.16
        nt.links.new(tex_nor.outputs['Color'], nmap.inputs['Color'])

        return mix_ph.outputs['Color'], comb_orm.outputs['Color'], nmap.outputs['Normal']

    # --- Recipe 3: Rubber (Deep vulcanised carbon-black tyre rubber using rubberized_track) ---
    def recipe_rubber(nt, bsdf):
        tex_diff = nt.nodes.new('ShaderNodeTexImage')
        tex_diff.image = load_ph_image("rubberized_track", "diff", False)
        tex_arm = nt.nodes.new('ShaderNodeTexImage')
        tex_arm.image = load_ph_image("rubberized_track", "arm", True)
        tex_nor = nt.nodes.new('ShaderNodeTexImage')
        tex_nor.image = load_ph_image("rubberized_track", "nor_gl", True)

        mix_col = nt.nodes.new('ShaderNodeMixRGB')
        mix_col.blend_type = 'MULTIPLY'
        mix_col.inputs['Fac'].default_value = 0.55
        mix_col.inputs['Color1'].default_value = (0.028, 0.030, 0.033, 1.0)
        nt.links.new(tex_diff.outputs['Color'], mix_col.inputs['Color2'])

        sep_arm = nt.nodes.new('ShaderNodeSeparateColor')
        nt.links.new(tex_arm.outputs['Color'], sep_arm.inputs['Color'])

        math_r = nt.nodes.new('ShaderNodeMath')
        math_r.operation = 'MULTIPLY_ADD'
        nt.links.new(sep_arm.outputs['Green'], math_r.inputs[0])
        math_r.inputs[1].default_value = 0.22
        math_r.inputs[2].default_value = 0.62

        comb_orm = nt.nodes.new('ShaderNodeCombineColor')
        nt.links.new(sep_arm.outputs['Red'], comb_orm.inputs['Red'])
        nt.links.new(math_r.outputs['Value'], comb_orm.inputs['Green'])
        comb_orm.inputs['Blue'].default_value = 0.02

        nmap = nt.nodes.new('ShaderNodeNormalMap')
        nmap.inputs['Strength'].default_value = 0.65
        nt.links.new(tex_nor.outputs['Color'], nmap.inputs['Color'])

        return mix_col.outputs['Color'], comb_orm.outputs['Color'], nmap.outputs['Normal']

    # --- Recipe 4: Interior (Charcoal leather & dashboard soft-touch using fabric_leather_02) ---
    def recipe_interior(nt, bsdf):
        tex_diff = nt.nodes.new('ShaderNodeTexImage')
        tex_diff.image = load_ph_image("fabric_leather_02", "diff", False)
        tex_arm = nt.nodes.new('ShaderNodeTexImage')
        tex_arm.image = load_ph_image("fabric_leather_02", "arm", True)
        tex_nor = nt.nodes.new('ShaderNodeTexImage')
        tex_nor.image = load_ph_image("fabric_leather_02", "nor_gl", True)

        mix_col = nt.nodes.new('ShaderNodeMixRGB')
        mix_col.blend_type = 'MULTIPLY'
        mix_col.inputs['Fac'].default_value = 0.45
        mix_col.inputs['Color1'].default_value = (0.11, 0.115, 0.13, 1.0)
        nt.links.new(tex_diff.outputs['Color'], mix_col.inputs['Color2'])

        sep_arm = nt.nodes.new('ShaderNodeSeparateColor')
        nt.links.new(tex_arm.outputs['Color'], sep_arm.inputs['Color'])

        comb_orm = nt.nodes.new('ShaderNodeCombineColor')
        nt.links.new(sep_arm.outputs['Red'], comb_orm.inputs['Red'])
        comb_orm.inputs['Green'].default_value = 0.60
        comb_orm.inputs['Blue'].default_value = 0.04

        nmap = nt.nodes.new('ShaderNodeNormalMap')
        nmap.inputs['Strength'].default_value = 0.45
        nt.links.new(tex_nor.outputs['Color'], nmap.inputs['Color'])

        return mix_col.outputs['Color'], comb_orm.outputs['Color'], nmap.outputs['Normal']

    # --- Recipe 5: Lamps (UV zoned: Headlight warm white, Taillight ruby red, Indicator amber) ---
    def recipe_lamps(nt, bsdf):
        tc = nt.nodes.new('ShaderNodeTexCoord')
        sep = nt.nodes.new('ShaderNodeSeparateXYZ')
        nt.links.new(tc.outputs['UV'], sep.inputs['Vector'])

        ramp = nt.nodes.new('ShaderNodeValToRGB')
        ramp.color_ramp.interpolation = 'CONSTANT'
        e0 = ramp.color_ramp.elements[0]
        e0.position = 0.0
        e0.color = (0.12, 0.13, 0.16, 1.0)
        e1 = ramp.color_ramp.elements[1]
        e1.position = 0.25
        e1.color = (1.00, 0.48, 0.04, 1.0)
        e2 = ramp.color_ramp.elements.new(0.50)
        e2.color = (0.92, 0.04, 0.04, 1.0)
        e3 = ramp.color_ramp.elements.new(0.75)
        e3.color = (0.99, 0.96, 0.88, 1.0)
        nt.links.new(sep.outputs['Y'], ramp.inputs['Fac'])

        comb_orm = nt.nodes.new('ShaderNodeCombineColor')
        comb_orm.inputs['Red'].default_value = 1.0
        comb_orm.inputs['Green'].default_value = 0.06
        comb_orm.inputs['Blue'].default_value = 0.15

        return ramp.outputs['Color'], comb_orm.outputs['Color'], None

    baked = {}
    baked["Paint"] = bake_recipe("Paint", recipe_paint)
    baked["Trim"] = bake_recipe("Trim", recipe_trim)
    baked["Rubber"] = bake_recipe("Rubber", recipe_rubber)
    baked["Interior"] = bake_recipe("Interior", recipe_interior)
    baked["Lamps"] = bake_recipe("Lamps", recipe_lamps)

    bpy.data.objects.remove(plane, do_unlink=True)
    return baked

# ---------------------------------------------------------------------------
# 2. Build Clean glTF-Ready PBR Materials from Baked Textures
# ---------------------------------------------------------------------------
def create_gltf_materials(baked):
    mats = {}

    def make_pbr_mat(name, col_path, norm_path, orm_path, base_factor=(1,1,1,1), clearcoat=0.0, emissive=False):
        mat = bpy.data.materials.new(name=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'])

        img_col = bpy.data.images.load(col_path, check_existing=True)
        tex_col = nt.nodes.new('ShaderNodeTexImage')
        tex_col.image = img_col

        if base_factor != (1, 1, 1, 1):
            mix_f = nt.nodes.new('ShaderNodeMixRGB')
            mix_f.blend_type = 'MULTIPLY'
            mix_f.inputs['Fac'].default_value = 1.0
            mix_f.inputs['Color2'].default_value = base_factor
            nt.links.new(tex_col.outputs['Color'], mix_f.inputs['Color1'])
            nt.links.new(mix_f.outputs['Color'], bsdf.inputs['Base Color'])
            bsdf.inputs['Base Color'].default_value = base_factor
        else:
            nt.links.new(tex_col.outputs['Color'], bsdf.inputs['Base Color'])

        img_orm = bpy.data.images.load(orm_path, check_existing=True)
        img_orm.colorspace_settings.name = 'Non-Color'
        tex_orm = nt.nodes.new('ShaderNodeTexImage')
        tex_orm.image = img_orm
        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'])

        gltf_settings = nt.nodes.new('ShaderNodeGroup')
        if "glTF Material Output" not in bpy.data.node_groups:
            ng = bpy.data.node_groups.new("glTF Material Output", 'ShaderNodeTree')
            ng.interface.new_socket(name="Occlusion", in_out='INPUT', socket_type='NodeSocketFloat')
        gltf_settings.node_tree = bpy.data.node_groups["glTF Material Output"]
        nt.links.new(sep.outputs['Red'], gltf_settings.inputs['Occlusion'])

        img_nor = bpy.data.images.load(norm_path, check_existing=True)
        img_nor.colorspace_settings.name = 'Non-Color'
        tex_nor = nt.nodes.new('ShaderNodeTexImage')
        tex_nor.image = img_nor
        nmap = nt.nodes.new('ShaderNodeNormalMap')
        nt.links.new(tex_nor.outputs['Color'], nmap.inputs['Color'])
        nt.links.new(nmap.outputs['Normal'], bsdf.inputs['Normal'])

        if clearcoat > 0.0:
            if 'Coat Weight' in bsdf.inputs:
                bsdf.inputs['Coat Weight'].default_value = clearcoat
                bsdf.inputs['Coat Roughness'].default_value = 0.05
            elif 'Clearcoat' in bsdf.inputs:
                bsdf.inputs['Clearcoat'].default_value = clearcoat
                bsdf.inputs['Clearcoat Roughness'].default_value = 0.05

        if emissive:
            if 'Emission Color' in bsdf.inputs:
                nt.links.new(tex_col.outputs['Color'], bsdf.inputs['Emission Color'])
                bsdf.inputs['Emission Strength'].default_value = 2.2
            elif 'Emission' in bsdf.inputs:
                nt.links.new(tex_col.outputs['Color'], bsdf.inputs['Emission'])
                bsdf.inputs['Emission Strength'].default_value = 2.2

        return mat

    mats["Paint"] = make_pbr_mat("Paint", *baked["Paint"], base_factor=(0.02, 0.05, 0.16, 1.0), clearcoat=1.0)
    mats["Trim"] = make_pbr_mat("Trim", *baked["Trim"])
    mats["Rubber"] = make_pbr_mat("Rubber", *baked["Rubber"])
    mats["Interior"] = make_pbr_mat("Interior", *baked["Interior"])
    mats["Lamps"] = make_pbr_mat("Lamps", *baked["Lamps"], emissive=True)

    mat_glass = bpy.data.materials.new(name="Glass")
    mat_glass.use_nodes = True
    if hasattr(mat_glass, 'blend_method'):
        mat_glass.blend_method = 'BLEND'
    if hasattr(mat_glass, 'surface_render_method'):
        mat_glass.surface_render_method = 'BLENDED'
    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.08, 0.12, 0.15, 1.0)
    bsdf.inputs['Metallic'].default_value = 0.12
    bsdf.inputs['Roughness'].default_value = 0.03
    bsdf.inputs['Alpha'].default_value = 0.36
    if 'IOR' in bsdf.inputs:
        bsdf.inputs['IOR'].default_value = 1.50
    mats["Glass"] = mat_glass

    return mats

# ---------------------------------------------------------------------------
# 3. UV Mapping & BMesh Object Helpers
# ---------------------------------------------------------------------------
def uv_box_project_bm(bm, uv_layer, scale=0.5, v_min=0.0, v_max=1.0):
    for f in bm.faces:
        n = f.normal
        ax, ay, az = abs(n.x), abs(n.y), abs(n.z)
        for loop in f.loops:
            v = loop.vert.co
            if ax >= ay and ax >= az:
                u = (v.y * scale) % 1.0
                vt = (v.z * scale) % 1.0
            elif ay >= ax and ay >= az:
                u = (v.x * scale) % 1.0
                vt = (v.z * scale) % 1.0
            else:
                u = (v.x * scale) % 1.0
                vt = (v.y * scale) % 1.0
            loop[uv_layer].uv = (u, v_min + vt * (v_max - v_min))

def uv_paint_project_bm(bm, uv_layer):
    for f in bm.faces:
        n = f.normal
        for loop in f.loops:
            v = loop.vert.co
            if abs(n.z) > 0.72:
                u = (v.x / 1.84 + 0.5) % 1.0
                vt = 0.50 + 0.45 * ((v.y / 4.80 + 0.5) % 1.0)
            else:
                u = (v.y / 4.80 + 0.5) % 1.0
                vt = max(0.02, min(0.98, (v.z - 0.14) / 1.30))
            loop[uv_layer].uv = (u, vt)

def new_bm_object(name, mat):
    mesh = bpy.data.meshes.new(name)
    obj = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(obj)
    obj.data.materials.append(mat)
    return obj

def finalize_bm(obj, bm, uv_mode="box", v_range=(0.0, 1.0), smooth=True, bevel_width=0.0, bevel_segs=2, subsurf=0):
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    uv_layer = bm.loops.layers.uv.verify()
    if uv_mode == "paint":
        uv_paint_project_bm(bm, uv_layer)
    else:
        uv_box_project_bm(bm, uv_layer, scale=0.6, v_min=v_range[0], v_max=v_range[1])
    for f in bm.faces:
        f.smooth = smooth
    bm.to_mesh(obj.data)
    bm.free()

    if bevel_width > 0.0:
        bev = obj.modifiers.new(name="Bevel", type='BEVEL')
        bev.width = bevel_width
        bev.segments = bevel_segs
        bev.limit_method = 'ANGLE'
        bev.angle_limit = math.radians(32.0)
        bev.harden_normals = False

    if subsurf > 0:
        sub = obj.modifiers.new(name="Subsurf", type='SUBSURF')
        sub.levels = subsurf
        sub.render_levels = subsurf

    return obj

# ---------------------------------------------------------------------------
# 4. Parametric Car Body Surface & Panel Builder
# ---------------------------------------------------------------------------
# Longitudinal profile stations (z_fwd from -2.35m rear to +2.45m front):
# Note how at z=-2.35 and z=+2.45 we curve smoothly inward in plan & elevation!
PROFILE_STATIONS = [
    # z_fwd,  y_bot, y_fend, y_shld, y_belt, y_roof, hw_bot, hw_mid, hw_belt, hw_roof
    (-2.33,   0.29,  0.54,   0.78,   0.92,   0.93,   0.62,   0.68,   0.63,    0.55),  # Rear bumper center curve
    (-2.27,   0.26,  0.55,   0.82,   0.98,   0.99,   0.73,   0.80,   0.75,    0.66),  # Rear bumper corner
    (-2.15,   0.24,  0.56,   0.85,   1.01,   1.02,   0.78,   0.85,   0.80,    0.70),  # Boot lid trailing lip (subtle spoiler edge)
    (-1.90,   0.23,  0.57,   0.86,   1.00,   1.03,   0.81,   0.88,   0.82,    0.70),  # Mid boot deck
    (-1.68,   0.22,  0.58,   0.86,   0.99,   1.05,   0.83,   0.90,   0.83,    0.69),  # Base of C-pillar / rear screen bottom
    (-1.40,   0.21,  0.59,   0.86,   0.98,   1.20,   0.84,   0.92,   0.84,    0.66),  # Rear axle center / C-pillar mid
    (-1.05,   0.18,  0.59,   0.85,   0.97,   1.34,   0.83,   0.91,   0.84,    0.64),  # Rear wheel arch front / C-pillar top
    (-0.65,   0.16,  0.58,   0.85,   0.96,   1.42,   0.83,   0.90,   0.84,    0.63),  # Rear door center
    (-0.20,   0.16,  0.58,   0.84,   0.95,   1.45,   0.83,   0.90,   0.84,    0.63),  # B-pillar / roof peak (1.45m)
    ( 0.25,   0.16,  0.58,   0.84,   0.94,   1.43,   0.83,   0.90,   0.84,    0.62),  # Front door center
    ( 0.62,   0.16,  0.57,   0.83,   0.93,   1.36,   0.83,   0.90,   0.83,    0.61),  # A-pillar mid
    ( 0.92,   0.16,  0.57,   0.82,   0.91,   0.92,   0.83,   0.90,   0.82,    0.72),  # Base of windshield / cowl
    ( 1.15,   0.18,  0.58,   0.81,   0.89,   0.90,   0.84,   0.91,   0.81,    0.72),  # Front wheel arch rear
    ( 1.40,   0.20,  0.58,   0.80,   0.87,   0.88,   0.84,   0.92,   0.80,    0.70),  # Front axle center
    ( 1.75,   0.20,  0.56,   0.77,   0.84,   0.85,   0.83,   0.90,   0.78,    0.67),  # Front wheel arch front
    ( 2.10,   0.19,  0.52,   0.72,   0.78,   0.79,   0.78,   0.82,   0.75,    0.63),  # Headlight outer corner
    ( 2.26,   0.19,  0.47,   0.65,   0.71,   0.72,   0.62,   0.66,   0.62,    0.52),  # Front bumper mid-corner
    ( 2.38,   0.20,  0.44,   0.62,   0.68,   0.69,   0.42,   0.45,   0.43,    0.38),  # Front grille nose edge
]

def interp_station(z_q):
    if z_q <= PROFILE_STATIONS[0][0]:
        return PROFILE_STATIONS[0][1:]
    if z_q >= PROFILE_STATIONS[-1][0]:
        return PROFILE_STATIONS[-1][1:]
    for i in range(len(PROFILE_STATIONS) - 1):
        z0 = PROFILE_STATIONS[i][0]
        z1 = PROFILE_STATIONS[i + 1][0]
        if z0 <= z_q <= z1:
            t = (z_q - z0) / (z1 - z0)
            vals0 = PROFILE_STATIONS[i][1:]
            vals1 = PROFILE_STATIONS[i + 1][1:]
            return [v0 + t * (v1 - v0) for v0, v1 in zip(vals0, vals1)]
    return PROFILE_STATIONS[-1][1:]

def wheel_arch_lift(z_fwd, y_up, x_abs):
    r_arch = 0.395
    r_flare = 0.465
    y_center = 0.34
    for z_axle in (1.40, -1.40):
        dz = z_fwd - z_axle
        if abs(dz) < r_flare:
            horiz_span = math.sqrt(max(0.0, r_arch * r_arch - dz * dz))
            y_arch_lip = y_center + horiz_span * 0.96
            dist_r = math.sqrt(dz * dz + max(0.0, y_up - y_center) ** 2)
            flare = 0.0
            if r_arch * 0.85 <= dist_r <= r_flare and x_abs > 0.72:
                w = 1.0 - abs(dist_r - r_arch) / (r_flare - r_arch * 0.85)
                flare = 0.024 * max(0.0, w)
            return y_arch_lip, flare, (abs(dz) < r_arch * 0.98)
    return None, 0.0, False

def build_painted_body_shell(mats):
    bm = bmesh.new()
    nz = 88
    z_list = [-2.33 + (4.71 * i / (nz - 1)) for i in range(nz)]  # -2.33 to +2.38

    ring_verts = []
    for z in z_list:
        y_bot, y_fend, y_shld, y_belt, y_roof, hw_bot, hw_mid, hw_belt, hw_roof = interp_station(z)
        y_arch, flare, in_arch = wheel_arch_lift(z, y_fend, hw_mid)

        seam_indent = 0.0
        for z_seam in (-1.68, -0.98, -0.18, 0.72, 0.92, 1.98):
            if abs(z - z_seam) < 0.016:
                seam_indent = -0.0045

        y_sill = max(y_bot, y_arch) if (in_arch and y_arch is not None) else y_bot
        y_low_mid = max(y_sill + 0.04, 0.5 * (y_sill + y_fend))

        in_cabin = (-1.60 < z < 0.90)
        if in_cabin:
            y_inner_top = 0.38 if (-1.35 < z < 0.65) else 0.68
            hw_inner = hw_belt - 0.14
        else:
            crown = 0.032 if z >= 0.90 else 0.024
            y_inner_top = y_belt + crown
            hw_inner = hw_belt * 0.52

        def z_curve(x_val):
            if z > 1.85:
                w = (z - 1.85) / (2.38 - 1.85)
                return z + w * 0.055 * (1.0 - (x_val / max(0.45, hw_mid)) ** 2)
            elif z < -1.85:
                w = (-1.85 - z) / (-1.85 - (-2.33))
                return z - w * 0.025 * (1.0 - (x_val / max(0.45, hw_mid)) ** 2)
            return z

        pts_pos = [
            (0.0,                                    y_bot,                        z_curve(0.0)),
            (hw_bot * 0.55,                          y_bot,                        z_curve(hw_bot * 0.55)),
            (hw_bot * 0.94,                          y_sill,                       z_curve(hw_bot * 0.94)),
            (hw_bot + flare + seam_indent,           y_sill + 0.03,                z_curve(hw_bot)),
            (hw_mid * 0.98 + flare + seam_indent,    y_low_mid,                    z_curve(hw_mid)),
            (hw_mid + flare + seam_indent,           max(y_fend, y_sill + 0.06),   z_curve(hw_mid)),
            (hw_mid * 0.985 + seam_indent,           y_shld - 0.015,               z_curve(hw_mid)),
            (hw_belt * 1.015 + seam_indent,          y_shld + 0.012,               z_curve(hw_belt)),
            (hw_belt + seam_indent,                  y_belt,                       z_curve(hw_belt)),
            (hw_inner,                               y_inner_top,                  z_curve(hw_inner)),
            (0.0,                                    y_inner_top + (0.012 if not in_cabin else -0.10), z_curve(0.0)),
        ]

        loop_verts = []
        for x, y, zc in pts_pos:
            loop_verts.append(bm.verts.new(co(x, y, zc)))
        for x, y, zc in reversed(pts_pos[1:-1]):
            loop_verts.append(bm.verts.new(co(-x, y, zc)))
        ring_verts.append(loop_verts)

    n_loop = len(ring_verts[0])
    for i in range(nz - 1):
        r0 = ring_verts[i]
        r1 = ring_verts[i + 1]
        for j in range(n_loop):
            jn = (j + 1) % n_loop
            bm.faces.new((r0[j], r0[jn], r1[jn], r1[j]))

    for end_ring, flip in ((ring_verts[0], False), (ring_verts[-1], True)):
        half = (n_loop // 2)
        for j in range(half):
            v0 = end_ring[j]
            v1 = end_ring[j + 1]
            v2 = end_ring[(n_loop - 1 - j) % n_loop]
            v3 = end_ring[(n_loop - j) % n_loop]
            verts = [v0, v1, v2, v3]
            uniq = []
            for v in verts:
                if v not in uniq:
                    uniq.append(v)
            if len(uniq) >= 3:
                bm.faces.new(uniq if not flip else list(reversed(uniq)))

    obj_body = new_bm_object("Body_MainShell", mats["Paint"])
    finalize_bm(obj_body, bm, uv_mode="paint", smooth=True, bevel_width=0.006, bevel_segs=2, subsurf=1)
    return obj_body

# ---------------------------------------------------------------------------
# 5. Roof, Pillars, Greenhouse Glass, Bumpers, Grille, Lamps & Exterior Detail
# ---------------------------------------------------------------------------
def add_lofted_strip(bm, stations, closed_profile=True):
    rings = []
    for st in stations:
        r = [bm.verts.new(co(x, y, z)) for (x, y, z) in st]
        rings.append(r)
    n_r = len(rings)
    n_p = len(rings[0])
    for i in range(n_r - 1):
        r0, r1 = rings[i], rings[i + 1]
        limit = n_p if closed_profile else (n_p - 1)
        for j in range(limit):
            jn = (j + 1) % n_p
            bm.faces.new((r0[j], r0[jn], r1[jn], r1[j]))
    if closed_profile and n_p >= 3:
        bm.faces.new(list(reversed(rings[0])))
        bm.faces.new(rings[-1])
    return rings

def build_roof_and_pillars(mats):
    bm = bmesh.new()

    roof_stations = []
    nz_roof = 22
    for i in range(nz_roof):
        t = i / (nz_roof - 1)
        z = -1.06 + t * (0.34 - (-1.06))
        _, _, _, _, y_roof, _, _, _, hw_roof = interp_station(z)
        thick = 0.026
        pts_top = []
        pts_bot = []
        nx = 9
        for k in range(nx):
            u = -1.0 + 2.0 * k / (nx - 1)
            x = u * (hw_roof - 0.02)
            y = y_roof - 0.030 * (u * u)
            pts_top.append((x, y, z))
            pts_bot.append((x * 0.96, y - thick, z))
        ring = pts_top + list(reversed(pts_bot))
        roof_stations.append(ring)
    add_lofted_strip(bm, roof_stations, closed_profile=True)

    arch_z = [0.92, 0.78, 0.62, 0.46, 0.34, 0.10, -0.20, -0.55, -0.85, -1.06, -1.28, -1.48, -1.68]
    for side in (1.0, -1.0):
        arch_stations = []
        for z in arch_z:
            _, _, _, y_belt, y_roof, _, _, hw_belt, hw_roof = interp_station(z)
            if z > 0.34:
                u = (z - 0.34) / (0.92 - 0.34)
                y_ext = y_roof * (1 - u) + y_belt * u - 0.02
                x_ext = hw_roof * (1 - u) + hw_belt * u
                w_pil = 0.055 + 0.025 * u
            elif z < -1.06:
                u = (-1.06 - z) / (-1.06 - (-1.68))
                y_ext = y_roof * (1 - u) + y_belt * u - 0.015
                x_ext = hw_roof * (1 - u) + hw_belt * u
                w_pil = 0.075 + 0.055 * u
            else:
                y_ext = y_roof - 0.028
                x_ext = hw_roof
                w_pil = 0.052

            p0 = (side * x_ext,                y_ext - 0.012, z)
            p1 = (side * (x_ext - w_pil*0.45), y_ext + 0.012, z)
            p2 = (side * (x_ext - w_pil),      y_ext + 0.008, z)
            p3 = (side * (x_ext - w_pil),      y_ext - 0.028, z)
            p4 = (side * (x_ext - w_pil*0.45), y_ext - 0.036, z)
            p5 = (side * (x_ext - 0.005),      y_ext - 0.032, z)
            ring = [p0, p1, p2, p3, p4, p5]
            if side < 0:
                ring = list(reversed(ring))
            arch_stations.append(ring)
        add_lofted_strip(bm, arch_stations, closed_profile=True)

    _, _, _, _, y_r_ant, _, _, _, _ = interp_station(-0.96)
    ant_stations = [
        [(-0.022, y_r_ant - 0.01, -0.86), (0.0, y_r_ant + 0.005, -0.86), (0.022, y_r_ant - 0.01, -0.86), (0.0, y_r_ant - 0.015, -0.86)],
        [(-0.016, y_r_ant - 0.01, -0.95), (0.0, y_r_ant + 0.058, -0.96), (0.016, y_r_ant - 0.01, -0.95), (0.0, y_r_ant - 0.015, -0.95)],
        [(-0.008, y_r_ant - 0.01, -1.03), (0.0, y_r_ant + 0.048, -1.04), (0.008, y_r_ant - 0.01, -1.03), (0.0, y_r_ant - 0.015, -1.03)],
    ]
    add_lofted_strip(bm, ant_stations, closed_profile=True)

    obj_roof = new_bm_object("Body_RoofPillars", mats["Paint"])
    finalize_bm(obj_roof, bm, uv_mode="paint", smooth=True, bevel_width=0.004, bevel_segs=2, subsurf=1)
    return obj_roof

def build_wheel_arch_liners(mats):
    bm = bmesh.new()
    for side in (1.0, -1.0):
        for z_axle in (1.40, -1.40):
            r_outer = 0.398
            x_out = side * 0.855
            x_in  = side * 0.560
            y_c   = 0.34
            n_seg = 24
            ring_out = []
            ring_in  = []
            for i in range(n_seg):
                ang = -0.12 * math.pi + (1.24 * math.pi) * (i / (n_seg - 1))
                dz = r_outer * math.cos(ang)
                dy = r_outer * math.sin(ang)
                ring_out.append(bm.verts.new(co(x_out, max(0.18, y_c + dy), z_axle + dz)))
                ring_in.append(bm.verts.new(co(x_in,  max(0.18, y_c + dy * 0.95), z_axle + dz * 0.95)))
            for i in range(n_seg - 1):
                if side > 0:
                    bm.faces.new((ring_out[i], ring_out[i+1], ring_in[i+1], ring_in[i]))
                else:
                    bm.faces.new((ring_in[i], ring_in[i+1], ring_out[i+1], ring_out[i]))
            bm.faces.new(ring_in if side > 0 else list(reversed(ring_in)))

    v0 = bm.verts.new(co(-0.68, 0.175, -2.15))
    v1 = bm.verts.new(co( 0.68, 0.175, -2.15))
    v2 = bm.verts.new(co( 0.68, 0.175,  2.25))
    v3 = bm.verts.new(co(-0.68, 0.175,  2.25))
    bm.faces.new((v0, v1, v2, v3))

    obj_liners = new_bm_object("Body_WheelLiners", mats["Trim"])
    finalize_bm(obj_liners, bm, uv_mode="box", v_range=(0.05, 0.45), smooth=True)
    return obj_liners

def build_windows_and_frames(mats):
    bm_glass = bmesh.new()
    bm_chrome = bmesh.new()
    bm_dark = bmesh.new()

    nz_ws = 10
    nx_ws = 12
    ws_grid = []
    for i in range(nz_ws):
        t = i / (nz_ws - 1)
        z = 0.90 + t * (0.34 - 0.90)
        _, _, _, y_belt, y_roof, _, _, hw_belt, hw_roof = interp_station(z)
        y_base = y_belt * (1 - t) + (y_roof - 0.018) * t
        hw = (hw_belt - 0.045) * (1 - t) + (hw_roof - 0.045) * t
        row = []
        for k in range(nx_ws):
            u = -1.0 + 2.0 * k / (nx_ws - 1)
            x = u * hw
            arch_y = 0.022 * (1.0 - u * u)
            arch_z = 0.045 * (1.0 - u * u) * (1.0 - 0.4 * t)
            row.append(bm_glass.verts.new(co(x, y_base + arch_y, z + arch_z)))
        ws_grid.append(row)
    for i in range(nz_ws - 1):
        for k in range(nx_ws - 1):
            bm_glass.faces.new((ws_grid[i][k], ws_grid[i][k+1], ws_grid[i+1][k+1], ws_grid[i+1][k]))

    rw_grid = []
    for i in range(nz_ws):
        t = i / (nz_ws - 1)
        z = -1.06 + t * (-1.66 - (-1.06))
        _, _, _, y_belt, y_roof, _, _, hw_belt, hw_roof = interp_station(z)
        y_base = (y_roof - 0.018) * (1 - t) + (y_belt + 0.01) * t
        hw = (hw_roof - 0.05) * (1 - t) + (hw_belt - 0.06) * t
        row = []
        for k in range(nx_ws):
            u = -1.0 + 2.0 * k / (nx_ws - 1)
            x = u * hw
            arch_y = 0.018 * (1.0 - u * u)
            arch_z = -0.035 * (1.0 - u * u)
            row.append(bm_glass.verts.new(co(x, y_base + arch_y, z + arch_z)))
        rw_grid.append(row)
    for i in range(nz_ws - 1):
        for k in range(nx_ws - 1):
            bm_glass.faces.new((rw_grid[i][k], rw_grid[i][k+1], rw_grid[i+1][k+1], rw_grid[i+1][k]))

    side_z = [0.76, 0.58, 0.36, 0.12, -0.16, -0.45, -0.75, -1.00, -1.20, -1.36]
    for side in (1.0, -1.0):
        sw_bot, sw_mid, sw_top = [], [], []
        bot_trim_stations, top_trim_stations = [], []
        for z in side_z:
            _, _, _, y_belt, y_roof, _, _, hw_belt, hw_roof = interp_station(z)
            if z > 0.34:
                u = (z - 0.34) / (0.76 - 0.34)
                y_top = (y_roof - 0.055) * (1 - u) + (y_belt + 0.025) * u
                x_top = (hw_roof - 0.015) * (1 - u) + (hw_belt - 0.012) * u
            elif z < -0.95:
                u = (-0.95 - z) / (-0.95 - (-1.36))
                y_top = (y_roof - 0.065) * (1 - u) + (y_belt + 0.025) * u
                x_top = (hw_roof - 0.018) * (1 - u) + (hw_belt - 0.014) * u
            else:
                y_top = y_roof - 0.052
                x_top = hw_roof - 0.012

            y_b = y_belt + 0.012
            x_b = hw_belt - 0.012
            y_m = 0.5 * (y_b + y_top)
            x_m = 0.5 * (x_b + x_top) + 0.012

            sw_bot.append(bm_glass.verts.new(co(side * x_b, y_b, z)))
            sw_mid.append(bm_glass.verts.new(co(side * x_m, y_m, z)))
            sw_top.append(bm_glass.verts.new(co(side * x_top, y_top, z)))

            r_b = [
                (side * (x_b + 0.018), y_b - 0.009, z),
                (side * (x_b + 0.018), y_b + 0.011, z),
                (side * (x_b - 0.008), y_b + 0.011, z),
                (side * (x_b - 0.008), y_b - 0.009, z),
            ]
            r_t = [
                (side * (x_top + 0.014), y_top - 0.009, z),
                (side * (x_top + 0.014), y_top + 0.010, z),
                (side * (x_top - 0.008), y_top + 0.010, z),
                (side * (x_top - 0.008), y_top - 0.009, z),
            ]
            if side < 0:
                r_b = list(reversed(r_b))
                r_t = list(reversed(r_t))
            bot_trim_stations.append(r_b)
            top_trim_stations.append(r_t)

        for i in range(len(side_z) - 1):
            if side > 0:
                bm_glass.faces.new((sw_bot[i], sw_bot[i+1], sw_mid[i+1], sw_mid[i]))
                bm_glass.faces.new((sw_mid[i], sw_mid[i+1], sw_top[i+1], sw_top[i]))
            else:
                bm_glass.faces.new((sw_bot[i+1], sw_bot[i], sw_mid[i], sw_mid[i+1]))
                bm_glass.faces.new((sw_mid[i+1], sw_mid[i], sw_top[i], sw_top[i+1]))

        add_lofted_strip(bm_chrome, bot_trim_stations, closed_profile=True)
        add_lofted_strip(bm_chrome, top_trim_stations, closed_profile=True)

        for z_pil, half_dz in ((-0.18, 0.048), (-0.96, 0.020)):
            _, _, _, y_belt, y_roof, _, _, hw_belt, hw_roof = interp_station(z_pil)
            y_b = y_belt + 0.005
            y_t = y_roof - 0.050 if z_pil > -0.5 else y_roof - 0.068
            x_b = hw_belt - 0.004
            x_t = hw_roof - 0.008
            pil_stations = []
            for s in (0.0, 0.5, 1.0):
                yy = y_b * (1 - s) + y_t * s
                xx = x_b * (1 - s) + x_t * s + (0.010 if s == 0.5 else 0.0)
                r_p = [
                    (side * (xx + 0.004), yy, z_pil - half_dz),
                    (side * (xx + 0.004), yy, z_pil + half_dz),
                    (side * (xx - 0.022), yy, z_pil + half_dz),
                    (side * (xx - 0.022), yy, z_pil - half_dz),
                ]
                if side < 0:
                    r_p = list(reversed(r_p))
                pil_stations.append(r_p)
            add_lofted_strip(bm_dark, pil_stations, closed_profile=True)

    for x0, x1, z_off in ((-0.52, 0.08, 0.0), (0.02, 0.62, -0.025)):
        w_stations = []
        for s in (0.0, 0.5, 1.0):
            xx = x0 + s * (x1 - x0)
            zz = 0.89 + z_off - 0.02 * math.sin(s * math.pi)
            yy = 0.925 - 0.015 * (xx * xx)
            r_w = [
                (xx, yy,         zz - 0.008),
                (xx, yy + 0.012, zz - 0.005),
                (xx, yy + 0.012, zz + 0.005),
                (xx, yy,         zz + 0.008),
            ]
            w_stations.append(r_w)
        add_lofted_strip(bm_dark, w_stations, closed_profile=True)

    obj_glass = new_bm_object("Body_Glass", mats["Glass"])
    finalize_bm(obj_glass, bm_glass, uv_mode="box", smooth=True, bevel_width=0.0, subsurf=0)

    obj_chrome = new_bm_object("Body_WindowChrome", mats["Trim"])
    finalize_bm(obj_chrome, bm_chrome, uv_mode="box", v_range=(0.55, 0.95), smooth=True, bevel_width=0.002, bevel_segs=1)

    obj_dark = new_bm_object("Body_BPillarsWipers", mats["Trim"])
    finalize_bm(obj_dark, bm_dark, uv_mode="box", v_range=(0.05, 0.45), smooth=True, bevel_width=0.003, bevel_segs=1)

    return [obj_glass, obj_chrome, obj_dark]

def build_front_rear_details(mats):
    bm_paint = bmesh.new()
    bm_chrome = bmesh.new()
    bm_dark = bmesh.new()
    bm_lamps_head = bmesh.new()
    bm_lamps_tail = bmesh.new()
    bm_lamps_amber = bmesh.new()
    bm_glass = bmesh.new()

    def z_nose_surf(x, y):
        # Exact match to build_painted_body_shell front surface across X=[0..0.82]
        ax = abs(x)
        if ax <= 0.45:
            z_shell = 2.38 + 0.055 * (1.0 - (ax / 0.45) ** 2)
        elif ax <= 0.66:
            t = (ax - 0.45) / (0.66 - 0.45)
            z_shell = 2.38 * (1.0 - t) + 2.26 * t
        else:
            t = min(1.0, (ax - 0.66) / (0.82 - 0.66))
            z_shell = 2.26 * (1.0 - t) + 2.10 * t
        return z_shell - 0.008 * ((y - 0.44) ** 2) + 0.015

    # 1. Hexagonal Front Grille (y from 0.24 to 0.64)
    def grille_hw(y):
        if y >= 0.54:
            return 0.42 - 0.40 * (y - 0.54)
        return 0.34 + 0.08 * (y - 0.24) / (0.54 - 0.24)

    g_ys = [0.24, 0.34, 0.44, 0.54, 0.64]
    g_stations = []
    for y in g_ys:
        w = grille_hw(y)
        r_g = []
        for u in (-1.0, -0.5, 0.0, 0.5, 1.0):
            xx = u * w
            zz = z_nose_surf(xx, y) + 0.010
            r_g.append((xx, y, zz))
        for u in (1.0, 0.5, 0.0, -0.5, -1.0):
            xx = u * w
            zz = z_nose_surf(xx, y) - 0.035
            r_g.append((xx, y, zz))
        g_stations.append(r_g)
    add_lofted_strip(bm_dark, g_stations, closed_profile=True)

    g_perim = [
        (0.0, 0.645), (-0.38, 0.645), (-0.425, 0.54), (-0.345, 0.235),
        (0.0, 0.235), ( 0.345, 0.235), ( 0.425, 0.54), ( 0.38, 0.645), (0.0, 0.645)
    ]
    frame_stations = []
    for (gx, gy) in g_perim:
        gz = z_nose_surf(gx, gy)
        dx, dy = gx, gy - 0.44
        ln = max(1e-4, math.hypot(dx, dy))
        nx, ny = dx / ln, dy / ln
        r_f = [
            (gx - nx * 0.014, gy - ny * 0.014, gz - 0.020),
            (gx - nx * 0.012, gy - ny * 0.012, gz + 0.022),
            (gx + nx * 0.014, gy + ny * 0.014, gz + 0.020),
            (gx + nx * 0.018, gy + ny * 0.018, gz - 0.020),
        ]
        frame_stations.append(r_f)
    add_lofted_strip(bm_chrome, frame_stations, closed_profile=True)

    for k in range(6):
        yy = 0.285 + k * 0.062
        hw = grille_hw(yy) - 0.010
        slat_stations = []
        for s in range(9):
            u = -1.0 + 2.0 * s / 8.0
            xx = u * hw
            zz = z_nose_surf(xx, yy)
            r_s = [
                (xx, yy - 0.008, zz - 0.015),
                (xx, yy - 0.006, zz + 0.022),
                (xx, yy + 0.006, zz + 0.022),
                (xx, yy + 0.008, zz - 0.015),
            ]
            slat_stations.append(r_s)
        add_lofted_strip(bm_chrome, slat_stations, closed_profile=True)

    for vx in (-0.26, -0.13, 0.0, 0.13, 0.26):
        v_stations = []
        for yy in (0.25, 0.44, 0.63):
            zz = z_nose_surf(vx, yy)
            v_stations.append([
                (vx - 0.006, yy, zz - 0.015),
                (vx + 0.006, yy, zz - 0.015),
                (vx + 0.005, yy, zz + 0.016),
                (vx - 0.005, yy, zz + 0.016),
            ])
        add_lofted_strip(bm_dark, v_stations, closed_profile=True)

    # 2. Lower Bumper Side Intakes + Fog Lights (L & R, wrapping continuously around bumper corner x=0.40..0.72)
    for side in (1.0, -1.0):
        ix_list = [0.40, 0.51, 0.62, 0.72]
        in_stations = []
        for xx in ix_list:
            sx = side * xx
            yb = 0.25 + 0.010 * ((xx - 0.40) / 0.32)
            yt = 0.37 - 0.008 * ((xx - 0.40) / 0.32)
            zb = z_nose_surf(sx, yb)
            zt = z_nose_surf(sx, yt)
            r_in = [
                (sx, yb, zb - 0.055),
                (sx, yb, zb + 0.022),
                (sx, yt, zt + 0.022),
                (sx, yt, zt - 0.055),
            ]
            if side < 0:
                r_in = list(reversed(r_in))
            in_stations.append(r_in)
        add_lofted_strip(bm_dark, in_stations, closed_profile=True)

        ym = 0.31
        bar_stations = []
        for xx in ix_list:
            sx = side * xx
            zz = z_nose_surf(sx, ym)
            r_b = [
                (sx, ym - 0.007, zz - 0.030),
                (sx, ym - 0.006, zz + 0.028),
                (sx, ym + 0.006, zz + 0.028),
                (sx, ym + 0.007, zz - 0.030),
            ]
            if side < 0:
                r_b = list(reversed(r_b))
            bar_stations.append(r_b)
        add_lofted_strip(bm_chrome, bar_stations, closed_profile=True)

        fx = side * 0.64
        fy = 0.31
        fz = z_nose_surf(fx, fy) + 0.024
        n_fog = 16
        fog_ring_o, fog_ring_i, fog_lens = [], [], []
        for k in range(n_fog):
            ang = 2.0 * math.pi * k / n_fog
            ca, sa = math.cos(ang), math.sin(ang)
            fog_ring_o.append(bm_chrome.verts.new(co(fx + 0.038 * ca, fy + 0.038 * sa, fz + 0.006)))
            fog_ring_i.append(bm_chrome.verts.new(co(fx + 0.026 * ca, fy + 0.026 * sa, fz + 0.001)))
            fog_lens.append(bm_lamps_head.verts.new(co(fx + 0.026 * ca, fy + 0.026 * sa, fz + 0.003)))
        for k in range(n_fog):
            kn = (k + 1) % n_fog
            bm_chrome.faces.new((fog_ring_o[k], fog_ring_o[kn], fog_ring_i[kn], fog_ring_i[k]))
        bm_lamps_head.faces.new(fog_lens)

    # 3. Angular Headlight Assemblies (L & R, wrapping continuously from grille edge x=0.41 to fender x=0.77)
    for side in (1.0, -1.0):
        hl_xs = [0.41, 0.53, 0.65, 0.77]
        hl_y_bot = [0.53, 0.525, 0.54, 0.56]
        hl_y_top = [0.625, 0.64, 0.655, 0.665]

        hl_stations = []
        glass_b, glass_t = [], []
        drl_stations = []
        for x, yb, yt in zip(hl_xs, hl_y_bot, hl_y_top):
            sx = side * x
            zb = z_nose_surf(sx, yb)
            zt = z_nose_surf(sx, yt)
            r_hl = [
                (sx, yb, zb - 0.065),
                (sx, yb, zb + 0.022),
                (sx, yt, zt + 0.020),
                (sx, yt, zt - 0.065),
            ]
            r_drl = [
                (sx, yb + 0.005, zb - 0.020),
                (sx, yb + 0.005, zb + 0.026),
                (sx, yb + 0.019, zb + 0.026),
                (sx, yb + 0.019, zb - 0.020),
            ]
            if side < 0:
                r_hl = list(reversed(r_hl))
                r_drl = list(reversed(r_drl))
            hl_stations.append(r_hl)
            drl_stations.append(r_drl)
            glass_b.append(bm_glass.verts.new(co(sx, yb, zb + 0.028)))
            glass_t.append(bm_glass.verts.new(co(sx, yt, zt + 0.026)))

        add_lofted_strip(bm_dark, hl_stations, closed_profile=True)
        add_lofted_strip(bm_lamps_head, drl_stations, closed_profile=True)

        for i in range(len(hl_xs) - 1):
            if side > 0:
                bm_glass.faces.new((glass_b[i], glass_b[i+1], glass_t[i+1], glass_t[i]))
            else:
                bm_glass.faces.new((glass_b[i+1], glass_b[i], glass_t[i], glass_t[i+1]))

        for px, py, pr, target_bm in ((0.49, 0.585, 0.030, bm_lamps_head), (0.60, 0.595, 0.028, bm_lamps_head), (0.71, 0.608, 0.022, bm_lamps_amber)):
            sx = side * px
            pz = z_nose_surf(sx, py) + 0.024
            ring_p, ring_c = [], []
            for k in range(14):
                ang = 2.0 * math.pi * k / 14
                ca, sa = math.cos(ang), math.sin(ang)
                ring_p.append(target_bm.verts.new(co(sx + pr * ca, py + pr * sa, pz + 0.003)))
                ring_c.append(bm_chrome.verts.new(co(sx + (pr + 0.006) * ca, py + (pr + 0.006) * sa, pz)))
            target_bm.faces.new(ring_p if side > 0 else list(reversed(ring_p)))
            bm_chrome.faces.new(ring_c if side > 0 else list(reversed(ring_c)))

    # 4. Wrap-Around Taillights (L & R, flush on rear surface around z = -2.33..-2.18)
    def z_tail_surf(x, y):
        ax = abs(x)
        if ax <= 0.63:
            z_base = -2.32
        else:
            t = min(1.0, (ax - 0.63) / (0.80 - 0.63))
            z_base = -2.32 * (1.0 - t) + (-2.18) * t
        return z_base - 0.020 * (1.0 - (ax / 0.80) ** 2)

    for side in (1.0, -1.0):
        tl_xs = [0.32, 0.48, 0.63, 0.78]
        tl_yb = [0.76, 0.75, 0.74, 0.75]
        tl_yt = [0.85, 0.87, 0.89, 0.89]
        tail_stations = []
        amber_stations = []
        for x, yb, yt in zip(tl_xs, tl_yb, tl_yt):
            sx = side * x
            ym = yb + 0.30 * (yt - yb)
            z_s = z_tail_surf(sx, yb)
            r_amb = [
                (sx, yb, z_s + 0.045),
                (sx, yb, z_s - 0.012),
                (sx, ym, z_s - 0.012),
                (sx, ym, z_s + 0.045),
            ]
            r_red = [
                (sx, ym, z_s + 0.045),
                (sx, ym, z_s - 0.014),
                (sx, yt, z_s - 0.012),
                (sx, yt, z_s + 0.045),
            ]
            if side < 0:
                r_amb = list(reversed(r_amb))
                r_red = list(reversed(r_red))
            amber_stations.append(r_amb)
            tail_stations.append(r_red)
        add_lofted_strip(bm_lamps_amber, amber_stations, closed_profile=True)
        add_lofted_strip(bm_lamps_tail, tail_stations, closed_profile=True)

    lp_stations = []
    for xx in (-0.26, 0.0, 0.26):
        zz = z_tail_surf(xx, 0.72)
        lp_stations.append([
            (xx, 0.66, zz + 0.025),
            (xx, 0.66, zz - 0.008),
            (xx, 0.77, zz - 0.008),
            (xx, 0.77, zz + 0.025),
        ])
    add_lofted_strip(bm_dark, lp_stations, closed_profile=True)

    # 5. Rear Bumper Lower Diffuser Valance + Dual Chrome Exhaust Pipes
    diff_stations = []
    for xx in (-0.58, -0.44, -0.20, 0.0, 0.20, 0.44, 0.58):
        zz = z_tail_surf(xx, 0.28)
        diff_stations.append([
            (xx, 0.24, zz + 0.035),
            (xx, 0.24, zz - 0.008),
            (xx, 0.33, zz - 0.010),
            (xx, 0.33, zz + 0.035),
        ])
    add_lofted_strip(bm_dark, diff_stations, closed_profile=True)

    for side in (1.0, -1.0):
        ex = side * 0.46
        ey = 0.27
        ez_front = -2.20
        ez_back  = -2.345
        n_ex = 16
        ring_f, ring_b, ring_in = [], [], []
        for k in range(n_ex):
            ang = 2.0 * math.pi * k / n_ex
            ca, sa = math.cos(ang), math.sin(ang)
            ring_f.append(bm_chrome.verts.new(co(ex + 0.040 * ca, ey + 0.040 * sa, ez_front)))
            ring_b.append(bm_chrome.verts.new(co(ex + 0.042 * ca, ey + 0.042 * sa, ez_back)))
            ring_in.append(bm_dark.verts.new(co(ex + 0.032 * ca, ey + 0.032 * sa, ez_back + 0.005)))
        for k in range(n_ex):
            kn = (k + 1) % n_ex
            bm_chrome.faces.new((ring_f[k], ring_f[kn], ring_b[kn], ring_b[k]))
        bm_dark.faces.new(list(reversed(ring_in)))

    # 6. Sculpted Aerodynamic Door Mirrors (tucked in cleanly so total width is 1.84m-1.88m)
    for side in (1.0, -1.0):
        ped_stations = [
            [(side*0.79, 0.91, 0.67), (side*0.79, 0.94, 0.67), (side*0.79, 0.94, 0.59), (side*0.79, 0.91, 0.59)],
            [(side*0.84, 0.94, 0.63), (side*0.84, 0.96, 0.63), (side*0.84, 0.96, 0.57), (side*0.84, 0.94, 0.57)],
        ]
        if side < 0:
            ped_stations = [list(reversed(r)) for r in ped_stations]
        add_lofted_strip(bm_dark, ped_stations, closed_profile=True)

        m_stations = []
        for u, w_y, z_f, z_b in (
            (0.00, 0.034, 0.630, 0.550),
            (0.30, 0.048, 0.642, 0.545),
            (0.75, 0.045, 0.625, 0.545),
            (1.00, 0.026, 0.595, 0.550),
        ):
            xx = side * (0.82 + u * 0.105)
            yc = 0.985 + 0.012 * u
            zm = 0.5 * (z_f + z_b)
            r_m = [
                (xx, yc - w_y,       z_b),
                (xx, yc - w_y,       zm),
                (xx, yc - w_y * 0.6, z_f),
                (xx, yc + w_y * 0.6, z_f),
                (xx, yc + w_y,       zm),
                (xx, yc + w_y,       z_b),
            ]
            if side < 0:
                r_m = list(reversed(r_m))
            m_stations.append(r_m)
        add_lofted_strip(bm_paint, m_stations, closed_profile=True)

        mg_pts = [
            (side * 0.83, 0.955, 0.543),
            (side * 0.92, 0.965, 0.543),
            (side * 0.92, 1.020, 0.543),
            (side * 0.83, 1.020, 0.543),
        ]
        if side > 0:
            mg_pts = list(reversed(mg_pts))
        bm_chrome.faces.new([bm_chrome.verts.new(co(*p)) for p in mg_pts])

    # 7. Four Pull-Style Door Handles
    for side in (1.0, -1.0):
        for z_h in (0.02, -0.76):
            _, _, y_shld, _, _, _, hw_mid, hw_belt, _ = interp_station(z_h)
            xh = side * (hw_mid * 0.992)
            yh = y_shld - 0.032

            cup_stations = []
            for dz in (-0.065, 0.0, 0.065):
                r_c = [
                    (xh - side * 0.008, yh - 0.022, z_h + dz),
                    (xh + side * 0.008, yh - 0.022, z_h + dz),
                    (xh + side * 0.008, yh + 0.022, z_h + dz),
                    (xh - side * 0.008, yh + 0.022, z_h + dz),
                ]
                if side < 0:
                    r_c = list(reversed(r_c))
                cup_stations.append(r_c)
            add_lofted_strip(bm_dark, cup_stations, closed_profile=True)

            h_stations = []
            hc_stations = []
            for s, dz, dx_in, dx_out in (
                (0.0,  0.095, -0.005, 0.014),
                (0.2,  0.065,  0.010, 0.030),
                (0.8, -0.065,  0.010, 0.030),
                (1.0, -0.095, -0.005, 0.014),
            ):
                xx_in = xh + side * dx_in
                xx_out = xh + side * dx_out
                r_h = [
                    (xx_in,  yh - 0.013, z_h + dz),
                    (xx_out, yh - 0.011, z_h + dz),
                    (xx_out, yh + 0.011, z_h + dz),
                    (xx_in,  yh + 0.013, z_h + dz),
                ]
                r_hc = [
                    (xx_out,              yh - 0.005, z_h + dz),
                    (xx_out + side*0.004, yh - 0.005, z_h + dz),
                    (xx_out + side*0.004, yh + 0.005, z_h + dz),
                    (xx_out,              yh + 0.005, z_h + dz),
                ]
                if side < 0:
                    r_h = list(reversed(r_h))
                    r_hc = list(reversed(r_hc))
                h_stations.append(r_h)
                hc_stations.append(r_hc)
            add_lofted_strip(bm_paint, h_stations, closed_profile=True)
            add_lofted_strip(bm_chrome, hc_stations, closed_profile=True)

    obj_p = new_bm_object("Body_ExtPaintDetails", mats["Paint"])
    finalize_bm(obj_p, bm_paint, uv_mode="paint", smooth=True, bevel_width=0.003, bevel_segs=2, subsurf=1)

    obj_c = new_bm_object("Body_ExtChromeDetails", mats["Trim"])
    finalize_bm(obj_c, bm_chrome, uv_mode="box", v_range=(0.55, 0.95), smooth=True, bevel_width=0.002, bevel_segs=1)

    obj_d = new_bm_object("Body_ExtDarkDetails", mats["Trim"])
    finalize_bm(obj_d, bm_dark, uv_mode="box", v_range=(0.05, 0.45), smooth=True, bevel_width=0.002, bevel_segs=1)

    obj_lh = new_bm_object("Body_LampsHead", mats["Lamps"])
    finalize_bm(obj_lh, bm_lamps_head, uv_mode="box", v_range=(0.78, 0.98), smooth=True)

    obj_lt = new_bm_object("Body_LampsTail", mats["Lamps"])
    finalize_bm(obj_lt, bm_lamps_tail, uv_mode="box", v_range=(0.53, 0.72), smooth=True)

    obj_la = new_bm_object("Body_LampsAmber", mats["Lamps"])
    finalize_bm(obj_la, bm_lamps_amber, uv_mode="box", v_range=(0.28, 0.47), smooth=True)

    obj_gl = new_bm_object("Body_HeadlightGlass", mats["Glass"])
    finalize_bm(obj_gl, bm_glass, uv_mode="box", smooth=True)

    return [obj_p, obj_c, obj_d, obj_lh, obj_lt, obj_la, obj_gl]

# ---------------------------------------------------------------------------
# 6. Believable 3D Interior Cabin + Separate SteeringWheel Node
# ---------------------------------------------------------------------------
def build_interior_and_steering(mats):
    bm_int = bmesh.new()
    bm_trim = bmesh.new()

    def add_box_to_bm(bm, x0, x1, y0, y1, z0, z1):
        vs = [
            bm.verts.new(co(x0, y0, z0)),
            bm.verts.new(co(x1, y0, z0)),
            bm.verts.new(co(x1, y1, z0)),
            bm.verts.new(co(x0, y1, z0)),
            bm.verts.new(co(x0, y0, z1)),
            bm.verts.new(co(x1, y0, z1)),
            bm.verts.new(co(x1, y1, z1)),
            bm.verts.new(co(x0, y1, z1)),
        ]
        bm.faces.new((vs[0], vs[1], vs[2], vs[3]))
        bm.faces.new((vs[7], vs[6], vs[5], vs[4]))
        bm.faces.new((vs[0], vs[4], vs[5], vs[1]))
        bm.faces.new((vs[1], vs[5], vs[6], vs[2]))
        bm.faces.new((vs[2], vs[6], vs[7], vs[3]))
        bm.faces.new((vs[3], vs[7], vs[4], vs[0]))

    dash_stations = [
        [(-0.72, 0.58, 0.52), (-0.72, 0.89, 0.55), (-0.70, 0.91, 0.88), (-0.70, 0.58, 0.88)],
        [(-0.20, 0.58, 0.50), (-0.20, 0.90, 0.53), (-0.20, 0.92, 0.88), (-0.20, 0.58, 0.88)],
        [( 0.20, 0.58, 0.50), ( 0.20, 0.90, 0.53), ( 0.20, 0.92, 0.88), ( 0.20, 0.58, 0.88)],
        [( 0.55, 0.58, 0.50), ( 0.55, 0.95, 0.53), ( 0.55, 0.92, 0.88), ( 0.55, 0.58, 0.88)],
        [( 0.72, 0.58, 0.52), ( 0.72, 0.89, 0.55), ( 0.70, 0.91, 0.88), ( 0.70, 0.58, 0.88)],
    ]
    add_lofted_strip(bm_int, dash_stations, closed_profile=True)

    add_box_to_bm(bm_trim, -0.14, 0.14, 0.80, 0.93, 0.49, 0.53)
    add_box_to_bm(bm_int, -0.13, 0.13, 0.26, 0.64, -0.35, 0.55)
    add_box_to_bm(bm_trim, -0.025, 0.025, 0.64, 0.75, 0.21, 0.27)

    for sx in (0.38, -0.38):
        add_box_to_bm(bm_int, sx - 0.23, sx + 0.23, 0.32, 0.47, -0.12, 0.32)
        add_box_to_bm(bm_int, sx - 0.24, sx - 0.16, 0.44, 0.51, -0.10, 0.28)
        add_box_to_bm(bm_int, sx + 0.16, sx + 0.24, 0.44, 0.51, -0.10, 0.28)

        back_stations = [
            [(sx - 0.22, 0.45, -0.02), (sx + 0.22, 0.45, -0.02), (sx + 0.22, 0.45, -0.14), (sx - 0.22, 0.45, -0.14)],
            [(sx - 0.23, 0.74, -0.09), (sx + 0.23, 0.74, -0.09), (sx + 0.23, 0.74, -0.21), (sx - 0.23, 0.74, -0.21)],
            [(sx - 0.19, 1.01, -0.16), (sx + 0.19, 1.01, -0.16), (sx + 0.19, 1.01, -0.26), (sx - 0.19, 1.01, -0.26)],
        ]
        add_lofted_strip(bm_int, back_stations, closed_profile=True)

        add_box_to_bm(bm_int, sx - 0.12, sx + 0.12, 1.03, 1.21, -0.26, -0.16)
        add_box_to_bm(bm_trim, sx - 0.06, sx - 0.045, 0.99, 1.05, -0.22, -0.20)
        add_box_to_bm(bm_trim, sx + 0.045, sx + 0.06, 0.99, 1.05, -0.22, -0.20)

    add_box_to_bm(bm_int, -0.68, 0.68, 0.34, 0.49, -1.18, -0.65)
    rear_back = [
        [(-0.68, 0.47, -1.06), (0.68, 0.47, -1.06), (0.68, 0.47, -1.20), (-0.68, 0.47, -1.20)],
        [(-0.66, 0.98, -1.22), (0.66, 0.98, -1.22), (0.66, 0.98, -1.35), (-0.66, 0.98, -1.35)],
    ]
    add_lofted_strip(bm_int, rear_back, closed_profile=True)
    for rx in (-0.42, 0.0, 0.42):
        add_box_to_bm(bm_int, rx - 0.11, rx + 0.11, 0.99, 1.13, -1.33, -1.22)
    add_box_to_bm(bm_int, -0.68, 0.68, 0.92, 0.98, -1.65, -1.28)

    for side in (1.0, -1.0):
        x0, x1 = min(side * 0.71, side * 0.77), max(side * 0.71, side * 0.77)
        add_box_to_bm(bm_int, x0, x1, 0.32, 0.90, -1.15, 0.68)
        ax0, ax1 = min(side * 0.66, side * 0.72), max(side * 0.66, side * 0.72)
        add_box_to_bm(bm_int, ax0, ax1, 0.66, 0.72, -0.95, 0.45)

    add_box_to_bm(bm_trim, -0.11, 0.11, 1.28, 1.33, 0.30, 0.34)
    add_box_to_bm(bm_trim, -0.012, 0.012, 1.33, 1.41, 0.32, 0.35)

    obj_int = new_bm_object("Body_Interior", mats["Interior"])
    finalize_bm(obj_int, bm_int, uv_mode="box", smooth=True, bevel_width=0.008, bevel_segs=2, subsurf=1)

    obj_int_trim = new_bm_object("Body_InteriorTrim", mats["Trim"])
    finalize_bm(obj_int_trim, bm_trim, uv_mode="box", v_range=(0.55, 0.95), smooth=True, bevel_width=0.003, bevel_segs=1)

    bm_sw = bmesh.new()
    tilt = math.radians(24.0)
    ct, st = math.cos(tilt), math.sin(tilt)

    def sw_local(lx, ly_up, lz_fwd):
        ry = ly_up * ct - lz_fwd * st
        rz = ly_up * st + lz_fwd * ct
        return Vector((lx, -rz, ry))

    R_rim = 0.185
    r_tube = 0.018
    n_u, n_v = 28, 10
    rim_rings = []
    for i in range(n_u):
        u_ang = 2.0 * math.pi * i / n_u
        cu, su = math.cos(u_ang), math.sin(u_ang)
        ring = []
        for j in range(n_v):
            v_ang = 2.0 * math.pi * j / n_v
            cv, sv = math.cos(v_ang), math.sin(v_ang)
            px = (R_rim + r_tube * cv) * cu
            py = (R_rim + r_tube * cv) * su
            pz = r_tube * sv
            ring.append(bm_sw.verts.new(sw_local(px, py, pz)))
        rim_rings.append(ring)
    for i in range(n_u):
        inext = (i + 1) % n_u
        for j in range(n_v):
            jnext = (j + 1) % n_v
            bm_sw.faces.new((rim_rings[i][j], rim_rings[inext][j], rim_rings[inext][jnext], rim_rings[i][jnext]))

    boss_rings = []
    for s, rad, z_d in ((0.0, 0.042, 0.14), (0.4, 0.055, 0.02), (0.8, 0.068, -0.01), (1.0, 0.045, -0.028)):
        ring = []
        for k in range(16):
            ang = 2.0 * math.pi * k / 16
            ring.append(bm_sw.verts.new(sw_local(rad * math.cos(ang), rad * math.sin(ang), z_d)))
        boss_rings.append(ring)
    for i in range(len(boss_rings) - 1):
        r0, r1 = boss_rings[i], boss_rings[i+1]
        for k in range(16):
            kn = (k + 1) % 16
            bm_sw.faces.new((r0[k], r0[kn], r1[kn], r1[k]))
    bm_sw.faces.new(list(reversed(boss_rings[0])))
    bm_sw.faces.new(boss_rings[-1])

    for sp_ang in (0.0, math.pi, 1.5 * math.pi):
        ca, sa = math.cos(sp_ang), math.sin(sp_ang)
        na_x, na_y = -sa, ca
        sp_stations = []
        for r_dist in (0.055, 0.175):
            ring = []
            for (w_off, z_off) in ((-0.018, -0.010), (0.018, -0.010), (0.016, 0.010), (-0.016, 0.010)):
                lx = r_dist * ca + w_off * na_x
                ly = r_dist * sa + w_off * na_y
                ring.append(bm_sw.verts.new(sw_local(lx, ly, z_off)))
            sp_stations.append(ring)
        for k in range(4):
            kn = (k + 1) % 4
            bm_sw.faces.new((sp_stations[0][k], sp_stations[0][kn], sp_stations[1][kn], sp_stations[1][k]))

    obj_sw = new_bm_object("SteeringWheel", mats["Interior"])
    finalize_bm(obj_sw, bm_sw, uv_mode="box", smooth=True, bevel_width=0.002, bevel_segs=1)
    obj_sw.location = co(0.38, 0.84, 0.38)

    return [obj_int, obj_int_trim], obj_sw

# ---------------------------------------------------------------------------
# 7. High-Detail Wheel Assemblies (Wheel_FL, Wheel_FR, Wheel_RL, Wheel_RR)
# ---------------------------------------------------------------------------
def build_single_wheel(name, center_game, is_left, mats):
    mesh = bpy.data.meshes.new(name)
    obj = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(obj)
    obj.data.materials.append(mats["Rubber"])
    obj.data.materials.append(mats["Trim"])

    bm = bmesh.new()
    s = 1.0 if is_left else -1.0

    def wco(lx_out, ly_rad, lz_rad):
        return Vector((s * lx_out, -lz_rad, ly_rad))

    # 1. Low-profile Tyre: outer radius = 0.340m, rim radius = 0.246m, half-width = 0.115m
    tyre_profile = [
        (-0.105, 0.246),
        (-0.114, 0.274),
        (-0.118, 0.308),
        (-0.108, 0.332),
        (-0.088, 0.340),
        (-0.056, 0.340),
        (-0.052, 0.333),
        (-0.044, 0.333),
        (-0.040, 0.340),
        (-0.008, 0.340),
        (-0.004, 0.333),
        ( 0.004, 0.333),
        ( 0.008, 0.340),
        ( 0.040, 0.340),
        ( 0.044, 0.333),
        ( 0.052, 0.333),
        ( 0.056, 0.340),
        ( 0.088, 0.340),
        ( 0.108, 0.332),
        ( 0.118, 0.308),
        ( 0.114, 0.274),
        ( 0.105, 0.246),
    ]
    n_circ = 56
    tyre_rings = []
    for i in range(n_circ):
        ang = 2.0 * math.pi * i / n_circ
        ca, sa = math.cos(ang), math.sin(ang)
        lug = 0.0012 * (1 if (i % 2 == 0) else -1)
        ring = []
        for idx_p, (px, pr) in enumerate(tyre_profile):
            r_mod = pr + (lug if idx_p in (3, 4, 17, 18) else 0.0)
            ring.append(bm.verts.new(wco(px, r_mod * sa, r_mod * ca)))
        tyre_rings.append(ring)

    n_tp = len(tyre_profile)
    for i in range(n_circ):
        inext = (i + 1) % n_circ
        for j in range(n_tp - 1):
            v0, v1, v2, v3 = tyre_rings[i][j], tyre_rings[i][j+1], tyre_rings[inext][j+1], tyre_rings[inext][j]
            f = bm.faces.new((v0, v1, v2, v3) if s > 0 else (v0, v3, v2, v1))
            f.material_index = 0
            f.smooth = True

    # 2. Alloy Rim Barrel & Outer Lip Flange (mat_index = 1, bright alloy UV except dark inner barrel)
    rim_profile = [
        (-0.105, 0.246),
        (-0.100, 0.230),
        (-0.085, 0.220),
        ( 0.065, 0.220),
        ( 0.095, 0.236),
        ( 0.108, 0.248),
        ( 0.102, 0.234),
    ]
    rim_rings = []
    for i in range(n_circ):
        ang = 2.0 * math.pi * i / n_circ
        ca, sa = math.cos(ang), math.sin(ang)
        ring = [bm.verts.new(wco(px, pr * sa, pr * ca)) for (px, pr) in rim_profile]
        rim_rings.append(ring)
    for i in range(n_circ):
        inext = (i + 1) % n_circ
        for j in range(len(rim_profile) - 1):
            v0, v1, v2, v3 = rim_rings[i][j], rim_rings[i][j+1], rim_rings[inext][j+1], rim_rings[inext][j]
            f = bm.faces.new((v0, v1, v2, v3) if s > 0 else (v0, v3, v2, v1))
            f.material_index = 1
            f.smooth = True
            # Tag inner barrel (j == 2) by setting its smooth/normal or we map its UV in the UV loop below!

    # 3. Center Hub Dish + Recessed Center Cap
    hub_profile = [
        (0.065, 0.082),
        (0.088, 0.072),
        (0.082, 0.036),
        (0.076, 0.030),
        (0.078, 0.000),
    ]
    n_hub = 30
    hub_rings = []
    for i in range(n_hub):
        ang = 2.0 * math.pi * i / n_hub
        ca, sa = math.cos(ang), math.sin(ang)
        ring = [bm.verts.new(wco(px, pr * sa, pr * ca)) for (px, pr) in hub_profile]
        hub_rings.append(ring)
    for i in range(n_hub):
        inext = (i + 1) % n_hub
        for j in range(len(hub_profile) - 1):
            v0, v1, v2, v3 = hub_rings[i][j], hub_rings[i][j+1], hub_rings[inext][j+1], hub_rings[inext][j]
            f = bm.faces.new((v0, v1, v2, v3) if s > 0 else (v0, v3, v2, v1))
            f.material_index = 1
            f.smooth = True

    # 4. Five-Twin-Spoke (10 Sculpted Spokes) matching sheet.jpg
    for pair_idx in range(5):
        base_deg = pair_idx * 72.0
        for sub_sign in (-1.0, 1.0):
            a_hub = math.radians(base_deg + sub_sign * 13.5)
            a_rim = math.radians(base_deg + sub_sign * 12.0)
            sp_stations = []
            for u, r_d, x_front, x_back, w_half in (
                (0.0,  0.068, 0.086, 0.056, 0.015),
                (0.35, 0.125, 0.095, 0.062, 0.014),
                (0.75, 0.188, 0.101, 0.068, 0.013),
                (1.0,  0.238, 0.104, 0.072, 0.015),
            ):
                ang = a_hub * (1.0 - u) + a_rim * u
                ca, sa = math.cos(ang), math.sin(ang)
                tx, ty = -sa, ca
                cy, cz = r_d * sa, r_d * ca
                ring_pts = [
                    wco(x_front,         cy - w_half * 0.65 * tx, cz - w_half * 0.65 * ty),
                    wco(x_front - 0.005, cy - w_half * tx,        cz - w_half * ty),
                    wco(x_back,          cy - w_half * 0.90 * tx, cz - w_half * 0.90 * ty),
                    wco(x_back,          cy + w_half * 0.90 * tx, cz + w_half * 0.90 * ty),
                    wco(x_front - 0.005, cy + w_half * tx,        cz + w_half * ty),
                    wco(x_front,         cy + w_half * 0.65 * tx, cz + w_half * 0.65 * ty),
                ]
                if s < 0:
                    ring_pts = list(reversed(ring_pts))
                sp_stations.append([bm.verts.new(p) for p in ring_pts])

            for st_i in range(len(sp_stations) - 1):
                r0, r1 = sp_stations[st_i], sp_stations[st_i + 1]
                for k in range(6):
                    kn = (k + 1) % 6
                    f = bm.faces.new((r0[k], r0[kn], r1[kn], r1[k]))
                    f.material_index = 1
                    f.smooth = True

        b_ang = math.radians(base_deg)
        by, bz = 0.052 * math.sin(b_ang), 0.052 * math.cos(b_ang)
        bolt_f, bolt_b = [], []
        for k in range(6):
            ka = 2.0 * math.pi * k / 6
            bolt_f.append(bm.verts.new(wco(0.088, by + 0.008 * math.sin(ka), bz + 0.008 * math.cos(ka))))
            bolt_b.append(bm.verts.new(wco(0.072, by + 0.008 * math.sin(ka), bz + 0.008 * math.cos(ka))))
        for k in range(6):
            kn = (k + 1) % 6
            f = bm.faces.new((bolt_f[k], bolt_f[kn], bolt_b[kn], bolt_b[k]) if s < 0 else (bolt_b[k], bolt_b[kn], bolt_f[kn], bolt_f[k]))
            f.material_index = 1
        f_cap = bm.faces.new(bolt_f if s > 0 else list(reversed(bolt_f)))
        f_cap.material_index = 1

    # 5. Internal Brake Disc + Brake Caliper (dark gunmetal/steel behind bright spokes)
    disc_profile = [
        (0.015, 0.065),
        (0.015, 0.180),
        (0.038, 0.180),
        (0.038, 0.065),
    ]
    n_disc = 32
    disc_rings = []
    for i in range(n_disc):
        ang = 2.0 * math.pi * i / n_disc
        ca, sa = math.cos(ang), math.sin(ang)
        disc_rings.append([bm.verts.new(wco(px, pr * sa, pr * ca)) for (px, pr) in disc_profile])
    for i in range(n_disc):
        inext = (i + 1) % n_disc
        for j in range(len(disc_profile) - 1):
            v0, v1, v2, v3 = disc_rings[i][j], disc_rings[i][j+1], disc_rings[inext][j+1], disc_rings[inext][j]
            f = bm.faces.new((v0, v1, v2, v3) if s > 0 else (v0, v3, v2, v1))
            f.material_index = 1
            f.smooth = True

    cal_stations = []
    for i in range(6):
        ang = math.radians(-25.0 + 10.0 * i)
        ca, sa = math.cos(ang), math.sin(ang)
        ring_pts = [
            wco(0.005, 0.122 * sa, 0.122 * ca),
            wco(0.005, 0.198 * sa, 0.198 * ca),
            wco(0.054, 0.198 * sa, 0.198 * ca),
            wco(0.054, 0.122 * sa, 0.122 * ca),
        ]
        if s < 0:
            ring_pts = list(reversed(ring_pts))
        cal_stations.append([bm.verts.new(p) for p in ring_pts])
    for i in range(len(cal_stations) - 1):
        r0, r1 = cal_stations[i], cal_stations[i+1]
        for k in range(4):
            kn = (k + 1) % 4
            f = bm.faces.new((r0[k], r0[kn], r1[kn], r1[k]))
            f.material_index = 1
            f.smooth = True
    bm.faces.new(list(reversed(cal_stations[0]))).material_index = 1
    bm.faces.new(cal_stations[-1]).material_index = 1

    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    uv_layer = bm.loops.layers.uv.verify()
    for f in bm.faces:
        if f.material_index == 0:
            vmin, vmax = (0.05, 0.95)
        else:
            # Check average outward X of face: if deeper inside wheel (|x| < 0.058), put in dark gunmetal UV band (0.05..0.42)
            # so the inner barrel & caliper contrast sharply against the bright silver spokes (0.58..0.95)!
            avg_x_out = sum(s * loop.vert.co.x for loop in f.loops) / len(f.loops)
            if avg_x_out < 0.058:
                vmin, vmax = (0.05, 0.42)
            else:
                vmin, vmax = (0.58, 0.95)
        for loop in f.loops:
            v = loop.vert.co
            u = (v.y * 1.4 + 0.5) % 1.0
            vt = (v.z * 1.4 + 0.5) % 1.0
            loop[uv_layer].uv = (u, vmin + vt * (vmax - vmin))

    bm.to_mesh(obj.data)
    bm.free()

    bev = obj.modifiers.new(name="Bevel", type='BEVEL')
    bev.width = 0.0025
    bev.segments = 1
    bev.limit_method = 'ANGLE'
    bev.angle_limit = math.radians(35.0)

    obj.location = co(*center_game)
    return obj

# ---------------------------------------------------------------------------
# 8. Scene Assembly, Modifier Application, GLB Export & Post-Patch, Renders
# ---------------------------------------------------------------------------
PAINTS_LIST = [
    [0.02, 0.05, 0.16],  # 1. Default Dark Metallic Blue (contract default)
    [0.36, 0.38, 0.41],  # 2. Quartz Metallic Grey (reference sheet finish)
    [0.02, 0.02, 0.025], # 3. Obsidian Phantom Black
    [0.42, 0.02, 0.03],  # 4. Crimson Garnet Metallic Red
    [0.72, 0.74, 0.77],  # 5. Ice Silver Metallic
    [0.02, 0.14, 0.07],  # 6. British Racing Emerald Green
]

def apply_all_modifiers_and_join_body(body_parts, wheel_objs, sw_obj):
    bpy.ops.object.select_all(action='DESELECT')
    for o in body_parts + wheel_objs + [sw_obj]:
        bpy.context.view_layer.objects.active = o
        o.select_set(True)
        for mod in list(o.modifiers):
            bpy.ops.object.modifier_apply(modifier=mod.name)
        o.select_set(False)

    bpy.ops.object.select_all(action='DESELECT')
    for o in body_parts:
        o.select_set(True)
    bpy.context.view_layer.objects.active = body_parts[0]
    bpy.ops.object.join()
    body_obj = bpy.context.active_object
    body_obj.name = "Body"
    body_obj.data.name = "Body"
    bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)

    for o in wheel_objs + [sw_obj]:
        bpy.ops.object.select_all(action='DESELECT')
        o.select_set(True)
        bpy.context.view_layer.objects.active = o
        bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)

    return body_obj

def create_contract_empties():
    empties_spec = {
        "Seat_Driver":  ( 0.38, 0.46,  0.05),
        "Door_L":       ( 0.96, 0.50,  0.15),
        "Door_R":       (-0.96, 0.50,  0.15),
        "Exhaust":      ( 0.48, 0.27, -2.38),
        "Light_Head_L": ( 0.64, 0.60,  2.36),
        "Light_Head_R": (-0.64, 0.60,  2.36),
        "Light_Tail_L": ( 0.60, 0.82, -2.28),
        "Light_Tail_R": (-0.60, 0.82, -2.28),
    }
    empty_objs = []
    for ename, pos in empties_spec.items():
        e = bpy.data.objects.new(ename, None)
        e.empty_display_type = 'PLAIN_AXES'
        e.empty_display_size = 0.15
        e.location = co(*pos)
        bpy.context.collection.objects.link(e)
        empty_objs.append(e)
    return empty_objs

def patch_glb_paint_contract(glb_path):
    with open(glb_path, "rb") as f:
        data = f.read()
    magic, version, length = data[:4], int.from_bytes(data[4:8], 'little'), int.from_bytes(data[8:12], 'little')
    if magic != b'glTF':
        return
    json_len = int.from_bytes(data[12:16], 'little')
    json_type = data[16:20]
    json_bytes = data[20:20 + json_len]
    rest_bytes = data[20 + json_len:]

    gltf = json.loads(json_bytes.decode('utf-8'))
    exts_used = gltf.setdefault("extensionsUsed", [])
    if "KHR_materials_clearcoat" not in exts_used:
        exts_used.append("KHR_materials_clearcoat")

    for mat in gltf.get("materials", []):
        if mat.get("name") == "Paint":
            pbr = mat.setdefault("pbrMetallicRoughness", {})
            pbr["baseColorFactor"] = [0.02, 0.05, 0.16, 1.0]
            exts = mat.setdefault("extensions", {})
            exts["KHR_materials_clearcoat"] = {
                "clearcoatFactor": 1.0,
                "clearcoatRoughnessFactor": 0.05
            }
        elif mat.get("name") == "Glass":
            mat["alphaMode"] = "BLEND"
            pbr = mat.setdefault("pbrMetallicRoughness", {})
            bcf = pbr.get("baseColorFactor", [0.08, 0.12, 0.15, 0.36])
            bcf[3] = 0.36
            pbr["baseColorFactor"] = bcf

    new_json = json.dumps(gltf, separators=(',', ':')).encode('utf-8')
    pad = (4 - (len(new_json) % 4)) % 4
    new_json += b' ' * pad
    new_total_len = 12 + 8 + len(new_json) + len(rest_bytes)

    with open(glb_path, "wb") as f:
        f.write(b'glTF')
        f.write(version.to_bytes(4, 'little'))
        f.write(new_total_len.to_bytes(4, 'little'))
        f.write(len(new_json).to_bytes(4, 'little'))
        f.write(json_type)
        f.write(new_json)
        f.write(rest_bytes)
    print(f"Verified and patched GLB Paint/Clearcoat/Glass contract: {glb_path} ({new_total_len / (1024*1024):.2f} MB)")

def write_nodes_json(all_objs, out_path):
    bpy.context.view_layer.update()
    min_x = min_y = min_z = 1e9
    max_x = max_y = max_z = -1e9

    nodes_dict = {}
    for obj in all_objs:
        loc = obj.matrix_world.translation
        gx, gy, gz = round(loc.x, 4), round(loc.z, 4), round(-loc.y, 4)
        entry = {"position": [gx, gy, gz]}
        if obj.type == 'MESH':
            b_min = [1e9, 1e9, 1e9]
            b_max = [-1e9, -1e9, -1e9]
            for v in obj.data.vertices:
                w = obj.matrix_world @ v.co
                wx, wy, wz = w.x, w.z, -w.y
                b_min[0] = min(b_min[0], wx)
                b_min[1] = min(b_min[1], wy)
                b_min[2] = min(b_min[2], wz)
                b_max[0] = max(b_max[0], wx)
                b_max[1] = max(b_max[1], wy)
                b_max[2] = max(b_max[2], wz)
            min_x, min_y, min_z = min(min_x, b_min[0]), min(min_y, b_min[1]), min(min_z, b_min[2])
            max_x, max_y, max_z = max(max_x, b_max[0]), max(max_y, b_max[1]), max(max_z, b_max[2])
            entry["bbox_min"] = [round(x, 4) for x in b_min]
            entry["bbox_max"] = [round(x, 4) for x in b_max]
        nodes_dict[obj.name] = entry

    total_tris = 0
    for o in all_objs:
        if o.type == 'MESH':
            o.data.calc_loop_triangles()
            total_tris += len(o.data.loop_triangles)

    payload = {
        "asset": "family-sedan-g4",
        "contract": "Forge Garage v2 (Y-up, +Z front, +X left, metres)",
        "triangle_count": total_tris,
        "bbox": {
            "min": [round(min_x, 4), round(min_y, 4), round(min_z, 4)],
            "max": [round(max_x, 4), round(max_y, 4), round(max_z, 4)],
            "dimensions": [
                round(max_x - min_x, 4),
                round(max_y - min_y, 4),
                round(max_z - min_z, 4),
            ],
        },
        "nodes": nodes_dict,
        "paints": PAINTS_LIST,
    }
    with open(out_path, "w") as f:
        json.dump(payload, f, indent=2)
    print(f"Wrote nodes.json: tris={total_tris}, dims={payload['bbox']['dimensions']}")
    return total_tris

# ---------------------------------------------------------------------------
# 9. Studio Daylight Lighting & Rendering (final.png, side.png, clay.png, paints.png)
# ---------------------------------------------------------------------------
def point_camera_at(cam_obj, loc, target):
    cam_obj.location = Vector(loc)
    direction = Vector(target) - Vector(loc)
    rot_quat = direction.to_track_quat('-Z', 'Y')
    cam_obj.rotation_euler = rot_quat.to_euler()

def set_paint_factor(mat_paint, rgb):
    nt = mat_paint.node_tree
    for n in nt.nodes:
        if n.type == 'MIX_RGB':
            n.inputs['Color2'].default_value = (rgb[0], rgb[1], rgb[2], 1.0)
        elif n.type == 'BSDF_PRINCIPLED':
            n.inputs['Base Color'].default_value = (rgb[0], rgb[1], rgb[2], 1.0)

def render_deliverables(mats, mesh_objs):
    setup_cycles_gpu()
    scene = bpy.context.scene
    scene.render.film_transparent = False

    world = bpy.data.worlds.new("StudioDaylight")
    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_sky = wnt.nodes.new('ShaderNodeTexSky')
    w_sky.sky_type = 'HOSEK_WILKIE'
    w_sky.turbidity = 2.4
    w_sky.ground_albedo = 0.35
    w_sky.sun_direction = Vector((0.45, -0.55, 0.70)).normalized()
    w_mix = wnt.nodes.new('ShaderNodeMixRGB')
    w_mix.inputs['Fac'].default_value = 0.26
    w_mix.inputs['Color1'].default_value = (0.78, 0.80, 0.83, 1.0)
    wnt.links.new(w_sky.outputs['Color'], w_mix.inputs['Color2'])
    wnt.links.new(w_mix.outputs['Color'], w_bg.inputs['Color'])
    w_bg.inputs['Strength'].default_value = 1.10
    wnt.links.new(w_bg.outputs['Background'], w_out.inputs['Surface'])

    bpy.ops.mesh.primitive_plane_add(size=60.0, location=(0, 0, 0.0))
    floor = bpy.context.active_object
    floor.name = "_StudioFloor"
    mat_floor = bpy.data.materials.new("_MatFloor")
    mat_floor.use_nodes = True
    bsdf_f = mat_floor.node_tree.nodes.get("Principled BSDF")
    if bsdf_f:
        bsdf_f.inputs['Base Color'].default_value = (0.70, 0.71, 0.73, 1.0)
        bsdf_f.inputs['Roughness'].default_value = 0.55
        bsdf_f.inputs['Metallic'].default_value = 0.0
    floor.data.materials.append(mat_floor)

    lights = []
    def add_area_light(name, loc, target, energy, size_x, size_y, color=(1.0, 0.98, 0.95)):
        ldata = bpy.data.lights.new(name=name, type='AREA')
        ldata.energy = energy
        ldata.shape = 'RECTANGLE'
        ldata.size = size_x
        ldata.size_y = size_y
        ldata.color = color
        lobj = bpy.data.objects.new(name=name, object_data=ldata)
        bpy.context.collection.objects.link(lobj)
        point_camera_at(lobj, loc, target)
        lights.append(lobj)

    add_area_light("_KeyLight",   ( 4.8, -5.4, 4.2), (0, 0, 0.6), 950.0, 6.5, 2.5, (1.0, 0.98, 0.94))
    add_area_light("_FillLight",  (-5.2, -3.8, 2.8), (0, 0, 0.6), 450.0, 5.5, 3.0, (0.92, 0.96, 1.0))
    add_area_light("_RimLight",   (-2.5,  5.5, 4.0), (0, 0, 0.7), 650.0, 6.0, 2.0, (1.0, 0.99, 0.97))
    add_area_light("_TopSoftbox", ( 0.0, -0.2, 5.6), (0, 0, 0.7), 700.0, 2.2, 7.5, (1.0, 1.0, 1.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

    # 1. final.png: 1920x1080 Cycles render, 3/4 front view framed so the full car (grille, headlights, side, wheels, boot) fits with generous margin
    cam_data.type = 'PERSP'
    cam_data.lens = 50.0
    point_camera_at(cam_obj, (4.45, -5.35, 1.55), (-0.05, -0.10, 0.62))

    scene.render.resolution_x = 1920
    scene.render.resolution_y = 1080
    scene.render.resolution_percentage = 100
    scene.cycles.samples = 64

    set_paint_factor(mats["Paint"], PAINTS_LIST[0])
    final_path = os.path.join(SCRIPT_DIR, "final.png")
    scene.render.filepath = final_path
    bpy.ops.render.render(write_still=True)
    print(f"Rendered {final_path}")

    # 2. clay.png: Same 3/4 front view as final.png, plain grey clay material
    mat_clay = bpy.data.materials.new("_ClayGrey")
    mat_clay.use_nodes = True
    bsdf_c = mat_clay.node_tree.nodes.get("Principled BSDF")
    if bsdf_c:
        bsdf_c.inputs['Base Color'].default_value = (0.48, 0.49, 0.50, 1.0)
        bsdf_c.inputs['Roughness'].default_value = 0.45
        bsdf_c.inputs['Metallic'].default_value = 0.0
    scene.view_layers[0].material_override = mat_clay
    clay_path = os.path.join(SCRIPT_DIR, "clay.png")
    scene.render.filepath = clay_path
    bpy.ops.render.render(write_still=True)
    scene.view_layers[0].material_override = None
    print(f"Rendered {clay_path}")

    # 3. side.png: Orthographic side view (front on LEFT, rear on RIGHT)
    cam_data.type = 'ORTHO'
    cam_data.ortho_scale = 5.45
    point_camera_at(cam_obj, (6.0, 0.0, 0.72), (0.0, 0.0, 0.72))
    side_path = os.path.join(SCRIPT_DIR, "side.png")
    scene.render.filepath = side_path
    bpy.ops.render.render(write_still=True)
    print(f"Rendered {side_path}")

    # 4. paints.png: 3x2 grid of the same 3/4 view in all 6 paints
    cam_data.type = 'PERSP'
    cam_data.lens = 50.0
    point_camera_at(cam_obj, (4.45, -5.35, 1.55), (-0.05, -0.10, 0.62))
    scene.render.resolution_x = 640
    scene.render.resolution_y = 360
    scene.cycles.samples = 28

    tile_paths = []
    for idx, rgb in enumerate(PAINTS_LIST):
        set_paint_factor(mats["Paint"], rgb)
        tpath = os.path.join(TEX_DIR, f"_paint_tile_{idx}.png")
        scene.render.filepath = tpath
        bpy.ops.render.render(write_still=True)
        tile_paths.append(tpath)

    set_paint_factor(mats["Paint"], PAINTS_LIST[0])

    grid_w, grid_h = 640 * 3, 360 * 2
    grid_img = bpy.data.images.new("_PaintsGrid", width=grid_w, height=grid_h, alpha=True)
    grid_pixels = [0.0] * (grid_w * grid_h * 4)

    for idx, tpath in enumerate(tile_paths):
        timg = bpy.data.images.load(tpath, check_existing=False)
        tpix = list(timg.pixels)
        col = idx % 3
        row = 1 - (idx // 3)
        x_off = col * 640
        y_off = row * 360
        for ty in range(360):
            src_start = (ty * 640) * 4
            dst_start = ((y_off + ty) * grid_w + x_off) * 4
            grid_pixels[dst_start:dst_start + 640 * 4] = tpix[src_start:src_start + 640 * 4]
        bpy.data.images.remove(timg)

    grid_img.pixels = grid_pixels
    paints_path = os.path.join(SCRIPT_DIR, "paints.png")
    grid_img.filepath_raw = paints_path
    grid_img.file_format = 'PNG'
    grid_img.save()
    bpy.data.images.remove(grid_img)
    print(f"Rendered and stitched 3x2 grid {paints_path}")

def main():
    bpy.ops.wm.read_factory_settings(use_empty=True)

    ensure_polyhaven_textures()
    baked = bake_material_maps()
    mats = create_gltf_materials(baked)

    body_shell = build_painted_body_shell(mats)
    roof_pillars = build_roof_and_pillars(mats)
    arch_liners = build_wheel_arch_liners(mats)
    win_parts = build_windows_and_frames(mats)
    ext_parts = build_front_rear_details(mats)
    int_parts, sw_obj = build_interior_and_steering(mats)

    body_parts = [body_shell, roof_pillars, arch_liners] + win_parts + ext_parts + int_parts

    w_fl = build_single_wheel("Wheel_FL", ( 0.80, 0.34,  1.40), True,  mats)
    w_fr = build_single_wheel("Wheel_FR", (-0.80, 0.34,  1.40), False, mats)
    w_rl = build_single_wheel("Wheel_RL", ( 0.80, 0.34, -1.40), True,  mats)
    w_rr = build_single_wheel("Wheel_RR", (-0.80, 0.34, -1.40), False, mats)
    wheel_objs = [w_fl, w_fr, w_rl, w_rr]

    body_obj = apply_all_modifiers_and_join_body(body_parts, wheel_objs, sw_obj)
    empty_objs = create_contract_empties()

    all_contract_objs = [body_obj] + wheel_objs + [sw_obj] + empty_objs

    nodes_json_path = os.path.join(SCRIPT_DIR, "nodes.json")
    write_nodes_json(all_contract_objs, nodes_json_path)

    glb_path = os.path.join(SCRIPT_DIR, "family-sedan-g4.glb")
    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,
    )
    patch_glb_paint_contract(glb_path)

    render_deliverables(mats, [body_obj] + wheel_objs + [sw_obj])

if __name__ == "__main__":
    main()
