#!/usr/bin/env python3
"""
Forge Asset Library - Realistic 3D Game Asset Builder
Asset: Modern Mid-Size Five-Door SUV Crossover (family-suv-g4)
Contract: Forge Garage v2 (Y-up in glTF, +Z front, +X left, origin on ground midway between axles)
"""

import os
import sys
import math
import json
import struct
import urllib.request
from math import sin, cos, pi, sqrt, atan2

import bpy
import bmesh
from mathutils import Vector, Matrix, Euler

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

# ---------------------------------------------------------------------------
# 1. SCENE & RENDER INITIALIZATION (Cycles GPU OptiX/CUDA)
# ---------------------------------------------------------------------------
bpy.ops.wm.read_factory_settings(use_empty=True)
S = bpy.context.scene
S.unit_settings.system = 'METRIC'
S.unit_settings.scale_length = 1.0

S.render.engine = 'CYCLES'
S.cycles.device = 'GPU'
prefs = bpy.context.preferences.addons['cycles'].preferences
for dev_type in ('OPTIX', 'CUDA'):
    try:
        prefs.compute_device_type = dev_type
        prefs.get_devices()
        if any(d.type == dev_type for d in prefs.devices):
            for d in prefs.devices:
                d.use = True
            break
    except Exception:
        pass

S.cycles.samples = 48
S.cycles.use_denoising = True
S.view_settings.view_transform = 'AgX'
S.view_settings.look = 'AgX - Medium High Contrast'
S.view_settings.exposure = -0.55

world = bpy.data.worlds.new("StudioWorld")
S.world = world
world.use_nodes = True
wnodes = world.node_tree.nodes
wlinks = world.node_tree.links
wnodes.clear()
w_out = wnodes.new("ShaderNodeOutputWorld")
w_bg = wnodes.new("ShaderNodeBackground")
# Subtle dark-neutral environment reflection so metallic paint has rich automotive contrast
w_bg.inputs["Color"].default_value = (0.18, 0.20, 0.23, 1.0)
w_bg.inputs["Strength"].default_value = 0.55
wlinks.new(w_bg.outputs["Background"], w_out.inputs["Surface"])

# Six realistic automotive paints (linear RGB, default silver grey first)
PAINTS = [
    ("Silver grey",   [0.62, 0.63, 0.64]),
    ("Deep navy",     [0.035, 0.075, 0.165]),
    ("Forest green",  [0.045, 0.135, 0.085]),
    ("Carmine red",   [0.26, 0.032, 0.042]),
    ("Pearl white",   [0.86, 0.87, 0.84]),
    ("Anthracite",    [0.085, 0.092, 0.105]),
]

GROUPS = {
    "Body": [],
    "Wheel_FL": [],
    "Wheel_FR": [],
    "Wheel_RL": [],
    "Wheel_RR": [],
    "SteeringWheel": [],
}

def set_active(obj):
    bpy.ops.object.select_all(action='DESELECT')
    obj.select_set(True)
    bpy.context.view_layer.objects.active = obj

# ---------------------------------------------------------------------------
# 2. POLY HAVEN CC0 TEXTURE DOWNLOADER & CYCLES MATERIAL BAKER
# ---------------------------------------------------------------------------
PH_ASSETS = [
    "metal_plate",
    "metal_plate_02",
    "rusty_metal_03",
    "rubberized_track",
    "fabric_leather_02",
]

def load_ph_image(asset_id, map_kind, res="1k"):
    fname = f"{asset_id}_{map_kind}_{res}.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/{res}/{asset_id}/{fname}"
        req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
        with urllib.request.urlopen(req, timeout=90) as resp:
            with open(fpath, "wb") as f:
                f.write(resp.read())
    img = bpy.data.images.load(fpath, check_existing=True)
    if map_kind != "diff":
        img.colorspace_settings.name = 'Non-Color'
    return img

def get_gltf_ao_group():
    ng = bpy.data.node_groups.get("glTF Material Output")
    if not ng:
        ng = bpy.data.node_groups.new("glTF Material Output", "ShaderNodeTree")
        ng.interface.new_socket(name="Occlusion", in_out='INPUT', socket_type='NodeSocketFloat')
    return ng

def bake_pbr_material(
    name,
    base_col,
    metallic,
    roughness,
    ph_main,
    ph_grime="rusty_metal_03",
    col_var=0.10,
    grime_strength=0.22,
    normal_strength=0.12,
    tex_size=512,
    orm_size=512,
    is_paint=False,
    spec_level=0.5,
):
    mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    nodes = mat.node_tree.nodes
    links = mat.node_tree.links
    nodes.clear()

    out = nodes.new("ShaderNodeOutputMaterial")
    bsdf = nodes.new("ShaderNodeBsdfPrincipled")
    links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])

    uv = nodes.new("ShaderNodeTexCoord")
    mapping = nodes.new("ShaderNodeMapping")
    mapping.inputs["Scale"].default_value = (4.0, 4.0, 4.0) if not is_paint else (3.0, 3.0, 3.0)
    links.new(uv.outputs["UV"], mapping.inputs["Vector"])

    tex_main = {}
    for k in ("diff", "nor_gl", "arm"):
        ti = nodes.new("ShaderNodeTexImage")
        ti.image = load_ph_image(ph_main, k)
        links.new(mapping.outputs["Vector"], ti.inputs["Vector"])
        tex_main[k] = ti

    tex_grime = {}
    for k in ("diff", "arm"):
        ti = nodes.new("ShaderNodeTexImage")
        ti.image = load_ph_image(ph_grime, k)
        links.new(mapping.outputs["Vector"], ti.inputs["Vector"])
        tex_grime[k] = ti

    bw_main = nodes.new("ShaderNodeRGBToBW")
    links.new(tex_main["diff"].outputs["Color"], bw_main.inputs["Color"])

    ramp_main = nodes.new("ShaderNodeValToRGB")
    c_low = [max(0.0, c * (1.0 - col_var)) for c in base_col]
    c_hi  = [min(1.0, c * (1.0 + col_var * 0.4)) for c in base_col]
    ramp_main.color_ramp.elements[0].color = (*c_low, 1.0)
    ramp_main.color_ramp.elements[1].color = (*c_hi, 1.0)
    links.new(bw_main.outputs["Val"], ramp_main.inputs["Fac"])

    sep_uv = nodes.new("ShaderNodeSeparateXYZ")
    links.new(uv.outputs["UV"], sep_uv.inputs["Vector"])

    map_v = nodes.new("ShaderNodeMapRange")
    map_v.inputs["From Min"].default_value = 0.02
    map_v.inputs["From Max"].default_value = 0.36
    map_v.inputs["To Min"].default_value = 1.0
    map_v.inputs["To Max"].default_value = 0.0
    links.new(sep_uv.outputs["Y"], map_v.inputs["Value"])

    bw_grime = nodes.new("ShaderNodeRGBToBW")
    links.new(tex_grime["diff"].outputs["Color"], bw_grime.inputs["Color"])

    noise = nodes.new("ShaderNodeTexNoise")
    noise.inputs["Scale"].default_value = 22.0
    noise.inputs["Detail"].default_value = 5.0
    noise.inputs["Roughness"].default_value = 0.65
    links.new(uv.outputs["UV"], noise.inputs["Vector"])

    mul_mask1 = nodes.new("ShaderNodeMath")
    mul_mask1.operation = 'MULTIPLY'
    mul_mask1.use_clamp = True
    links.new(map_v.outputs["Result"], mul_mask1.inputs[0])
    links.new(bw_grime.outputs["Val"], mul_mask1.inputs[1])

    mul_mask2 = nodes.new("ShaderNodeMath")
    mul_mask2.operation = 'MULTIPLY'
    mul_mask2.use_clamp = True
    mul_mask2.inputs[1].default_value = grime_strength
    links.new(mul_mask1.outputs["Value"], mul_mask2.inputs[0])

    dust_col = (0.68, 0.66, 0.63, 1.0) if is_paint else (base_col[0]*1.35, base_col[1]*1.30, base_col[2]*1.22, 1.0)
    mix_col = nodes.new("ShaderNodeMixRGB")
    mix_col.blend_type = 'MIX'
    links.new(mul_mask2.outputs["Value"], mix_col.inputs["Fac"])
    links.new(ramp_main.outputs["Color"], mix_col.inputs["Color1"])
    mix_col.inputs["Color2"].default_value = dust_col

    mix_micro = nodes.new("ShaderNodeMixRGB")
    mix_micro.blend_type = 'MULTIPLY'
    mix_micro.inputs["Fac"].default_value = 0.035 if is_paint else 0.08
    links.new(mix_col.outputs["Color"], mix_micro.inputs["Color1"])
    links.new(noise.outputs["Color"], mix_micro.inputs["Color2"])
    links.new(mix_micro.outputs["Color"], bsdf.inputs["Base Color"])

    nm = nodes.new("ShaderNodeNormalMap")
    nm.inputs["Strength"].default_value = normal_strength
    links.new(tex_main["nor_gl"].outputs["Color"], nm.inputs["Color"])
    links.new(nm.outputs["Normal"], bsdf.inputs["Normal"])

    sep_arm = nodes.new("ShaderNodeSeparateColor")
    links.new(tex_main["arm"].outputs["Color"], sep_arm.inputs["Color"])

    map_r = nodes.new("ShaderNodeMapRange")
    r_spread = 0.04 if is_paint else 0.07
    map_r.inputs["To Min"].default_value = max(0.03, roughness - r_spread)
    map_r.inputs["To Max"].default_value = min(0.98, roughness + r_spread)
    links.new(sep_arm.outputs["Green"], map_r.inputs["Value"])

    add_r = nodes.new("ShaderNodeMath")
    add_r.operation = 'ADD'
    add_r.use_clamp = True
    links.new(map_r.outputs["Result"], add_r.inputs[0])

    mul_r = nodes.new("ShaderNodeMath")
    mul_r.operation = 'MULTIPLY'
    mul_r.inputs[1].default_value = 0.24 if is_paint else 0.12
    links.new(mul_mask2.outputs["Value"], mul_r.inputs[0])
    links.new(mul_r.outputs["Value"], add_r.inputs[1])
    links.new(add_r.outputs["Value"], bsdf.inputs["Roughness"])

    bsdf.inputs["Metallic"].default_value = metallic

    bpy.ops.mesh.primitive_plane_add(size=1.0, location=(0, 0, -10.0))
    plane = bpy.context.object
    plane.name = f"BakePlane_{name}"
    plane.data.materials.append(mat)
    set_active(plane)

    bake_target = nodes.new("ShaderNodeTexImage")
    emit_node = nodes.new("ShaderNodeEmission")

    def run_bake(suffix, sz, emit_socket=None):
        img = bpy.data.images.new(f"{name}_{suffix}", width=sz, height=sz, alpha=False)
        if suffix != "BaseColor":
            img.colorspace_settings.name = 'Non-Color'
        bake_target.image = img
        nodes.active = bake_target
        if suffix == "Normal":
            links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
            btype = 'NORMAL'
        else:
            links.new(emit_socket, emit_node.inputs["Color"])
            links.new(emit_node.outputs["Emission"], out.inputs["Surface"])
            btype = 'EMIT'
        S.cycles.samples = 4
        S.render.bake.margin = 8
        bpy.ops.object.bake(type=btype)
        out_path = os.path.join(TEX_DIR, f"{name}_{suffix}.png")
        img.filepath_raw = out_path
        img.file_format = 'PNG'
        img.save()
        return img

    img_bc = run_bake("BaseColor", tex_size, mix_micro.outputs["Color"])
    img_nm = run_bake("Normal", tex_size)

    comb_orm = nodes.new("ShaderNodeCombineColor")
    map_ao = nodes.new("ShaderNodeMapRange")
    map_ao.inputs["To Min"].default_value = 0.76
    map_ao.inputs["To Max"].default_value = 1.0
    links.new(sep_arm.outputs["Red"], map_ao.inputs["Value"])
    links.new(map_ao.outputs["Result"], comb_orm.inputs["Red"])
    links.new(add_r.outputs["Value"], comb_orm.inputs["Green"])
    comb_orm.inputs["Blue"].default_value = metallic
    img_orm = run_bake("ORM", orm_size, comb_orm.outputs["Color"])

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

    nodes.clear()
    out = nodes.new("ShaderNodeOutputMaterial")
    bsdf = nodes.new("ShaderNodeBsdfPrincipled")
    bsdf.inputs["Specular IOR Level"].default_value = spec_level
    links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])

    n_bc = nodes.new("ShaderNodeTexImage")
    n_bc.image = img_bc
    links.new(n_bc.outputs["Color"], bsdf.inputs["Base Color"])

    n_nm = nodes.new("ShaderNodeTexImage")
    n_nm.image = img_nm
    nm_node = nodes.new("ShaderNodeNormalMap")
    links.new(n_nm.outputs["Color"], nm_node.inputs["Color"])
    links.new(nm_node.outputs["Normal"], bsdf.inputs["Normal"])

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

    ao_grp = nodes.new("ShaderNodeGroup")
    ao_grp.node_tree = get_gltf_ao_group()
    links.new(sep.outputs["Red"], ao_grp.inputs["Occlusion"])

    if is_paint:
        bsdf.inputs["Coat Weight"].default_value = 1.0
        bsdf.inputs["Coat Roughness"].default_value = 0.05

    print(f"[Material Baked] {name} (BC:{tex_size}, NM:{tex_size}, ORM:{orm_size})", flush=True)
    return mat

