"""
Tandem-Axle Flatbed Frame Trailer — Blender procedural model

使用方法：
1. 打开 Blender > Scripting > New。
2. 打开或粘贴本文件，点击 Run Script。
3. 模型会生成在 FLATBED_TRAILER_MODEL 集合中。

当前比例依据单张参考图目测。收到实测数据后，可直接修改下方参数。
"""

import bpy
import math
from mathutils import Vector


MODEL_SCALE = 1.0
BED_LENGTH = 4.40 * MODEL_SCALE
BED_WIDTH = 2.05 * MODEL_SCALE
DECK_HEIGHT = 0.74 * MODEL_SCALE
HITCH_X = -3.65 * MODEL_SCALE
WHEEL_RADIUS = 0.36 * MODEL_SCALE
WHEEL_WIDTH = 0.18 * MODEL_SCALE
AXLE_POSITIONS = (0.82 * MODEL_SCALE, 1.55 * MODEL_SCALE)


def clear_scene():
    bpy.ops.object.select_all(action='SELECT')
    bpy.ops.object.delete(use_global=False)
    for datablocks in (bpy.data.meshes, bpy.data.curves, bpy.data.materials, bpy.data.cameras, bpy.data.lights):
        for block in list(datablocks):
            if block.users == 0:
                datablocks.remove(block)


def make_material(name, base_color, metallic=0.0, roughness=0.5, emission=None):
    material = bpy.data.materials.new(name)
    material.diffuse_color = (*base_color, 1.0)
    material.use_nodes = True
    bsdf = next(node for node in material.node_tree.nodes if node.type == 'BSDF_PRINCIPLED')
    bsdf.inputs['Base Color'].default_value = (*base_color, 1.0)
    bsdf.inputs['Metallic'].default_value = metallic
    bsdf.inputs['Roughness'].default_value = roughness
    if emission:
        bsdf.inputs['Emission Color'].default_value = (*emission, 1.0)
        bsdf.inputs['Emission Strength'].default_value = 1.25
    return material


def move_to_collection(obj, collection):
    for source in list(obj.users_collection):
        source.objects.unlink(obj)
    collection.objects.link(obj)


def box(name, size, location, material, collection, bevel=0.012):
    bpy.ops.mesh.primitive_cube_add(location=location)
    obj = bpy.context.object
    obj.name = name
    obj.dimensions = size
    bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
    obj.data.materials.append(material)
    if bevel > 0:
        modifier = obj.modifiers.new('Rounded steel edge', 'BEVEL')
        modifier.width = bevel * MODEL_SCALE
        modifier.segments = 2
    move_to_collection(obj, collection)
    return obj


def cylinder(name, radius, depth, location, rotation, material, collection, vertices=36):
    bpy.ops.mesh.primitive_cylinder_add(
        vertices=vertices,
        radius=radius,
        depth=depth,
        location=location,
        rotation=rotation,
    )
    obj = bpy.context.object
    obj.name = name
    obj.data.materials.append(material)
    move_to_collection(obj, collection)
    for poly in obj.data.polygons:
        poly.use_smooth = True
    modifier = obj.modifiers.new('Edge bevel', 'BEVEL')
    modifier.width = 0.007 * MODEL_SCALE
    modifier.segments = 2
    return obj


def beam_between(name, start, end, thickness, material, collection, vertical_size=None):
    start = Vector(start)
    end = Vector(end)
    direction = end - start
    height = vertical_size if vertical_size is not None else thickness
    obj = box(name, (direction.length, thickness, height), (start + end) / 2, material, collection, bevel=0.01)
    obj.rotation_mode = 'QUATERNION'
    obj.rotation_quaternion = direction.to_track_quat('X', 'Z')
    return obj


def torus(name, major_radius, minor_radius, location, rotation, material, collection):
    bpy.ops.mesh.primitive_torus_add(
        major_radius=major_radius,
        minor_radius=minor_radius,
        major_segments=28,
        minor_segments=8,
        location=location,
        rotation=rotation,
    )
    obj = bpy.context.object
    obj.name = name
    obj.data.materials.append(material)
    move_to_collection(obj, collection)
    for poly in obj.data.polygons:
        poly.use_smooth = True
    return obj