# Build the 7 materials matching sheet.jpg & Forge Garage v2 contract
Mat_Paint = bake_pbr_material(
    "Paint",
    base_col=(0.92, 0.92, 0.92),
    metallic=0.68,
    roughness=0.32,
    ph_main="metal_plate",
    ph_grime="rusty_metal_03",
    col_var=0.02,
    grime_strength=0.48,
    normal_strength=0.015,
    tex_size=1024,
    orm_size=512,
    is_paint=True,
    spec_level=0.55,
)

Mat_Rubber = bake_pbr_material(
    "Rubber",
    base_col=(0.014, 0.015, 0.016),
    metallic=0.0,
    roughness=0.84,
    ph_main="rubberized_track",
    ph_grime="rusty_metal_03",
    col_var=0.16,
    grime_strength=0.22,
    normal_strength=0.28,
    tex_size=512,
    orm_size=512,
    spec_level=0.18,
)

Mat_Chrome = bake_pbr_material(
    "Chrome",
    base_col=(0.82, 0.84, 0.86),
    metallic=0.94,
    roughness=0.15,
    ph_main="metal_plate_02",
    ph_grime="rusty_metal_03",
    col_var=0.06,
    grime_strength=0.06,
    normal_strength=0.04,
    tex_size=512,
    orm_size=512,
    spec_level=0.70,
)

Mat_Plastic = bake_pbr_material(
    "Plastic",
    base_col=(0.020, 0.022, 0.024),
    metallic=0.02,
    roughness=0.68,
    ph_main="fabric_leather_02",
    ph_grime="rusty_metal_03",
    col_var=0.14,
    grime_strength=0.25,
    normal_strength=0.15,
    tex_size=512,
    orm_size=512,
    spec_level=0.22,
)

Mat_Interior = bake_pbr_material(
    "Interior",
    base_col=(0.030, 0.032, 0.036),
    metallic=0.0,
    roughness=0.60,
    ph_main="fabric_leather_02",
    ph_grime="metal_plate",
    col_var=0.12,
    grime_strength=0.08,
    normal_strength=0.18,
    tex_size=512,
    orm_size=512,
    spec_level=0.20,
)

# Glass material (alpha BLEND, tinted, low roughness)
Mat_Glass = bpy.data.materials.new("Glass")
Mat_Glass.use_nodes = True
gnodes = Mat_Glass.node_tree.nodes
g_bsdf = gnodes["Principled BSDF"]
g_bsdf.inputs["Base Color"].default_value = (0.04, 0.055, 0.065, 1.0)
g_bsdf.inputs["Metallic"].default_value = 0.05
g_bsdf.inputs["Roughness"].default_value = 0.04
g_bsdf.inputs["IOR"].default_value = 1.50
g_bsdf.inputs["Alpha"].default_value = 0.42
g_bsdf.inputs["Specular IOR Level"].default_value = 0.85

# Lamps material with baked 256x256 palette texture
Mat_Lamps = bpy.data.materials.new("Lamps")
Mat_Lamps.use_nodes = True
lnodes = Mat_Lamps.node_tree.nodes
llinks = Mat_Lamps.node_tree.links
l_bsdf = lnodes["Principled BSDF"]
lamp_img = bpy.data.images.new("Lamps_Emissive", width=256, height=256, alpha=False)
px = [0.0] * (256 * 256 * 4)
for y in range(256):
    for x in range(256):
        idx = (y * 256 + x) * 4
        u = x / 255.0
        if u < 0.34:
            c = (0.98, 0.96, 0.90) # Warm white LED headlight / DRL
        elif u < 0.67:
            c = (0.90, 0.04, 0.03) # Ruby red LED taillight / brake light
        else:
            c = (0.96, 0.45, 0.02) # Amber turn indicator
        px[idx:idx+4] = [c[0], c[1], c[2], 1.0]
lamp_img.pixels = px
lamp_img.filepath_raw = os.path.join(TEX_DIR, "Lamps_Emissive.png")
lamp_img.file_format = 'PNG'
lamp_img.save()

t_lamp = lnodes.new("ShaderNodeTexImage")
t_lamp.image = lamp_img
llinks.new(t_lamp.outputs["Color"], l_bsdf.inputs["Base Color"])
llinks.new(t_lamp.outputs["Color"], l_bsdf.inputs["Emission Color"])
l_bsdf.inputs["Emission Strength"].default_value = 1.8
l_bsdf.inputs["Roughness"].default_value = 0.10

# ---------------------------------------------------------------------------
# 3. GEOMETRY HELPER FUNCTIONS
# ---------------------------------------------------------------------------
def assign_uvs(obj, uv_mode="BOX"):
    me = obj.data
    if not me.uv_layers:
        me.uv_layers.new(name="UVMap")
    uv_layer = me.uv_layers.active
    mw = obj.matrix_world
    for poly in me.polygons:
        n = poly.normal
        for li in poly.loop_indices:
            v = mw @ me.vertices[me.loops[li].vertex_index].co
            if uv_mode == "BODY":
                u = (v.y + 2.35) / 4.70
                vv = max(0.02, min(0.98, (v.z - 0.22) / 1.46))
            elif uv_mode == "LAMP_WHITE":
                u, vv = 0.17, 0.5
            elif uv_mode == "LAMP_RED":
                u, vv = 0.50, 0.5
            elif uv_mode == "LAMP_AMBER":
                u, vv = 0.83, 0.5
            else:
                if abs(n.x) >= abs(n.y) and abs(n.x) >= abs(n.z):
                    u, vv = (v.y + 2.35) * 0.5, max(0.02, min(0.98, (v.z - 0.15) / 1.4))
                elif abs(n.y) >= abs(n.x) and abs(n.y) >= abs(n.z):
                    u, vv = (v.x + 1.0) * 0.5, max(0.02, min(0.98, (v.z - 0.15) / 1.4))
                else:
                    u, vv = (v.x + 1.0) * 0.5, (v.y + 2.35) * 0.5
            uv_layer.data[li].uv = (u, vv)

def add_mesh_obj(name, verts, faces, mat, group="Body", smooth=True, uv_mode="BOX"):
    me = bpy.data.meshes.new(name)
    me.from_pydata(verts, [], faces)
    me.update()
    obj = bpy.data.objects.new(name, me)
    bpy.context.collection.objects.link(obj)
    if mat:
        me.materials.append(mat)
    for p in me.polygons:
        p.use_smooth = smooth
    assign_uvs(obj, uv_mode)
    if group is not None:
        GROUPS[group].append(obj)
    return obj

def apply_bevel(obj, width=0.006, segments=2, angle=32.0):
    set_active(obj)
    mod = obj.modifiers.new("Bevel", 'BEVEL')
    mod.width = width
    mod.segments = segments
    mod.limit_method = 'ANGLE'
    mod.angle_limit = math.radians(angle)
    try:
        bpy.ops.object.modifier_apply(modifier=mod.name)
    except Exception:
        pass
    return obj

def add_box(name, loc, dim, mat, bev=0.006, seg=2, group="Body", rot=None, uv_mode="BOX"):
    bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc)
    obj = bpy.context.object
    obj.name = name
    obj.scale = (dim[0], dim[1], dim[2])
    set_active(obj)
    bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
    if rot:
        obj.rotation_euler = rot
        bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)
    obj.data.materials.append(mat)
    if bev > 0:
        apply_bevel(obj, bev, seg)
    for p in obj.data.polygons:
        p.use_smooth = True
    assign_uvs(obj, uv_mode)
    if group is not None:
        GROUPS[group].append(obj)
    return obj

def add_curve_tube(name, pts, radius, mat, group="Body", closed=False, res_u=12, bev_res=4, uv_mode="BOX"):
    cu = bpy.data.curves.new(name, 'CURVE')
    cu.dimensions = '3D'
    cu.resolution_u = res_u
    cu.bevel_depth = radius
    cu.bevel_resolution = bev_res
    use_nurbs = (len(pts) >= 4 and not closed)
    sp = cu.splines.new('NURBS' if use_nurbs else 'POLY')
    sp.points.add(len(pts) - 1)
    for i, co in enumerate(pts):
        sp.points[i].co = (co[0], co[1], co[2], 1.0)
    sp.use_cyclic_u = closed
    if use_nurbs:
        sp.use_endpoint_u = True
        sp.order_u = min(4, len(pts))
    obj = bpy.data.objects.new(name, cu)
    bpy.context.collection.objects.link(obj)
    obj.data.materials.append(mat)
    set_active(obj)
    bpy.ops.object.convert(target='MESH')
    for p in obj.data.polygons:
        p.use_smooth = True
    assign_uvs(obj, uv_mode)
    if group is not None:
        GROUPS[group].append(obj)
    return obj

# ---------------------------------------------------------------------------
# 4. SCULPTED AUTOMOTIVE BODY SHELL WITH TRUE CLOSED FRONT & REAR CAPS
# ---------------------------------------------------------------------------
def eval_catmull_1d(y_val, keyframes):
    if y_val <= keyframes[0][0]:
        return keyframes[0][1]
    if y_val >= keyframes[-1][0]:
        return keyframes[-1][1]
    for i in range(len(keyframes) - 1):
        y0, v0 = keyframes[i]
        y1, v1 = keyframes[i + 1]
        if y0 <= y_val <= y1:
            t = (y_val - y0) / max(1e-6, (y1 - y0))
            p_prev = keyframes[max(0, i - 1)][1]
            p_next = keyframes[min(len(keyframes) - 1, i + 2)][1]
            m0 = 0.5 * (v1 - p_prev)
            m1 = 0.5 * (p_next - v0)
            t2 = t * t
            t3 = t2 * t
            return (2*t3 - 3*t2 + 1)*v0 + (t3 - 2*t2 + t)*m0 + (-2*t3 + 3*t2)*v1 + (t3 - t2)*m1
    return keyframes[-1][1]

#Notice: At y=-2.30 (front nose) and y=+2.28 (rear tail), CENTER_TOP_Z and BELTLINE_Z stay at true hood/tailgate height (~0.92m front, ~1.08m rear)
# and the front/rear end faces are closed by a dedicated vertical fascia grid from z_sill (0.30m) up to z_top (0.92m / 1.08m)!
CENTER_TOP_Z = [
    (-2.300, 0.925), # Front hood leading edge above grille (never collapses down!)
    (-2.150, 0.972),
    (-1.750, 1.025),
    (-1.375, 1.052),
    (-0.980, 1.078), # Hood rear / cowl base of windshield
    (-0.600, 1.360), # Mid windshield
    (-0.220, 1.595), # Top of windshield / front of roof
    ( 0.200, 1.632), # Roof peak
    ( 0.750, 1.628),
    ( 1.250, 1.608),
    ( 1.580, 1.580), # Rear roof edge
    ( 1.730, 1.572), # Integrated roof spoiler tip
    ( 1.840, 1.440), # Upper rear glass slope
    ( 2.130, 1.135), # Base of rear glass / tailgate deck crease
    ( 2.260, 1.075), # Tailgate upper edge (never collapses down!)
]

LOWER_SILL_Z = [
    (-2.300, 0.305),
    (-2.150, 0.275),
    (-1.850, 0.240),
    (-1.375, 0.240),
    (-0.900, 0.235),
    ( 0.000, 0.235),
    ( 0.900, 0.235),
    ( 1.375, 0.245),
    ( 1.880, 0.265),
    ( 2.150, 0.310),
    ( 2.260, 0.340),
]

BELTLINE_Z = [
    (-2.300, 0.895), # Front headlight top / fender line stays at 0.895m right to the front corner!
    (-2.100, 0.928),
    (-1.800, 0.955),
    (-1.375, 0.980),
    (-0.950, 1.000),
    ( 0.000, 1.032),
    ( 0.950, 1.068),
    ( 1.375, 1.090),
    ( 1.850, 1.115),
    ( 2.260, 1.080), # Rear taillight top / quarter line stays at 1.080m right to the rear corner!
]

HALF_WIDTH_X = [
    (-2.300, 0.805), # Wide, muscular front bumper corner width matching sheet.jpg!
    (-2.120, 0.868),
    (-1.820, 0.898),
    (-1.375, 0.916), # Front fender blister
    (-0.950, 0.896),
    ( 0.000, 0.892), # Door waist
    ( 0.950, 0.898),
    ( 1.375, 0.916), # Rear fender blister
    ( 1.820, 0.892),
    ( 2.120, 0.848),
    ( 2.260, 0.785), # Wide rear bumper corner width
]

CANTRAIL_X = [
    (-2.300, 0.690),
    (-2.100, 0.725),
    (-1.750, 0.750),
    (-0.980, 0.760), # Base of A-pillar
    (-0.220, 0.642), # Top of A-pillar
    ( 0.300, 0.638), # Roof rail line
    ( 1.000, 0.622),
    ( 1.580, 0.580), # Top of D-pillar
    ( 1.730, 0.555), # Spoiler width
    ( 2.130, 0.645), # Base of D-pillar
    ( 2.260, 0.620),
]

def wheel_arch_lift(y, z_base):
    z_center = 0.380
    r_cut = 0.440
    for y_ax in (-1.375, 1.375):
        dy = abs(y - y_ax)
        if dy < r_cut:
            z_arch = z_center + sqrt(max(0.0, r_cut * r_cut - dy * dy))
            if z_arch > z_base:
                z_base = z_arch
    return z_base

def build_sculpted_body_shell():
    y_stations = [
        -2.290, -2.220, -2.130, -2.020, -1.910,
        -1.815, -1.700, -1.580, -1.475, -1.375, -1.275, -1.170, -1.060,
        -0.960, -0.840, -0.690, -0.530, -0.370, -0.220, -0.060,
         0.100,  0.260,  0.420,  0.580,  0.740,  0.890,
         0.975,  1.080,  1.180,  1.275,  1.375,  1.475,  1.580,
         1.680,  1.750,  1.860,  1.990,  2.125,  2.200,  2.260
    ]

    ring_s = list(range(-12, 13))
    n_y = len(y_stations)
    n_k = len(ring_s)

    def station_point(y, s_idx, side=1.0):
        z_sill = eval_catmull_1d(y, LOWER_SILL_Z)
        z_belt = eval_catmull_1d(y, BELTLINE_Z)
        z_top  = eval_catmull_1d(y, CENTER_TOP_Z)
        x_max  = eval_catmull_1d(y, HALF_WIDTH_X)
        x_cant = eval_catmull_1d(y, CANTRAIL_X)

        flare = 0.0
        for y_ax in (-1.375, 1.375):
            dy = abs(y - y_ax)
            if dy < 0.50:
                flare = max(flare, 0.022 * (0.5 + 0.5 * cos(pi * dy / 0.50)))

        z_arch = wheel_arch_lift(y, z_sill)

        if s_idx == 0:
            x = max(0.25, x_max - 0.16)
            z = max(z_sill - 0.015, z_arch - 0.008)
        elif s_idx == 1:
            x = x_max - 0.040 + flare * 0.5
            z = z_arch
        elif s_idx == 2:
            x = x_max - 0.020 + flare * 0.8
            z = max(z_arch + 0.022, z_sill + 0.16 * (z_belt - z_sill))
        elif s_idx == 3:
            x = x_max - 0.008 + flare * 0.9
            z = max(z_arch + 0.048, z_sill + 0.34 * (z_belt - z_sill))
        elif s_idx == 4:
            x = x_max + flare
            z = max(z_arch + 0.082, z_sill + 0.62 * (z_belt - z_sill))
        elif s_idx == 5:
            x = x_max - 0.012 + flare * 0.4
            z = max(z_arch + 0.108, z_sill + 0.88 * (z_belt - z_sill))
        elif s_idx == 6:
            x = x_max - 0.042
            z = max(z_arch + 0.122, z_belt)
        elif s_idx == 7:
            x = x_max - 0.066
            z = z_belt + 0.08 * (z_top - z_belt)
        elif s_idx == 8:
            x = x_cant + 0.52 * (x_max - 0.066 - x_cant)
            z = z_belt + 0.50 * (z_top - z_belt)
        elif s_idx == 9:
            x = x_cant + 0.020
            z = z_belt + 0.88 * (z_top - z_belt)
        elif s_idx == 10:
            x = x_cant * 0.84
            z = z_belt + 0.95 * (z_top - z_belt)
        elif s_idx == 11:
            x = x_cant * 0.42
            z = z_belt + 0.99 * (z_top - z_belt)
        else:
            x = 0.0
            z = z_top

        # Plan-view curve at nose & tail: smooth taper at the corners
        y_adj = y
        x_ratio = abs(x) / max(0.45, x_max)
        if y < -1.90:
            t_front = min(1.0, (-1.90 - y) / 0.39)
            y_adj = y + 0.085 * t_front * (x_ratio ** 1.6)
        elif y > 1.90:
            t_rear = min(1.0, (y - 1.90) / 0.36)
            y_adj = y - 0.075 * t_rear * (x_ratio ** 1.6)

        return (side * x, y_adj, z)

    verts = []
    faces = []

    for iy, y in enumerate(y_stations):
        for ik, k in enumerate(ring_s):
            s_idx = 12 - abs(k)
            side = 1.0 if k > 0 else (-1.0 if k < 0 else 0.0)
            verts.append(station_point(y, s_idx, side))

    def is_window_opening(iy, ik):
        y0 = y_stations[iy]
        y1 = y_stations[iy + 1]
        ym = 0.5 * (y0 + y1)
        s0 = 12 - abs(ring_s[ik])
        s1 = 12 - abs(ring_s[ik + 1])
        s_min = min(s0, s1)
        s_max = max(s0, s1)

        if -0.94 <= ym <= -0.24 and s_min >= 10:
            return True
        if 1.62 <= ym <= 2.11 and s_min >= 10:
            return True
        if -0.75 <= ym <= 1.54 and s_min >= 7 and s_max <= 9:
            return True
        return False

    for iy in range(n_y - 1):
        for ik in range(n_k - 1):
            if is_window_opening(iy, ik):
                continue
            v00 = iy * n_k + ik
            v01 = iy * n_k + ik + 1
            v11 = (iy + 1) * n_k + ik + 1
            v10 = (iy + 1) * n_k + ik
            faces.append((v00, v10, v11, v01))

    # Watertight Front Fascia Cap (iy = 0) & Rear Fascia Cap (iy = n_y - 1):
    # Note that in ring_s = [-12..+12]:
    #   Right side s_idx in 0..7 is at ik = s_idx
    #   Left side  s_idx in 0..7 is at ik = 24 - s_idx
    #   Upper arch between right s_idx=7 (ik=7) and left s_idx=7 (ik=17) is ik = 7..17 (11 columns!)
    n_cap_cols = 11

    # 1. Front Fascia Cap (8 rows r_i = 0..7 by 11 cols c_i = 0..10)
    f_cap_start = len(verts)
    for r_i in range(8):
        p_right = verts[0 * n_k + r_i]
        p_left  = verts[0 * n_k + (24 - r_i)]
        for c_i in range(n_cap_cols):
            if r_i == 7:
                # Stitch top row directly to the hood leading edge (ik = 7..17)
                verts.append(verts[0 * n_k + (7 + c_i)])
            elif c_i == 0:
                verts.append(p_right)
            elif c_i == n_cap_cols - 1:
                verts.append(p_left)
            else:
                u = (c_i / (n_cap_cols - 1)) * 2.0 - 1.0 # -1..+1
                x_c = u * p_left[0]
                z_c = p_left[2]
                # Smooth parabolic forward nose curve so post-Subsurf nose reaches y ≈ -2.342 at x=0
                bulge = -0.175 if 2 <= r_i <= 5 else (-0.145 if r_i in (1, 6) else -0.115)
                y_c = p_left[1] + bulge * (1.0 - u * u)
                verts.append((x_c, y_c, z_c))

    for r_i in range(7):
        for c_i in range(n_cap_cols - 1):
            a = f_cap_start + r_i * n_cap_cols + c_i
            b = a + 1
            c = f_cap_start + (r_i + 1) * n_cap_cols + c_i + 1
            d = f_cap_start + (r_i + 1) * n_cap_cols + c_i
            faces.append((a, d, c, b))

    # 2. Rear Fascia Cap (8 rows r_i = 0..7 by 11 cols c_i = 0..10 at iy = n_y - 1)
    r_cap_start = len(verts)
    last_iy = n_y - 1
    for r_i in range(8):
        p_right = verts[last_iy * n_k + r_i]
        p_left  = verts[last_iy * n_k + (24 - r_i)]
        for c_i in range(n_cap_cols):
            if r_i == 7:
                # Stitch top row directly to the tailgate upper crease (ik = 7..17)
                verts.append(verts[last_iy * n_k + (7 + c_i)])
            elif c_i == 0:
                verts.append(p_right)
            elif c_i == n_cap_cols - 1:
                verts.append(p_left)
            else:
                u = (c_i / (n_cap_cols - 1)) * 2.0 - 1.0
                x_c = u * p_left[0]
                z_c = p_left[2]
                # Rear bumper (r_i in 1..3) steps out to y ≈ +2.342 post-Subsurf; upper tailgate is slightly inset
                bulge = 0.195 if 1 <= r_i <= 3 else (0.140 if r_i == 4 else 0.110)
                y_c = p_left[1] + bulge * (1.0 - u * u)
                verts.append((x_c, y_c, z_c))

    for r_i in range(7):
        for c_i in range(n_cap_cols - 1):
            a = r_cap_start + r_i * n_cap_cols + c_i
            b = a + 1
            c = r_cap_start + (r_i + 1) * n_cap_cols + c_i + 1
            d = r_cap_start + (r_i + 1) * n_cap_cols + c_i
            faces.append((a, b, c, d))

    body_obj = add_mesh_obj("Body_Shell", verts, faces, Mat_Paint, group="Body", smooth=True, uv_mode="BODY")

    # Weld coinciding boundary vertices between the side shell and front/rear bumper caps before Subsurf!
    set_active(body_obj)
    bm = bmesh.new()
    bm.from_mesh(body_obj.data)
    bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.006)
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    bm.to_mesh(body_obj.data)
    bm.free()
    body_obj.data.update()

    sub = body_obj.modifiers.new("SmoothSurface", 'SUBSURF')
    sub.levels = 1
    sub.render_levels = 1
    bpy.ops.object.modifier_apply(modifier=sub.name)

    sol = body_obj.modifiers.new("SheetMetalThickness", 'SOLIDIFY')
    sol.thickness = 0.004
    sol.offset = -1.0
    sol.use_even_offset = True
    bpy.ops.object.modifier_apply(modifier=sol.name)

    assign_uvs(body_obj, "BODY")
    return body_obj

BODY_SHELL_OBJ = build_sculpted_body_shell()