def build_flatbed_trailer():
    clear_scene()
    model_collection = bpy.data.collections.new('FLATBED_TRAILER_MODEL')
    preview_collection = bpy.data.collections.new('_PREVIEW')
    bpy.context.scene.collection.children.link(model_collection)
    bpy.context.scene.collection.children.link(preview_collection)

    frame_black = make_material('Black Powder-Coated Steel', (0.018, 0.025, 0.027), metallic=0.58, roughness=0.32)
    frame_edge = make_material('Exposed Steel Edge', (0.17, 0.20, 0.20), metallic=0.76, roughness=0.30)
    galvanized = make_material('Galvanized Hardware', (0.56, 0.62, 0.62), metallic=0.82, roughness=0.25)
    tire_material = make_material('Tire Rubber', (0.012, 0.017, 0.018), roughness=0.90)
    tread_material = make_material('Tire Tread', (0.045, 0.055, 0.058), roughness=0.98)
    red_material = make_material('Tail Light Red', (0.48, 0.012, 0.018), roughness=0.22, emission=(0.30, 0.002, 0.003))
    red_handle = make_material('Coupler Handle Red', (0.46, 0.025, 0.018), metallic=0.25, roughness=0.36)

    half_length = BED_LENGTH / 2
    half_width = BED_WIDTH / 2
    rail_height = 0.13 * MODEL_SCALE
    rail_width = 0.12 * MODEL_SCALE

    # Open flatbed perimeter and longitudinal rails.
    for y in (-half_width, half_width):
        box('Outer Perimeter Rail', (BED_LENGTH, rail_width, rail_height), (0, y, DECK_HEIGHT), frame_black, model_collection)
        box('Silver Wear Edge', (BED_LENGTH, 0.018, 0.025), (0, y + math.copysign(0.061, y), DECK_HEIGHT + 0.065), frame_edge, model_collection, bevel=0.003)
    for x in (-half_length, half_length):
        box('End Perimeter Rail', (rail_width, BED_WIDTH, rail_height), (x, 0, DECK_HEIGHT), frame_black, model_collection)

    for y in (-0.51, 0, 0.51):
        box('Longitudinal Deck Rail', (BED_LENGTH - 0.12, 0.085, 0.10), (0, y * MODEL_SCALE, DECK_HEIGHT - 0.005), frame_black, model_collection)

    # Dense transverse deck grid visible in the reference.
    x = -half_length + 0.20 * MODEL_SCALE
    index = 0
    while x < half_length - 0.12 * MODEL_SCALE:
        material = frame_edge if index % 4 == 0 else frame_black
        width = 0.075 if index % 4 == 0 else 0.060
        box('Deck Crossmember', (width * MODEL_SCALE, BED_WIDTH - 0.14, 0.075), (x, 0, DECK_HEIGHT + 0.015), material, model_collection, bevel=0.006)
        x += 0.36 * MODEL_SCALE
        index += 1

    # Reinforced underframe and diagonal gussets.
    for y in (-0.47, 0.47):
        box('Underframe Main Rail', (BED_LENGTH - 0.35, 0.13, 0.18), (0.10, y * MODEL_SCALE, DECK_HEIGHT - 0.16), frame_black, model_collection)
    for x in (-1.72, -0.72, 0.28, 1.28, 1.95):
        box('Underframe Cross Brace', (0.11, BED_WIDTH - 0.34, 0.13), (x * MODEL_SCALE, 0, DECK_HEIGHT - 0.16), frame_black, model_collection)
    beam_between('Front Left Gusset', (-half_length, -0.90 * MODEL_SCALE, DECK_HEIGHT - 0.10), (-1.23 * MODEL_SCALE, -0.47 * MODEL_SCALE, DECK_HEIGHT - 0.17), 0.075, frame_black, model_collection)
    beam_between('Front Right Gusset', (-half_length, 0.90 * MODEL_SCALE, DECK_HEIGHT - 0.10), (-1.23 * MODEL_SCALE, 0.47 * MODEL_SCALE, DECK_HEIGHT - 0.17), 0.075, frame_black, model_collection)

    # Tandem axles, suspension beams, exposed wheels and hubs.
    for axle_x in AXLE_POSITIONS:
        cylinder('Axle Tube', 0.052 * MODEL_SCALE, 2.32 * MODEL_SCALE, (axle_x, 0, 0.43 * MODEL_SCALE), (math.pi / 2, 0, 0), galvanized, model_collection, 24)
        for side in (-1, 1):
            wheel_y = side * 1.14 * MODEL_SCALE
            cylinder('Road Tire', WHEEL_RADIUS, WHEEL_WIDTH, (axle_x, wheel_y, 0.39 * MODEL_SCALE), (math.pi / 2, 0, 0), tire_material, model_collection, 48)
            cylinder('Silver Wheel Rim', 0.20 * MODEL_SCALE, WHEEL_WIDTH + 0.018 * MODEL_SCALE, (axle_x, wheel_y, 0.39 * MODEL_SCALE), (math.pi / 2, 0, 0), galvanized, model_collection, 30)
            cylinder('Black Hub Cap', 0.072 * MODEL_SCALE, WHEEL_WIDTH + 0.03 * MODEL_SCALE, (axle_x, wheel_y, 0.39 * MODEL_SCALE), (math.pi / 2, 0, 0), frame_black, model_collection, 20)
            for tread_index in range(12):
                angle = tread_index * math.tau / 12
                tread = box(
                    'Tire Tread Block',
                    (0.04, WHEEL_WIDTH + 0.025, 0.034),
                    (axle_x + math.cos(angle) * WHEEL_RADIUS * 0.95, wheel_y, 0.39 * MODEL_SCALE + math.sin(angle) * WHEEL_RADIUS * 0.95),
                    tread_material,
                    model_collection,
                    bevel=0.003,
                )
                tread.rotation_euler.y = -angle

    for side in (-1, 1):
        y = side * 0.64 * MODEL_SCALE
        box('Leaf Spring', (1.24, 0.055, 0.06), (1.18 * MODEL_SCALE, y, 0.48 * MODEL_SCALE), galvanized, model_collection, bevel=0.01)
        for axle_x in AXLE_POSITIONS:
            box('Axle Hanger', (0.12, 0.09, 0.22), (axle_x, y, 0.56 * MODEL_SCALE), frame_black, model_collection)

    # A-frame tongue and coupler.
    hitch_point = (HITCH_X, 0, 0.52 * MODEL_SCALE)
    beam_between('A-frame Left', (-half_length, -0.82 * MODEL_SCALE, DECK_HEIGHT - 0.10), hitch_point, 0.13 * MODEL_SCALE, frame_black, model_collection, vertical_size=0.16 * MODEL_SCALE)
    beam_between('A-frame Right', (-half_length, 0.82 * MODEL_SCALE, DECK_HEIGHT - 0.10), hitch_point, 0.13 * MODEL_SCALE, frame_black, model_collection, vertical_size=0.16 * MODEL_SCALE)
    beam_between('Centre Drawbar', (-half_length, 0, DECK_HEIGHT - 0.12), (-3.48 * MODEL_SCALE, 0, 0.55 * MODEL_SCALE), 0.12 * MODEL_SCALE, frame_black, model_collection, vertical_size=0.15 * MODEL_SCALE)
    box('Coupler Body', (0.32, 0.19, 0.14), (-3.63 * MODEL_SCALE, 0, 0.52 * MODEL_SCALE), galvanized, model_collection)
    bpy.ops.mesh.primitive_uv_sphere_add(segments=24, ring_count=12, location=(-3.82 * MODEL_SCALE, 0, 0.52 * MODEL_SCALE))
    coupler = bpy.context.object
    coupler.name = 'Coupler Head'
    coupler.scale = (0.15 * MODEL_SCALE, 0.10 * MODEL_SCALE, 0.078 * MODEL_SCALE)
    coupler.data.materials.append(galvanized)
    move_to_collection(coupler, model_collection)
    box('Red Coupler Handle', (0.18, 0.055, 0.065), (-3.62 * MODEL_SCALE, -0.03, 0.68 * MODEL_SCALE), red_handle, model_collection, bevel=0.02)

    # Front winding jack with paired jockey wheels.
    jack_x = -2.78 * MODEL_SCALE
    jack_y = -0.38 * MODEL_SCALE
    box('Jockey Jack Post', (0.09, 0.09, 0.83), (jack_x, jack_y, 0.43 * MODEL_SCALE), galvanized, model_collection)
    box('Jockey Jack Clamp', (0.20, 0.14, 0.12), (jack_x, jack_y, 0.66 * MODEL_SCALE), frame_edge, model_collection)
    for wheel_offset in (-0.065, 0.065):
        cylinder('Jockey Twin Wheel', 0.15 * MODEL_SCALE, 0.065 * MODEL_SCALE, (jack_x, jack_y + wheel_offset * MODEL_SCALE, 0.14 * MODEL_SCALE), (math.pi / 2, 0, 0), tire_material, model_collection, 28)
        cylinder('Jockey Wheel Hub', 0.065 * MODEL_SCALE, 0.073 * MODEL_SCALE, (jack_x, jack_y + wheel_offset * MODEL_SCALE, 0.14 * MODEL_SCALE), (math.pi / 2, 0, 0), galvanized, model_collection, 20)
    box('Jockey Wheel Fork', (0.20, 0.21, 0.09), (jack_x, jack_y, 0.28 * MODEL_SCALE), galvanized, model_collection)
    box('Winding Crank', (0.08, 0.34, 0.07), (jack_x, jack_y, 0.88 * MODEL_SCALE), frame_edge, model_collection)

    # Two rear stabilizer legs with square feet.
    for side in (-1, 1):
        y = side * 0.88 * MODEL_SCALE
        box('Rear Drop Leg Outer', (0.12, 0.12, 0.62), (1.98 * MODEL_SCALE, y, 0.48 * MODEL_SCALE), frame_black, model_collection)
        box('Rear Drop Leg Inner', (0.07, 0.07, 0.56), (1.98 * MODEL_SCALE, y, 0.18 * MODEL_SCALE), galvanized, model_collection)
        box('Rear Stabilizer Foot', (0.27, 0.22, 0.045), (1.98 * MODEL_SCALE, y, -0.03 * MODEL_SCALE), galvanized, model_collection)
        box('Leg Lock Pin', (0.04, 0.22, 0.04), (1.98 * MODEL_SCALE, y, 0.58 * MODEL_SCALE), galvanized, model_collection, bevel=0.004)

    # Safety chains and rear lights.
    for side in (-1, 1):
        for link_index in range(9):
            progress = link_index / 8
            location = (
                (-3.02 - progress * 0.70) * MODEL_SCALE,
                side * (0.14 - progress * 0.08) * MODEL_SCALE,
                (0.48 - math.sin(progress * math.pi) * 0.24) * MODEL_SCALE,
            )
            rotation = (math.pi / 2 if link_index % 2 else 0, 0, 0)
            link = torus('Safety Chain Link', 0.052 * MODEL_SCALE, 0.012 * MODEL_SCALE, location, rotation, galvanized, model_collection)
            link.scale.x = 1.25
            link.scale.z = 0.72

        box('Rear Red Lamp', (0.07, 0.16, 0.11), (half_length + 0.07, side * 0.78 * MODEL_SCALE, DECK_HEIGHT), red_material, model_collection, bevel=0.012)

    # Preview-only studio floor, lights and camera.
    floor_material = make_material('Preview Floor', (0.57, 0.61, 0.61), roughness=0.96)
    cylinder('Preview Floor', 6.4 * MODEL_SCALE, 0.025 * MODEL_SCALE, (0, 0, -0.08), (0, 0, 0), floor_material, preview_collection, 96)

    world = bpy.context.scene.world or bpy.data.worlds.new('Flatbed Studio World')
    bpy.context.scene.world = world
    world.use_nodes = True
    world.node_tree.nodes['Background'].inputs['Color'].default_value = (0.74, 0.78, 0.79, 1)
    world.node_tree.nodes['Background'].inputs['Strength'].default_value = 0.72

    def add_area(name, location, energy, size, color):
        data = bpy.data.lights.new(name, 'AREA')
        data.energy = energy
        data.shape = 'DISK'
        data.size = size
        data.color = color
        obj = bpy.data.objects.new(name, data)
        obj.location = location
        obj.rotation_euler = ((Vector((0, 0, 0.75)) - obj.location).to_track_quat('-Z', 'Y')).to_euler()
        preview_collection.objects.link(obj)

    add_area('Key Light', (-4.2, -4.8, 7.5), 1350, 4.8, (1.0, 0.97, 0.92))
    add_area('Edge Light', (5.0, 3.2, 4.4), 850, 3.6, (0.72, 0.87, 1.0))

    camera_data = bpy.data.cameras.new('Flatbed Trailer Camera')
    camera = bpy.data.objects.new('Flatbed Trailer Camera', camera_data)
    camera.location = (-6.2 * MODEL_SCALE, -5.8 * MODEL_SCALE, 4.0 * MODEL_SCALE)
    camera.rotation_euler = ((Vector((-0.15, 0, 0.72)) - camera.location).to_track_quat('-Z', 'Y')).to_euler()
    camera_data.lens = 55
    preview_collection.objects.link(camera)
    bpy.context.scene.camera = camera

    scene = bpy.context.scene
    scene.render.engine = 'BLENDER_EEVEE'
    scene.render.resolution_x = 1600
    scene.render.resolution_y = 1000
    scene.render.resolution_percentage = 60
    scene.render.image_settings.file_format = 'PNG'
    scene.view_settings.look = 'AgX - Medium High Contrast'

    bpy.ops.object.select_all(action='DESELECT')
    for obj in model_collection.objects:
        obj.select_set(True)
    if model_collection.objects:
        bpy.context.view_layer.objects.active = model_collection.objects[0]

    print('Flatbed frame trailer generated in FLATBED_TRAILER_MODEL collection.')
    print('Edit the parameters at the top when measured dimensions are available.')


if __name__ == '__main__':
    build_flatbed_trailer()