# ---------------------------------------------------------------------------
# 5. GREENHOUSE WINDOWS, PILLARS, CHROME WINDOW SURROUND & ROOF RAILS
# ---------------------------------------------------------------------------
def build_greenhouse_and_glass():
    # 1. Front Windshield Glass
    ws_verts = []
    ws_faces = []
    ws_rows = [
        (-0.98, 1.074, 0.720, 0.026),
        (-0.78, 1.218, 0.692, 0.024),
        (-0.58, 1.362, 0.662, 0.020),
        (-0.39, 1.488, 0.632, 0.017),
        (-0.21, 1.594, 0.602, 0.014),
    ]
    n_cols = 15
    for y_r, z_r, w_r, crown in ws_rows:
        for c_idx in range(n_cols):
            u = (c_idx / (n_cols - 1)) * 2.0 - 1.0
            x = u * w_r
            y = y_r - 0.042 * (1.0 - u * u)
            z = z_r + crown * (1.0 - u * u)
            ws_verts.append((x, y, z))
    for r in range(len(ws_rows) - 1):
        for c in range(n_cols - 1):
            a = r * n_cols + c
            b = a + 1
            d = (r + 1) * n_cols + c
            e = d + 1
            ws_faces.append((a, d, e, b))
    add_mesh_obj("Windshield_Glass", ws_verts, ws_faces, Mat_Glass, group="Body", smooth=True)

    # 2. Rear Tailgate Window Glass
    rw_verts = []
    rw_faces = []
    rw_rows = [
        (1.60, 1.568, 0.552, 0.014),
        (1.78, 1.430, 0.582, 0.018),
        (1.96, 1.280, 0.608, 0.020),
        (2.13, 1.135, 0.622, 0.022),
    ]
    for y_r, z_r, w_r, crown in rw_rows:
        for c_idx in range(n_cols):
            u = (c_idx / (n_cols - 1)) * 2.0 - 1.0
            x = u * w_r
            y = y_r + 0.038 * (1.0 - u * u)
            z = z_r + crown * (1.0 - u * u)
            rw_verts.append((x, y, z))
    for r in range(len(rw_rows) - 1):
        for c in range(n_cols - 1):
            a = r * n_cols + c
            b = a + 1
            d = (r + 1) * n_cols + c
            e = d + 1
            rw_faces.append((a, b, e, d))
    add_mesh_obj("Rear_Windshield_Glass", rw_verts, rw_faces, Mat_Glass, group="Body", smooth=True)

    # 3. Side Windows, B/C Pillars, Continuous Chrome Surround & Raised Roof Rails
    for side in (-1.0, 1.0):
        sname = "L" if side > 0 else "R"
        sw_cols = [
            (-0.80, 1.008, 1.195, 0.822, 0.742),
            (-0.52, 1.018, 1.375, 0.822, 0.692),
            (-0.22, 1.028, 1.548, 0.822, 0.646),
            ( 0.10, 1.038, 1.565, 0.820, 0.636),
            ( 0.55, 1.050, 1.562, 0.818, 0.632),
            ( 1.02, 1.065, 1.540, 0.814, 0.620),
            ( 1.35, 1.088, 1.505, 0.804, 0.600),
            ( 1.58, 1.135, 1.465, 0.786, 0.592),
        ]
        sv = []
        sf = []
        n_vz = 5
        for ic, (yy, z_bot, z_top, x_bot, x_top) in enumerate(sw_cols):
            for iz in range(n_vz):
                t = iz / (n_vz - 1)
                zz = z_bot + t * (z_top - z_bot)
                xx = side * (x_bot + t * (x_top - x_bot) + 0.008 * sin(pi * t))
                sv.append((xx, yy, zz))
        for ic in range(len(sw_cols) - 1):
            for iz in range(n_vz - 1):
                a = ic * n_vz + iz
                b = a + 1
                c = (ic + 1) * n_vz + iz + 1
                d = (ic + 1) * n_vz + iz
                sf.append((a, d, c, b) if side > 0 else (a, b, c, d))
        add_mesh_obj(f"Side_Glass_{sname}", sv, sf, Mat_Glass, group="Body", smooth=True)

        b_verts = [
            (side * 0.824,  0.04, 1.038),
            (side * 0.824,  0.14, 1.042),
            (side * 0.642,  0.16, 1.552),
            (side * 0.644,  0.06, 1.552),
        ]
        add_mesh_obj(f"B_Pillar_Trim_{sname}", b_verts, [(0, 1, 2, 3) if side > 0 else (0, 3, 2, 1)], Mat_Plastic, group="Body")

        c_verts = [
            (side * 0.818,  0.96, 1.066),
            (side * 0.816,  1.06, 1.072),
            (side * 0.622,  1.01, 1.532),
            (side * 0.625,  0.91, 1.538),
        ]
        add_mesh_obj(f"C_Pillar_Trim_{sname}", c_verts, [(0, 1, 2, 3) if side > 0 else (0, 3, 2, 1)], Mat_Plastic, group="Body")

        tri_verts = [
            (side * 0.824, -0.80, 1.012),
            (side * 0.824, -0.68, 1.020),
            (side * 0.790, -0.74, 1.085),
        ]
        add_mesh_obj(f"Mirror_Triangle_{sname}", tri_verts, [(0, 1, 2) if side > 0 else (0, 2, 1)], Mat_Plastic, group="Body")

        chrome_loop = [
            (side * 0.830, -0.81, 1.012),
            (side * 0.720, -0.53, 1.365),
            (side * 0.650, -0.22, 1.548),
            (side * 0.640,  0.20, 1.564),
            (side * 0.634,  0.70, 1.560),
            (side * 0.622,  1.12, 1.535),
            (side * 0.602,  1.42, 1.495),
            (side * 0.596,  1.58, 1.455),
            (side * 0.788,  1.58, 1.140),
            (side * 0.816,  1.15, 1.078),
            (side * 0.826,  0.50, 1.052),
            (side * 0.830, -0.20, 1.030),
        ]
        add_curve_tube(f"Chrome_Window_Surround_{sname}", chrome_loop, 0.010, Mat_Chrome, group="Body", closed=True, res_u=10, bev_res=4)

        rail_pts = [
            (side * 0.566, -0.16, 1.608),
            (side * 0.564, -0.04, 1.658),
            (side * 0.560,  0.34, 1.674),
            (side * 0.556,  0.85, 1.668),
            (side * 0.548,  1.32, 1.640),
            (side * 0.542,  1.46, 1.592),
        ]
        add_curve_tube(f"Roof_Rail_{sname}", rail_pts, 0.014, Mat_Chrome, group="Body", closed=False, res_u=14, bev_res=5)

        for fy, fz, flen in [(-0.08, 1.628, 0.12), (0.62, 1.646, 0.10), (1.35, 1.612, 0.12)]:
            add_box(f"Roof_Rail_Foot_{sname}_{fy:.2f}", (side * 0.556, fy, fz), (0.024, flen, 0.028), Mat_Plastic, bev=0.005)

    fin_verts = [
        ( 0.000, 1.32, 1.595),
        ( 0.020, 1.47, 1.586),
        (-0.020, 1.47, 1.586),
        ( 0.000, 1.45, 1.678),
        ( 0.000, 1.37, 1.650),
    ]
    fin_faces = [(0, 1, 4), (0, 4, 2), (1, 3, 4), (2, 4, 3), (1, 2, 3), (0, 2, 1)]
    fin_obj = add_mesh_obj("Shark_Fin_Antenna", fin_verts, fin_faces, Mat_Plastic, group="Body", smooth=True)
    apply_bevel(fin_obj, 0.003, 2)

    for wx, wy in [(-0.28, -0.98), (0.22, -0.98)]:
        add_box("Front_Wiper_Arm", (wx, wy, 1.084), (0.48, 0.016, 0.012), Mat_Plastic, bev=0.003, rot=(0.15, 0.06, -0.05))
    add_box("Rear_Wiper_Arm", (0.16, 2.138, 1.148), (0.34, 0.014, 0.012), Mat_Plastic, bev=0.003, rot=(-0.35, 0.0, 0.0))

build_greenhouse_and_glass()

# ---------------------------------------------------------------------------
# 6. WHEEL-ARCH CLADDING, ROCKER CLADDING, CHROME SPEARS, MIRRORS & HANDLES
# ---------------------------------------------------------------------------
def build_cladding_and_side_details():
    for side in (-1.0, 1.0):
        sname = "L" if side > 0 else "R"

        for ax_name, y_ax in [("Front", -1.375), ("Rear", 1.375)]:
            z_ax = 0.380
            r_in = 0.428
            r_out = 0.506
            n_seg = 32
            c_verts = []
            c_faces = []
            l_verts = []
            l_faces = []

            for i in range(n_seg + 1):
                ang = math.radians(-12.0 + 204.0 * (i / n_seg))
                ca, sa = cos(ang), sin(ang)
                y_in = y_ax - r_in * ca
                z_in = max(0.235, z_ax + r_in * sa)
                y_out = y_ax - r_out * ca
                z_out = max(0.245, z_ax + r_out * sa)

                x_outer_lip = side * 0.930
                x_body_meet = side * 0.904
                x_inner_well = side * 0.560

                c_verts.extend([
                    (x_body_meet, y_in, z_in),
                    (x_outer_lip, y_in * 0.72 + y_out * 0.28, z_in * 0.72 + z_out * 0.28),
                    (x_outer_lip, y_out * 0.86 + y_in * 0.14, z_out * 0.86 + z_in * 0.14),
                    (x_body_meet, y_out, z_out),
                ])

                l_verts.extend([
                    (x_body_meet, y_in, z_in),
                    (x_inner_well, y_in, z_in),
                ])

            for i in range(n_seg):
                b0 = i * 4
                b1 = (i + 1) * 4
                for j in range(3):
                    if side > 0:
                        c_faces.append((b0 + j, b1 + j, b1 + j + 1, b0 + j + 1))
                    else:
                        c_faces.append((b0 + j, b0 + j + 1, b1 + j + 1, b1 + j))

                l0 = i * 2
                l1 = (i + 1) * 2
                if side > 0:
                    l_faces.append((l0, l0 + 1, l1 + 1, l1))
                else:
                    l_faces.append((l0, l1, l1 + 1, l0 + 1))

            center_idx = len(l_verts)
            l_verts.append((side * 0.560, y_ax, z_ax))
            for i in range(n_seg):
                if side > 0:
                    l_faces.append((i * 2 + 1, center_idx, (i + 1) * 2 + 1))
                else:
                    l_faces.append((i * 2 + 1, (i + 1) * 2 + 1, center_idx))

            add_mesh_obj(f"Wheel_Arch_Cladding_{ax_name}_{sname}", c_verts, c_faces, Mat_Plastic, group="Body", smooth=True)
            add_mesh_obj(f"Wheel_Well_Liner_{ax_name}_{sname}", l_verts, l_faces, Mat_Plastic, group="Body", smooth=True)

        add_box(
            f"Rocker_Cladding_{sname}",
            (side * 0.886, 0.00, 0.285),
            (0.052, 1.84, 0.105),
            Mat_Plastic,
            bev=0.010,
        )

        add_box(
            f"Lower_Door_Chrome_Strip_{sname}",
            (side * 0.900, 0.00, 0.365),
            (0.016, 1.80, 0.020),
            Mat_Chrome,
            bev=0.004,
        )

        add_box(
            f"Fender_Chrome_Spear_{sname}",
            (side * 0.898, -0.86, 0.955),
            (0.014, 0.34, 0.016),
            Mat_Chrome,
            bev=0.003,
            rot=(0.03, 0.0, 0.0),
        )

        for d_name, dy, dz in [("Front", -0.16, 0.995), ("Rear", 0.82, 1.032)]:
            add_box(f"Door_Handle_Cup_{d_name}_{sname}", (side * 0.884, dy, dz), (0.012, 0.13, 0.040), Mat_Plastic, bev=0.004)
            add_box(f"Door_Handle_Bar_{d_name}_{sname}", (side * 0.904, dy, dz), (0.022, 0.175, 0.025), Mat_Paint, bev=0.006, uv_mode="BODY")
            add_box(f"Door_Handle_Chrome_{d_name}_{sname}", (side * 0.914, dy, dz + 0.007), (0.006, 0.145, 0.007), Mat_Chrome, bev=0.002)

        shut_front = [(side * 0.894, -0.90, 0.35), (side * 0.904, -0.90, 0.68), (side * 0.886, -0.84, 1.00)]
        shut_mid   = [(side * 0.894,  0.09, 0.35), (side * 0.904,  0.09, 0.70), (side * 0.884,  0.09, 1.03)]
        shut_rear  = [(side * 0.894,  0.88, 0.36), (side * 0.906,  1.06, 0.72), (side * 0.884,  1.12, 1.06)]
        for s_idx, pts in enumerate([shut_front, shut_mid, shut_rear]):
            add_curve_tube(f"Door_Shutline_{s_idx}_{sname}", pts, 0.0022, Mat_Plastic, group="Body", res_u=6, bev_res=2)

        add_box(
            f"Mirror_Stalk_{sname}",
            (side * 0.852, -0.73, 1.015),
            (0.062, 0.072, 0.042),
            Mat_Plastic,
            bev=0.008,
            rot=(0.0, side * -0.25, side * 0.15),
        )
        add_box(
            f"Mirror_Cap_Paint_{sname}",
            (side * 0.882, -0.72, 1.055),
            (0.112, 0.142, 0.080),
            Mat_Paint,
            bev=0.022,
            seg=3,
            rot=(0.05, 0.0, side * 0.18),
            uv_mode="BODY",
        )
        add_box(
            f"Mirror_Base_Plastic_{sname}",
            (side * 0.880, -0.718, 1.028),
            (0.108, 0.136, 0.034),
            Mat_Plastic,
            bev=0.010,
            rot=(0.05, 0.0, side * 0.18),
        )
        add_box(
            f"Mirror_Glass_{sname}",
            (side * 0.882, -0.656, 1.050),
            (0.096, 0.010, 0.066),
            Mat_Chrome,
            bev=0.003,
            rot=(0.05, 0.0, side * 0.18),
        )
        add_box(
            f"Mirror_Turn_LED_{sname}",
            (side * 0.890, -0.775, 1.050),
            (0.090, 0.012, 0.009),
            Mat_Lamps,
            bev=0.002,
            rot=(0.05, 0.0, side * 0.18),
            uv_mode="LAMP_AMBER",
        )

build_cladding_and_side_details()

# ---------------------------------------------------------------------------
# 7. FLUSH CONTOUR-MATCHED FRONT FASCIA: WIDE GRILLE, LED HEADLIGHTS & INTAKES
# ---------------------------------------------------------------------------
def build_curved_patch(name, x_vals, z_vals, y_func, thickness, mat, group="Body", uv_mode="BOX"):
    verts = []
    faces = []
    nx = len(x_vals)
    nz = len(z_vals)
    for iz, z in enumerate(z_vals):
        for ix, x in enumerate(x_vals):
            verts.append((x, y_func(x, z), z))
    for iz in range(nz - 1):
        for ix in range(nx - 1):
            a = iz * nx + ix
            b = a + 1
            c = (iz + 1) * nx + ix + 1
            d = (iz + 1) * nx + ix
            faces.append((a, b, c, d))
    obj = add_mesh_obj(name, verts, faces, mat, group=group, smooth=True, uv_mode=uv_mode)
    set_active(obj)
    sol = obj.modifiers.new("Thick", 'SOLIDIFY')
    sol.thickness = thickness
    sol.offset = 0.0
    bpy.ops.object.modifier_apply(modifier=sol.name)
    apply_bevel(obj, min(0.003, abs(thickness) * 0.35), 2)
    assign_uvs(obj, uv_mode)
    return obj

def y_front_skin(x, z):
    """Exact front bumper/grille skin queried directly from the subdivided BODY_SHELL_OBJ."""
    z_q = max(0.32, min(0.895, z))
    sgn = 1.0 if x >= 0.0 else -1.0
    ax = abs(x)
    for step in range(25):
        x_try = sgn * max(0.0, ax - step * 0.015)
        hit, loc, _norm, _idx = BODY_SHELL_OBJ.ray_cast((x_try, -3.0, z_q), (0.0, 1.0, 0.0))
        if hit and loc.y < -1.80:
            # Account for corner wrap-around sweep if x was outside the cap boundary
            dx_extra = ax - abs(x_try)
            return loc.y + dx_extra * 0.42
    u = min(1.0, ax / 0.785)
    return -2.170 - 0.165 * (1.0 - u * u)

def y_rear_skin(x, z):
    """Exact rear tailgate/bumper skin queried directly from the subdivided BODY_SHELL_OBJ."""
    z_q = max(0.35, min(1.065, z))
    sgn = 1.0 if x >= 0.0 else -1.0
    ax = abs(x)
    for step in range(25):
        x_try = sgn * max(0.0, ax - step * 0.015)
        hit, loc, _norm, _idx = BODY_SHELL_OBJ.ray_cast((x_try, 3.0, z_q), (0.0, -1.0, 0.0))
        if hit and loc.y > 1.80:
            dx_extra = ax - abs(x_try)
            return loc.y - dx_extra * 0.42
    u = min(1.0, ax / 0.765)
    return 2.150 + 0.180 * (1.0 - u * u)

def build_front_fascia():
    # 1. Flush Upper Grille Mesh Backing (hugs y_front_skin across x in [-0.48..+0.48], z in [0.68..0.90])
    gx = [-0.48 + 0.96 * i / 14 for i in range(15)]
    gz = [0.68, 0.74, 0.80, 0.85, 0.90]
    build_curved_patch("Front_Grille_Backing", gx, gz, lambda x, z: y_front_skin(x, z) - 0.002, 0.005, Mat_Plastic)

    # Chrome Lower/Side U-Surround Cradle around Upper Grille
    grille_cradle_pts = [
        (-0.49, y_front_skin(-0.49, 0.89) - 0.006, 0.89),
        (-0.46, y_front_skin(-0.46, 0.68) - 0.006, 0.68),
        (-0.23, y_front_skin(-0.23, 0.675) - 0.006, 0.675),
        ( 0.00, y_front_skin( 0.00, 0.672) - 0.006, 0.672),
        ( 0.23, y_front_skin( 0.23, 0.675) - 0.006, 0.675),
        ( 0.46, y_front_skin( 0.46, 0.68) - 0.006, 0.68),
        ( 0.49, y_front_skin( 0.49, 0.89) - 0.006, 0.89),
    ]
    add_curve_tube("Grille_Chrome_Cradle", grille_cradle_pts, 0.006, Mat_Chrome, group="Body", res_u=12, bev_res=4)

    # 4 Full-width Horizontal Chrome Grille Bars flowing into the headlights (matching sheet.jpg!)
    for iz, z_bar in enumerate([0.705, 0.768, 0.832, 0.895]):
        w_bar = 0.46 + iz * 0.015
        pts = [
            (sx_val, y_front_skin(sx_val, z_bar) - 0.007, z_bar)
            for sx_val in [-w_bar, -w_bar * 0.5, 0.0, w_bar * 0.5, w_bar]
        ]
        add_curve_tube(f"Grille_Chrome_Bar_{iz}", pts, 0.006, Mat_Chrome, group="Body", res_u=12, bev_res=4)

    # Vertical dark grille strakes
    for vx in [-0.36, -0.24, -0.12, 0.0, 0.12, 0.24, 0.36]:
        yv = y_front_skin(vx, 0.79) - 0.004
        add_box(f"Grille_Vane_{vx:.2f}", (vx, yv, 0.79), (0.008, 0.008, 0.20), Mat_Plastic, bev=0.002)

    # 2. Flush Lower Central Bumper Intake + Brushed Silver Chin Lip
    lx = [-0.42 + 0.84 * i / 12 for i in range(13)]
    lz = [0.35, 0.41, 0.47]
    build_curved_patch("Lower_Center_Intake", lx, lz, lambda x, z: y_front_skin(x, z) - 0.002, 0.005, Mat_Plastic)

    for z_slat in [0.385, 0.435]:
        pts = [(sx_val, y_front_skin(sx_val, z_slat) - 0.006, z_slat) for sx_val in [-0.40, -0.20, 0.0, 0.20, 0.40]]
        add_curve_tube(f"Lower_Intake_Slat_{z_slat:.2f}", pts, 0.004, Mat_Plastic, group="Body", res_u=10, bev_res=3)

    chin_pts = [(sx_val, y_front_skin(sx_val, 0.325) - 0.005, 0.325) for sx_val in [-0.50, -0.25, 0.0, 0.25, 0.50]]
    add_curve_tube("Front_Chin_Chrome_Lip", chin_pts, 0.009, Mat_Chrome, group="Body", res_u=12, bev_res=4)

    # 3. Flush Swept-Back LED Headlights & Lower Fog/Air Intake Bezels
    for side in (-1.0, 1.0):
        sname = "L" if side > 0 else "R"

        hx = [side * (0.48 + 0.27 * i / 6) for i in range(7)]
        if side < 0:
            hx.reverse()
        hz = [0.765, 0.832, 0.900]
        build_curved_patch(f"Headlight_Housing_{sname}", hx, hz, lambda x, z: y_front_skin(x, z) - 0.002, 0.005, Mat_Plastic)

        # Dual Bi-LED Projector Modules + Glowing White LED Cores
        for px_abs in (0.55, 0.66):
            px_val = side * px_abs
            py_val = y_front_skin(px_val, 0.842) - 0.006
            yaw = side * 0.38
            add_box(
                f"Headlight_Proj_Ring_{sname}_{px_abs:.2f}",
                (px_val, py_val, 0.842),
                (0.068, 0.010, 0.050),
                Mat_Chrome,
                bev=0.004,
                rot=(0.05, 0.0, yaw),
            )
            add_box(
                f"Headlight_Proj_LED_{sname}_{px_abs:.2f}",
                (px_val, py_val - 0.003, 0.842),
                (0.048, 0.008, 0.032),
                Mat_Lamps,
                bev=0.003,
                rot=(0.05, 0.0, yaw),
                uv_mode="LAMP_WHITE",
            )

        # Signature Dual-L DRL Light Ribbon
        drl_pts = [
            (side * 0.49, y_front_skin(side * 0.49, 0.785) - 0.007, 0.785),
            (side * 0.61, y_front_skin(side * 0.61, 0.785) - 0.007, 0.785),
            (side * 0.72, y_front_skin(side * 0.72, 0.792) - 0.007, 0.792),
            (side * 0.74, y_front_skin(side * 0.74, 0.882) - 0.007, 0.882),
        ]
        add_curve_tube(f"Headlight_DRL_{sname}", drl_pts, 0.005, Mat_Lamps, group="Body", res_u=10, bev_res=3, uv_mode="LAMP_WHITE")

        # Amber Turn Indicator Strip
        amber_pts = [
            (side * 0.59, y_front_skin(side * 0.59, 0.772) - 0.007, 0.772),
            (side * 0.67, y_front_skin(side * 0.67, 0.774) - 0.007, 0.774),
            (side * 0.73, y_front_skin(side * 0.73, 0.778) - 0.007, 0.778),
        ]
        add_curve_tube(f"Headlight_Turn_Amber_{sname}", amber_pts, 0.004, Mat_Lamps, group="Body", res_u=6, bev_res=3, uv_mode="LAMP_AMBER")

        # Flush Clear Polycarbonate Headlight Lens
        build_curved_patch(f"Headlight_Lens_Glass_{sname}", hx, hz, lambda x, z: y_front_skin(x, z) - 0.009, 0.003, Mat_Glass)

        # Compact Lower Side Bumper Intake / Fog Bezel (leaving sculpted painted upper bumper cheek matching sheet.jpg!)
        ix = [side * (0.46 + 0.19 * i / 5) for i in range(6)]
        if side < 0:
            ix.reverse()
        iz_list = [0.36, 0.415, 0.47]
        build_curved_patch(f"Side_Intake_Pocket_{sname}", ix, iz_list, lambda x, z: y_front_skin(x, z) - 0.002, 0.005, Mat_Plastic)

        for sz_bar in [0.39, 0.435]:
            bar_pts = [
                (side * x_b, y_front_skin(side * x_b, sz_bar) - 0.006, sz_bar)
                for x_b in (0.47, 0.555, 0.64)
            ]
            add_curve_tube(
                f"Side_Intake_Fin_{sname}_{sz_bar:.2f}",
                bar_pts,
                0.0045,
                Mat_Chrome if sz_bar == 0.435 else Mat_Plastic,
                group="Body",
                res_u=6,
                bev_res=3,
            )

    # Hood Power-Dome Character Creases
    for side in (-1.0, 1.0):
        sname = "L" if side > 0 else "R"
        hood_crease = [
            (side * 0.64, -0.98, 1.074),
            (side * 0.59, -1.45, 1.044),
            (side * 0.54, -1.88, 1.004),
            (side * 0.49, -2.20, 0.935),
        ]
        add_curve_tube(f"Hood_Power_Crease_{sname}", hood_crease, 0.005, Mat_Paint, group="Body", res_u=8, bev_res=3, uv_mode="BODY")

build_front_fascia()

# ---------------------------------------------------------------------------
# 8. FLUSH CONTOUR-MATCHED REAR TAILGATE, SPLIT LED TAILLIGHTS & EXHAUSTS
# ---------------------------------------------------------------------------
def build_rear_fascia():
    add_box(
        "Third_Brake_Light",
        (0.0, 1.735, 1.564),
        (0.46, 0.018, 0.012),
        Mat_Lamps,
        bev=0.003,
        uv_mode="LAMP_RED",
    )

    # License Plate Recess (tucked flush into central tailgate)
    lx = [-0.18 + 0.36 * i / 6 for i in range(7)]
    lz = [0.84, 0.90, 0.95]
    build_curved_patch("Rear_License_Plate_Back", lx, lz, lambda x, z: y_rear_skin(x, z) - 0.001, 0.004, Mat_Plastic)

    # Flush Wrap-Around Split LED Taillight Clusters
    for side in (-1.0, 1.0):
        sname = "L" if side > 0 else "R"
        tx = [side * (0.38 + 0.35 * i / 6) for i in range(7)]
        if side > 0:
            tx.reverse()
        tz = [0.985, 1.028, 1.070]
        build_curved_patch(f"Taillight_Housing_{sname}", tx, tz, lambda x, z: y_rear_skin(x, z) + 0.002, 0.005, Mat_Plastic)

        for z_led in (1.052, 1.006):
            led_pts = [
                (side * x_l, y_rear_skin(side * x_l, z_led) + 0.006, z_led)
                for x_l in (0.40, 0.51, 0.62, 0.72)
            ]
            add_curve_tube(f"Taillight_Red_LED_{sname}_{z_led:.3f}", led_pts, 0.0065, Mat_Lamps, group="Body", res_u=8, bev_res=3, uv_mode="LAMP_RED")

        amb_pts = [
            (side * x_l, y_rear_skin(side * x_l, 1.029) + 0.007, 1.029)
            for x_l in (0.46, 0.59, 0.71)
        ]
        add_curve_tube(f"Taillight_Amber_LED_{sname}", amb_pts, 0.004, Mat_Lamps, group="Body", res_u=6, bev_res=3, uv_mode="LAMP_AMBER")

    # Flush Rear Bumper Lower Diffuser Valance, Chrome Strip, Red Reflectors & Dual Exhausts
    vx = [-0.66 + 1.32 * i / 14 for i in range(15)]
    vz = [0.35, 0.42, 0.49]
    build_curved_patch("Rear_Bumper_Valance", vx, vz, lambda x, z: y_rear_skin(x, z) + 0.002, 0.005, Mat_Plastic)

    rear_chrome_pts = [(sx_val, y_rear_skin(sx_val, 0.502) + 0.006, 0.502) for sx_val in [-0.66, -0.34, 0.0, 0.34, 0.66]]
    add_curve_tube("Rear_Bumper_Chrome_Trim", rear_chrome_pts, 0.006, Mat_Chrome, group="Body", res_u=12, bev_res=4)

    for side in (-1.0, 1.0):
        sname = "L" if side > 0 else "R"
        ref_pts = [
            (side * x_r, y_rear_skin(side * x_r, 0.462) + 0.007, 0.462)
            for x_r in (0.46, 0.55, 0.64)
        ]
        add_curve_tube(f"Rear_Reflector_{sname}", ref_pts, 0.005, Mat_Lamps, group="Body", res_u=6, bev_res=3, uv_mode="LAMP_RED")

        ex_y = y_rear_skin(side * 0.56, 0.365) - 0.012
        add_box(
            f"Exhaust_Tip_Chrome_{sname}",
            (side * 0.56, ex_y, 0.365),
            (0.135, 0.036, 0.046),
            Mat_Chrome,
            bev=0.007,
            rot=(0.0, 0.0, side * -0.20),
        )
        add_box(
            f"Exhaust_Tip_Inner_{sname}",
            (side * 0.56, ex_y + 0.004, 0.365),
            (0.108, 0.032, 0.028),
            Mat_Plastic,
            bev=0.004,
            rot=(0.0, 0.0, side * -0.20),
        )

build_rear_fascia()

# ---------------------------------------------------------------------------
# 9. BELIEVABLE INTERIOR CABIN & STEERING WHEEL (Visible Through Tinted Glass)
# ---------------------------------------------------------------------------
def build_interior_and_steering():
    add_box("Cabin_Floor", (0.0, 0.35, 0.32), (1.48, 2.45, 0.06), Mat_Interior, bev=0.01)
    add_box("Cargo_Parcel_Shelf", (0.0, 1.48, 0.98), (1.32, 0.62, 0.04), Mat_Interior, bev=0.01)

    add_box("Dashboard_Main", (0.0, -0.68, 0.92), (1.46, 0.52, 0.28), Mat_Interior, bev=0.025, seg=3)
    add_box("Instrument_Hood", (0.42, -0.58, 1.055), (0.38, 0.26, 0.11), Mat_Interior, bev=0.018)
    add_box("Infotainment_Bezel", (0.0, -0.52, 1.02), (0.32, 0.05, 0.16), Mat_Plastic, bev=0.008, rot=(-0.18, 0.0, 0.0))
    add_box("Infotainment_Trim", (0.0, -0.515, 1.02), (0.33, 0.04, 0.17), Mat_Chrome, bev=0.004, rot=(-0.18, 0.0, 0.0))

    add_box("Center_Console", (0.0, -0.12, 0.56), (0.28, 1.05, 0.36), Mat_Interior, bev=0.020)
    add_box("Center_Armrest", (0.0, 0.12, 0.76), (0.24, 0.38, 0.09), Mat_Interior, bev=0.018)
    add_box("Gear_Selector_Base", (0.0, -0.32, 0.755), (0.14, 0.18, 0.02), Mat_Chrome, bev=0.004)
    add_box("Gear_Selector_Knob", (0.0, -0.32, 0.815), (0.065, 0.085, 0.10), Mat_Plastic, bev=0.012)

    for side in (-1.0, 1.0):
        sname = "L" if side > 0 else "R"
        add_box(f"Inner_Door_Card_{sname}", (side * 0.76, 0.15, 0.68), (0.07, 1.82, 0.64), Mat_Interior, bev=0.015)
        add_box(f"Door_Armrest_{sname}", (side * 0.71, 0.10, 0.76), (0.08, 1.55, 0.06), Mat_Interior, bev=0.012)
        add_box(f"Inner_B_Pillar_{sname}", (side * 0.72, 0.09, 1.15), (0.07, 0.14, 0.78), Mat_Interior, bev=0.010, rot=(0.0, side * -0.18, 0.0))

    add_box("Interior_Rearview_Mirror", (0.0, -0.28, 1.43), (0.22, 0.045, 0.055), Mat_Plastic, bev=0.008)
    add_box("Interior_Mirror_Stalk", (0.0, -0.25, 1.47), (0.028, 0.05, 0.06), Mat_Plastic, bev=0.004)

    for sx_name, sx in [("Driver", 0.42), ("Passenger", -0.42)]:
        add_box(f"Front_Seat_Cushion_{sx_name}", (sx, -0.04, 0.56), (0.48, 0.52, 0.14), Mat_Interior, bev=0.025, seg=3)
        for bside in (-1.0, 1.0):
            add_box(f"Front_Seat_Bolster_Low_{sx_name}_{bside}", (sx + bside * 0.21, -0.04, 0.61), (0.09, 0.48, 0.10), Mat_Interior, bev=0.020)
            add_box(f"Front_Seat_Bolster_Back_{sx_name}_{bside}", (sx + bside * 0.21, 0.17, 0.90), (0.09, 0.14, 0.48), Mat_Interior, bev=0.020, rot=(-0.18, 0.0, 0.0))
        add_box(f"Front_Seat_Back_{sx_name}", (sx, 0.19, 0.90), (0.46, 0.14, 0.56), Mat_Interior, bev=0.025, seg=3, rot=(-0.18, 0.0, 0.0))
        add_box(f"Front_Headrest_{sx_name}", (sx, 0.26, 1.27), (0.24, 0.11, 0.19), Mat_Interior, bev=0.025, seg=3, rot=(-0.10, 0.0, 0.0))
        for px in (-0.06, 0.06):
            add_box(f"Headrest_Post_{sx_name}_{px}", (sx + px, 0.24, 1.17), (0.014, 0.014, 0.08), Mat_Chrome, bev=0.002)

    add_box("Rear_Bench_Cushion", (0.0, 0.88, 0.58), (1.34, 0.50, 0.14), Mat_Interior, bev=0.025, seg=3)
    add_box("Rear_Bench_Back", (0.0, 1.12, 0.92), (1.32, 0.15, 0.54), Mat_Interior, bev=0.025, seg=3, rot=(-0.16, 0.0, 0.0))
    for rx in (-0.44, 0.0, 0.44):
        add_box(f"Rear_Headrest_{rx:.2f}", (rx, 1.18, 1.26), (0.22, 0.10, 0.16), Mat_Interior, bev=0.020, seg=3, rot=(-0.12, 0.0, 0.0))

    hub_pos = Vector((0.42, -0.38, 0.98))
    tilt = math.radians(24.0)

    add_box("Steering_Column_Shroud", (0.42, -0.50, 0.94), (0.14, 0.26, 0.11), Mat_Plastic, bev=0.015, rot=(tilt, 0.0, 0.0))

    bpy.ops.mesh.primitive_torus_add(
        major_radius=0.175,
        minor_radius=0.018,
        major_segments=36,
        minor_segments=12,
        location=hub_pos,
        rotation=(pi/2 - tilt, 0.0, 0.0),
    )
    sw_rim = bpy.context.object
    sw_rim.name = "SW_Rim"
    sw_rim.data.materials.append(Mat_Interior)
    for p in sw_rim.data.polygons:
        p.use_smooth = True
    assign_uvs(sw_rim)
    GROUPS["SteeringWheel"].append(sw_rim)

    add_box("SW_Hub", hub_pos, (0.11, 0.055, 0.10), Mat_Interior, bev=0.015, group="SteeringWheel", rot=(-tilt, 0.0, 0.0))
    add_box("SW_Spoke_L", hub_pos + Vector((0.09, 0.005, 0.0)), (0.12, 0.022, 0.032), Mat_Chrome, bev=0.004, group="SteeringWheel", rot=(-tilt, 0.0, 0.0))
    add_box("SW_Spoke_R", hub_pos + Vector((-0.09, 0.005, 0.0)), (0.12, 0.022, 0.032), Mat_Chrome, bev=0.004, group="SteeringWheel", rot=(-tilt, 0.0, 0.0))
    add_box("SW_Spoke_B", hub_pos + Vector((0.0, 0.038, -0.082)), (0.036, 0.022, 0.12), Mat_Chrome, bev=0.004, group="SteeringWheel", rot=(-tilt, 0.0, 0.0))

build_interior_and_steering()

# ---------------------------------------------------------------------------
# 10. HIGH-DETAIL 20-INCH ALLOY WHEELS, TYRES & EXPOSED BRAKES (Wheel_FL/FR/RL/RR)
# ---------------------------------------------------------------------------
def build_wheel_assembly(group_name, center_pos, side):
    cx, cy, cz = center_pos
    r_tyre = 0.380
    r_rim  = 0.256

    # 1. Contoured Tyre Carcass
    n_circ = 64
    sec_pts = [
        (-0.108, r_rim - 0.005),
        (-0.120, r_rim + 0.025),
        (-0.126, r_rim + 0.064),
        (-0.118, r_tyre - 0.015),
        (-0.094, r_tyre - 0.002),
        (-0.045, r_tyre),
        (-0.038, r_tyre - 0.007),
        (-0.028, r_tyre),
        (-0.005, r_tyre),
        ( 0.000, r_tyre - 0.007),
        ( 0.005, r_tyre),
        ( 0.028, r_tyre),
        ( 0.038, r_tyre - 0.007),
        ( 0.045, r_tyre),
        ( 0.094, r_tyre - 0.002),
        ( 0.118, r_tyre - 0.015),
        ( 0.126, r_rim + 0.064),
        ( 0.120, r_rim + 0.025),
        ( 0.108, r_rim - 0.005),
    ]
    t_verts = []
    t_faces = []
    n_sec = len(sec_pts)
    for i in range(n_circ):
        ang = 2.0 * pi * i / n_circ
        ca, sa = cos(ang), sin(ang)
        for dx, rr in sec_pts:
            t_verts.append((cx + dx, cy + rr * ca, cz + rr * sa))
    for i in range(n_circ):
        i_next = (i + 1) % n_circ
        for j in range(n_sec - 1):
            a = i * n_sec + j
            b = a + 1
            c = i_next * n_sec + j + 1
            d = i_next * n_sec + j
            t_faces.append((a, b, c, d))
    add_mesh_obj(f"{group_name}_Tyre", t_verts, t_faces, Mat_Rubber, group=group_name, smooth=True)

    # 2. 3D Shoulder Tread Blocks
    n_tread = 40
    for i in range(n_tread):
        ang = 2.0 * pi * i / n_tread
        ca, sa = cos(ang), sin(ang)
        for side_t in (-1.0, 1.0):
            bx = cx + side_t * 0.086
            by = cy + (r_tyre - 0.003) * ca
            bz = cz + (r_tyre - 0.003) * sa
            add_box(
                f"{group_name}_Tread_{i}_{side_t}",
                (bx, by, bz),
                (0.056, 0.024, 0.006),
                Mat_Rubber,
                bev=0.001,
                seg=1,
                group=group_name,
                rot=(ang - pi/2, 0.0, 0.0),
            )

    # 3. Deep Inner Rim Barrel Drum placed BEHIND the brake disc (at cx - side * 0.045)
    # so the ventilated steel brake disc and caliper are 100% visible through the twin spokes!
    bpy.ops.mesh.primitive_cylinder_add(
        vertices=48,
        radius=r_rim - 0.006,
        depth=0.110,
        location=(cx - side * 0.045, cy, cz),
        rotation=(0.0, pi/2, 0.0),
    )
    drum = bpy.context.object
    drum.name = f"{group_name}_Inner_Drum"
    drum.data.materials.append(Mat_Plastic)
    for p in drum.data.polygons:
        p.use_smooth = True
    assign_uvs(drum)
    GROUPS[group_name].append(drum)

    # 4. Outer Machined Alloy Rim Flange Ring
    bpy.ops.mesh.primitive_torus_add(
        major_radius=r_rim - 0.004,
        minor_radius=0.013,
        major_segments=48,
        minor_segments=12,
        location=(cx + side * 0.098, cy, cz),
        rotation=(0.0, pi/2, 0.0),
    )
    flange = bpy.context.object
    flange.name = f"{group_name}_Rim_Flange"
    flange.data.materials.append(Mat_Chrome)
    for p in flange.data.polygons:
        p.use_smooth = True
    assign_uvs(flange)
    GROUPS[group_name].append(flange)

    # 5. Twin 5-Spoke (10 Spokes in 5 V-Pairs) Sculpted Two-Tone Alloy Face
    for p_idx in range(5):
        base_ang = 2.0 * pi * p_idx / 5.0
        for sub_sign in (-1.0, 1.0):
            ang_in  = base_ang + sub_sign * math.radians(10.5)
            ang_out = base_ang + sub_sign * math.radians(15.0)
            r_in = 0.056
            r_out = r_rim - 0.008

            y_in, z_in   = cy + r_in * cos(ang_in),   cz + r_in * sin(ang_in)
            y_out, z_out = cy + r_out * cos(ang_out), cz + r_out * sin(ang_out)
            ym, zm = 0.5 * (y_in + y_out), 0.5 * (z_in + z_out)
            spoke_len = math.hypot(y_out - y_in, z_out - z_in)
            spoke_ang = atan2(z_out - z_in, y_out - y_in)

            add_box(
                f"{group_name}_Spoke_Dark_{p_idx}_{sub_sign}",
                (cx + side * 0.088, ym, zm),
                (0.024, spoke_len, 0.032),
                Mat_Plastic,
                bev=0.004,
                seg=2,
                group=group_name,
                rot=(spoke_ang, 0.0, 0.0),
            )
            add_box(
                f"{group_name}_Spoke_Bright_{p_idx}_{sub_sign}",
                (cx + side * 0.098, ym, zm),
                (0.012, spoke_len + 0.008, 0.021),
                Mat_Chrome,
                bev=0.003,
                seg=2,
                group=group_name,
                rot=(spoke_ang, 0.0, 0.0),
            )

        lug_ang = base_ang + math.radians(36.0)
        ly = cy + 0.044 * cos(lug_ang)
        lz = cz + 0.044 * sin(lug_ang)
        add_box(
            f"{group_name}_Lug_{p_idx}",
            (cx + side * 0.095, ly, lz),
            (0.018, 0.016, 0.016),
            Mat_Chrome,
            bev=0.003,
            seg=1,
            group=group_name,
        )

    bpy.ops.mesh.primitive_cylinder_add(
        vertices=32,
        radius=0.065,
        depth=0.036,
        location=(cx + side * 0.084, cy, cz),
        rotation=(0.0, pi/2, 0.0),
    )
    hub = bpy.context.object
    hub.name = f"{group_name}_Center_Hub"
    hub.data.materials.append(Mat_Plastic)
    for p in hub.data.polygons:
        p.use_smooth = True
    assign_uvs(hub)
    GROUPS[group_name].append(hub)

    # Ventilated Steel Brake Disc & Multi-Piston Caliper clearly visible in front of Inner_Drum!
    bpy.ops.mesh.primitive_cylinder_add(
        vertices=40,
        radius=0.190,
        depth=0.024,
        location=(cx + side * 0.048, cy, cz),
        rotation=(0.0, pi/2, 0.0),
    )
    disc = bpy.context.object
    disc.name = f"{group_name}_Brake_Disc"
    disc.data.materials.append(Mat_Chrome)
    for p in disc.data.polygons:
        p.use_smooth = True
    assign_uvs(disc)
    GROUPS[group_name].append(disc)

    cal_y = cy + (0.135 if cy < 0 else -0.135)
    add_box(
        f"{group_name}_Brake_Caliper",
        (cx + side * 0.058, cal_y, cz + 0.035),
        (0.058, 0.088, 0.150),
        Mat_Plastic,
        bev=0.008,
        group=group_name,
    )

WHEEL_CENTERS = {
    "Wheel_FL": ( 0.805, -1.375, 0.380,  1.0),
    "Wheel_FR": (-0.805, -1.375, 0.380, -1.0),
    "Wheel_RL": ( 0.805,  1.375, 0.380,  1.0),
    "Wheel_RR": (-0.805,  1.375, 0.380, -1.0),
}
for wname, (wx, wy, wz, wside) in WHEEL_CENTERS.items():
    build_wheel_assembly(wname, (wx, wy, wz), wside)

add_box("Underbody_Chassis_Tray", (0.0, 0.0, 0.255), (1.42, 3.30, 0.050), Mat_Plastic, bev=0.008)
add_box("Rear_Muffler_Box", (0.0, 1.98, 0.335), (0.85, 0.32, 0.11), Mat_Plastic, bev=0.015)

# ---------------------------------------------------------------------------
# 11. JOIN NAMED MESH GROUPS, SET PIVOTS & EXACT BOUNDING BOX FIT
# ---------------------------------------------------------------------------
PIVOTS = {
    "Body":          Vector((0.0, 0.0, 0.0)),
    "Wheel_FL":      Vector(( 0.805, -1.375, 0.380)),
    "Wheel_FR":      Vector((-0.805, -1.375, 0.380)),
    "Wheel_RL":      Vector(( 0.805,  1.375, 0.380)),
    "Wheel_RR":      Vector((-0.805,  1.375, 0.380)),
    "SteeringWheel": Vector(( 0.420, -0.380, 0.980)),
}

JOINED_OBJS = {}
for gname, obj_list in GROUPS.items():
    bpy.ops.object.select_all(action='DESELECT')
    for o in obj_list:
        o.select_set(True)
    bpy.context.view_layer.objects.active = obj_list[0]
    bpy.ops.object.join()
    joined = bpy.context.object
    joined.name = gname
    joined.data.name = gname

    mod_tri = joined.modifiers.new("Triangulate", 'TRIANGULATE')
    mod_tri.keep_custom_normals = True
    bpy.ops.object.modifier_apply(modifier=mod_tri.name)

    S.cursor.location = PIVOTS[gname]
    bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
    bpy.ops.object.transform_apply(location=(gname == "Body"), rotation=True, scale=True)
    JOINED_OBJS[gname] = joined

for wname in ("Wheel_FL", "Wheel_FR", "Wheel_RL", "Wheel_RR"):
    wo = JOINED_OBJS[wname]
    max_ry = max(abs(v.co.y) for v in wo.data.vertices)
    max_rz = max(abs(v.co.z) for v in wo.data.vertices)
    for v in wo.data.vertices:
        v.co.y *= 0.380 / max_ry
        v.co.z *= 0.380 / max_rz
    wo.data.update()

body_obj = JOINED_OBJS["Body"]
b_verts_world = [body_obj.matrix_world @ v.co for v in body_obj.data.vertices]
min_x = min(p.x for p in b_verts_world)
max_x = max(p.x for p in b_verts_world)
min_y = min(p.y for p in b_verts_world)
max_y = max(p.y for p in b_verts_world)
max_z = max(p.z for p in b_verts_world)

sx = 0.940 / max(abs(min_x), abs(max_x))
for v in body_obj.data.vertices:
    v.co.x *= sx
    if v.co.y < -1.80:
        v.co.y = -1.80 + (v.co.y + 1.80) * ((-2.350 + 1.80) / (min_y + 1.80))
    elif v.co.y > 1.80:
        v.co.y = 1.80 + (v.co.y - 1.80) * ((2.350 - 1.80) / (max_y - 1.80))
    if v.co.z > 1.50:
        v.co.z = 1.50 + (v.co.z - 1.50) * ((1.680 - 1.50) / (max_z - 1.50))
body_obj.data.update()

# ---------------------------------------------------------------------------
# 12. EMPTY SOCKET NODES (Forge Garage v2 Contract)
# ---------------------------------------------------------------------------
EMPTY_SOCKETS = {
    "Seat_Driver":  ( 0.420, -0.040, 0.640),
    "Door_L":       ( 1.020, -0.100, 0.360),
    "Door_R":       (-1.020, -0.100, 0.360),
    "Exhaust":      (-0.580,  2.325, 0.365),
    "Light_Head_L": ( 0.650, -2.235, 0.852),
    "Light_Head_R": (-0.650, -2.235, 0.852),
    "Light_Tail_L": ( 0.620,  2.255, 1.032),
    "Light_Tail_R": (-0.620,  2.255, 1.032),
}

EMPTY_OBJS = {}
for ename, epos in EMPTY_SOCKETS.items():
    eobj = bpy.data.objects.new(ename, None)
    bpy.context.collection.objects.link(eobj)
    eobj.location = epos
    eobj.empty_display_size = 0.08
    EMPTY_OBJS[ename] = eobj

ALL_ASSET_OBJS = list(JOINED_OBJS.values()) + list(EMPTY_OBJS.values())

# ---------------------------------------------------------------------------
# 13. GLB EXPORT & BINARY CONTRACT PATCH (Paint baseColorFactor + Clearcoat)
# ---------------------------------------------------------------------------
bpy.ops.object.select_all(action='DESELECT')
for o in ALL_ASSET_OBJS:
    o.select_set(True)
bpy.context.view_layer.objects.active = JOINED_OBJS["Body"]

glb_path = os.path.join(ROOT, "family-suv-g4.glb")
bpy.ops.export_scene.gltf(
    filepath=glb_path,
    export_format='GLB',
    use_selection=True,
    export_apply=True,
    export_yup=True,
    export_image_format='AUTO',
    export_materials='EXPORT',
    export_cameras=False,
    export_lights=False,
)

raw_glb = open(glb_path, "rb").read()
j_len, j_type = struct.unpack_from("<II", raw_glb, 12)
gltf_doc = json.loads(raw_glb[20:20 + j_len])
bin_chunk = raw_glb[20 + j_len:]

ext_used = set(gltf_doc.get("extensionsUsed", []))
ext_used.add("KHR_materials_clearcoat")
gltf_doc["extensionsUsed"] = sorted(ext_used)

for m in gltf_doc.get("materials", []):
    if m.get("name") == "Paint":
        pbr = m.setdefault("pbrMetallicRoughness", {})
        pbr["baseColorFactor"] = [0.62, 0.63, 0.64, 1.0]
        m.setdefault("extensions", {})["KHR_materials_clearcoat"] = {
            "clearcoatFactor": 1.0,
            "clearcoatRoughnessFactor": 0.05,
        }
    elif m.get("name") == "Glass":
        m["alphaMode"] = "BLEND"
        m["doubleSided"] = True

json_bytes = json.dumps(gltf_doc, separators=(",", ":")).encode("utf-8")
json_bytes += b" " * ((-len(json_bytes)) % 4)
total_len = 20 + len(json_bytes) + len(bin_chunk)
with open(glb_path, "wb") as f:
    f.write(struct.pack("<III", 0x46546C67, 2, total_len))
    f.write(struct.pack("<II", len(json_bytes), 0x4E4F534A))
    f.write(json_bytes)
    f.write(bin_chunk)

# ---------------------------------------------------------------------------
# 14. WRITE nodes.json & NOTES.md
# ---------------------------------------------------------------------------
def to_gltf_coords(v):
    return [round(float(v[0]), 4), round(float(v[2]), 4), round(float(-v[1]), 4)]

all_world_verts = [
    o.matrix_world @ v.co
    for o in JOINED_OBJS.values()
    for v in o.data.vertices
]
gltf_pts = [to_gltf_coords(p) for p in all_world_verts]
bb_min = [round(min(p[i] for p in gltf_pts), 4) for i in range(3)]
bb_max = [round(max(p[i] for p in gltf_pts), 4) for i in range(3)]
bb_size = [round(bb_max[i] - bb_min[i], 4) for i in range(3)]
total_tris = sum(len(o.data.polygons) for o in JOINED_OBJS.values())
glb_size_mb = os.path.getsize(glb_path) / (1024.0 * 1024.0)

nodes_manifest = {
    "contract": "Forge Garage v2",
    "units": "metres",
    "front": "+Z",
    "up": "+Y",
    "left": "+X",
    "nodes": {
        o.name: {
            "world_position": to_gltf_coords(o.matrix_world.translation),
            "type": "MESH" if o.type == 'MESH' else "EMPTY",
        }
        for o in ALL_ASSET_OBJS
    },
    "bounding_box": {
        "min": bb_min,
        "max": bb_max,
        "size": bb_size,
    },
    "wheelbase": 2.75,
    "triangles": total_tris,
    "materials": [m["name"] for m in gltf_doc.get("materials", [])],
    "paints": [
        {"name": pname, "linear_rgb": prgb}
        for pname, prgb in PAINTS
    ],
}

with open(os.path.join(ROOT, "nodes.json"), "w") as f:
    json.dump(nodes_manifest, f, indent=2)

notes_lines = [
    "Modern five-door mid-size SUV crossover with roof rails, plastic wheel-arch cladding, 20-inch twin-spoke alloys, full 5-seat interior and Forge Garage v2 nodes.",
    f"{total_tris:,} triangles ({glb_size_mb:.2f} MB GLB), bounding box {bb_size[0]:.2f} m x {bb_size[1]:.2f} m x {bb_size[2]:.2f} m, wheelbase 2.75 m.",
    "7 materials: Paint (neutral baked texture + baseColorFactor [0.62, 0.63, 0.64] + KHR_materials_clearcoat), Rubber, Chrome, Plastic, Interior, Glass (BLEND), Lamps (emissive).",
    "Texture sizes: Paint BaseColor/Normal 1024x1024, Paint ORM 512x512; Rubber/Chrome/Plastic/Interior BaseColor/Normal/ORM 512x512; Lamps 256x256.",
    "Poly Haven CC0 ids used: metal_plate, metal_plate_02, rusty_metal_03, rubberized_track, fabric_leather_02 (https://polyhaven.com/license).",
    "Differences from reference: original brand-free 3-bar grille and dual-L LED headlamp signature; simplified interior controls.",
]
with open(os.path.join(ROOT, "NOTES.md"), "w") as f:
    f.write("\n".join(notes_lines) + "\n")

print(f"[EXPORT DONE] tris={total_tris}, size={glb_size_mb:.2f}MB, bbox={bb_size}", flush=True)

# ---------------------------------------------------------------------------
# 15. STUDIO CYCLORAMA & RENDERS (final.png, side.png, clay.png, paints.png)
# ---------------------------------------------------------------------------
p_nodes = Mat_Paint.node_tree.nodes
p_links = Mat_Paint.node_tree.links
p_bsdf = p_nodes["Principled BSDF"]
bc_link = p_bsdf.inputs["Base Color"].links[0]
bc_src = bc_link.from_socket
p_links.remove(bc_link)

paint_mul = p_nodes.new("ShaderNodeMixRGB")
paint_mul.blend_type = 'MULTIPLY'
paint_mul.inputs["Fac"].default_value = 1.0
p_links.new(bc_src, paint_mul.inputs["Color1"])
paint_mul.inputs["Color2"].default_value = (*PAINTS[0][1], 1.0)
p_links.new(paint_mul.outputs["Color"], p_bsdf.inputs["Base Color"])

Mat_Ground = bpy.data.materials.new("StudioGround")
Mat_Ground.use_nodes = True
g_bsdf2 = Mat_Ground.node_tree.nodes["Principled BSDF"]
g_bsdf2.inputs["Base Color"].default_value = (0.62, 0.63, 0.64, 1.0)
g_bsdf2.inputs["Roughness"].default_value = 0.85
g_bsdf2.inputs["Specular IOR Level"].default_value = 0.20

bpy.ops.mesh.primitive_plane_add(size=240.0, location=(0.0, 0.0, 0.0))
ground_obj = bpy.context.object
ground_obj.name = "StudioGroundPlane"
ground_obj.data.materials.append(Mat_Ground)

def add_area_light(name, loc, target, energy, size, color=(1.0, 0.98, 0.95)):
    ldata = bpy.data.lights.new(name, 'AREA')
    ldata.energy = energy
    ldata.shape = 'RECTANGLE'
    ldata.size = size[0]
    ldata.size_y = size[1]
    ldata.color = color
    lobj = bpy.data.objects.new(name, ldata)
    bpy.context.collection.objects.link(lobj)
    lobj.location = loc
    lobj.rotation_euler = (Vector(target) - Vector(loc)).to_track_quat('-Z', 'Y').to_euler()
    return lobj

# Studio ribbon softboxes for crisp automotive body-line highlights & rich gunmetal contrast
add_area_light("Key_Overhead_Softbox", ( 3.2, -3.5, 5.2), (0.0, 0.0, 0.8), 820.0, (8.0, 4.0), (1.0, 0.98, 0.94))
add_area_light("Fill_Left_Side",       ( 6.0, -1.0, 1.8), (0.0, 0.0, 0.7), 360.0, (6.0, 2.2), (0.94, 0.97, 1.0))
add_area_light("Rim_Back_Right",       (-4.5,  3.8, 3.8), (0.0, 0.0, 0.8), 460.0, (5.5, 3.0), (0.92, 0.96, 1.0))
add_area_light("Front_Fascia_Fill",    ( 0.0, -6.5, 1.2), (0.0, -2.0, 0.7), 260.0, (4.5, 1.8), (1.0, 0.98, 0.96))

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

def set_camera(loc, target, lens=50.0, ortho_scale=None):
    cam_obj.location = loc
    cam_obj.rotation_euler = (Vector(target) - Vector(loc)).to_track_quat('-Z', 'Y').to_euler()
    if ortho_scale:
        cam_data.type = 'ORTHO'
        cam_data.ortho_scale = ortho_scale
    else:
        cam_data.type = 'PERSP'
        cam_data.lens = lens

# 1. final.png (1920x1080 Cycles render, 3/4 front view)
S.render.resolution_x = 1920
S.render.resolution_y = 1080
S.cycles.samples = 48
set_camera((4.45, -5.55, 1.82), (0.0, 0.02, 0.76), lens=50.0)
S.render.filepath = os.path.join(ROOT, "final.png")
bpy.ops.render.render(write_still=True)

# 2. side.png (orthographic side view matching sheet.jpg main profile)
ground_obj.hide_render = True
w_bg.inputs["Color"].default_value = (0.58, 0.60, 0.62, 1.0)
S.cycles.samples = 36
set_camera((7.5, 0.0, 0.84), (0.0, 0.0, 0.84), ortho_scale=5.45)
S.render.filepath = os.path.join(ROOT, "side.png")
bpy.ops.render.render(write_still=True)
ground_obj.hide_render = False
w_bg.inputs["Color"].default_value = (0.18, 0.20, 0.23, 1.0)

# 3. clay.png (same 3/4 view as final.png, plain grey clay override material)
Mat_Clay = bpy.data.materials.new("ClayGrey")
Mat_Clay.use_nodes = True
c_bsdf = Mat_Clay.node_tree.nodes["Principled BSDF"]
c_bsdf.inputs["Base Color"].default_value = (0.38, 0.39, 0.40, 1.0)
c_bsdf.inputs["Roughness"].default_value = 0.52
c_bsdf.inputs["Specular IOR Level"].default_value = 0.30

S.view_layers[0].material_override = Mat_Clay
set_camera((4.45, -5.55, 1.82), (0.0, 0.02, 0.76), lens=50.0)
S.cycles.samples = 36
S.render.filepath = os.path.join(ROOT, "clay.png")
bpy.ops.render.render(write_still=True)
S.view_layers[0].material_override = None

# 4. paints.png (3x2 grid of the 6 paints in the same 3/4 view)
S.render.resolution_x = 640
S.render.resolution_y = 360
S.cycles.samples = 24
tile_imgs = []
for idx, (pname, prgb) in enumerate(PAINTS):
    paint_mul.inputs["Color2"].default_value = (*prgb, 1.0)
    tpath = os.path.join(ROOT, f"paint_{idx}.png")
    S.render.filepath = tpath
    bpy.ops.render.render(write_still=True)
    tile_imgs.append(bpy.data.images.load(tpath))

grid_w, grid_h = 1920, 720
grid_img = bpy.data.images.new("PaintsGrid", width=grid_w, height=grid_h, alpha=True)
grid_px = [0.0] * (grid_w * grid_h * 4)

for idx, timg in enumerate(tile_imgs):
    col_i = idx % 3
    row_i = 1 - (idx // 3)
    src_px = list(timg.pixels)
    for y in range(360):
        dst_y = row_i * 360 + y
        dst_start = (dst_y * grid_w + col_i * 640) * 4
        src_start = (y * 640) * 4
        grid_px[dst_start:dst_start + 640 * 4] = src_px[src_start:src_start + 640 * 4]

grid_img.pixels = grid_px
grid_img.filepath_raw = os.path.join(ROOT, "paints.png")
grid_img.file_format = 'PNG'
grid_img.save()

for idx in range(len(PAINTS)):
    tpath = os.path.join(ROOT, f"paint_{idx}.png")
    if os.path.exists(tpath):
        os.remove(tpath)

print("[ALL RENDERS COMPLETE]", flush=True)
