# build_trailer.py — v3 — construction-true parametric trailer
# EDIT THE PARAMS BLOCK AND RE-RUN. All values in millimetres.
# v3 (Anthony 15 Aug, fixes 8-11): A-frame joins 200 ahead of nose then 400
# straight hitch; jockey on RIGHT A-member; rear doors 900+600 with RV paddle
# handles at 1200; 25mm stainless trim; twin LED light bars under doors, inset 100.
import bpy, math, mathutils

# ============ PARAMS — change these ============
P = {
    "body_len": 2000, "body_wid": 1500, "body_hgt": 1500,
    "nose_flat": 500, "nose_depth": 430,
    "rail_w": 100, "rail_h": 50, "n_cross": 3,
    "panel_t": 2, "turn_edge": 20,
    "wheel_dia": 778, "wheel_w": 265,
    "axle_from_rear": 800, "axle_below_base": 100,
    "aframe_join": 200,     # A members meet this far ahead of the nose tip
    "hitch_len": 400,       # straight section from the join to the coupler
    "fender_top_len": 560, "fender_drop": 380, "fender_angle": 60, "fender_wid": 300,
    "door_w_L": 900, "door_w_R": 600,   # rear door split (off-centre)
    "handle_h": 700,        # RV paddle handle height above the floor (Anthony: 700, was 1200 too high)
    "trim": 25,             # stainless trim width
    "trim_t": 3,            # trim proudness — flat strip ON the surface (frame is INSIDE)
    "belt_h": 600,          # horizontal belt trim height above floor
    "light_w": 350, "light_h": 100, "light_inset": 100,
    "solar_len": 1600, "solar_wid": 1000, "solar_t": 35,
}
# ===============================================

MM = 0.001
def mm(v): return v * MM

root = bpy.data.objects.get("param_trailer_root")
if root:
    for o in list(bpy.data.objects):
        if o.parent == root or o is root:
            bpy.data.objects.remove(o, do_unlink=True)
rodin = bpy.data.objects.get("model")
if rodin:
    rodin.location.x = -3.0
    rodin.hide_set(True); rodin.hide_render = True
cube = bpy.data.objects.get("Cube")
if cube: cube.hide_render = True; cube.hide_set(True)

def mat(name, color, metallic, rough, emit=None):
    m = bpy.data.materials.get(name)
    if not m:
        m = bpy.data.materials.new(name); m.use_nodes = True
    b = m.node_tree.nodes.get("Principled BSDF")
    b.inputs["Base Color"].default_value = (*color, 1)
    b.inputs["Metallic"].default_value = metallic
    b.inputs["Roughness"].default_value = rough
    if emit:
        try:
            b.inputs["Emission Color"].default_value = (*emit, 1)
            b.inputs["Emission Strength"].default_value = 0.8
        except KeyError:
            pass
    return m
M_STEEL = mat("steel_galv", (0.42, 0.44, 0.46), 1.0, 0.55)
M_ALU   = mat("alu_brushed", (0.75, 0.76, 0.78), 1.0, 0.35)
M_SS    = mat("stainless_trim", (0.9, 0.91, 0.92), 1.0, 0.12)
M_BLACK = mat("steel_black", (0.02, 0.02, 0.02), 0.6, 0.45)
M_RUB   = mat("rubber", (0.03, 0.03, 0.03), 0.0, 0.9)
M_SOLAR = mat("solar", (0.05, 0.06, 0.1), 0.2, 0.25)
M_AMBER = mat("lens_amber", (1.0, 0.45, 0.02), 0.0, 0.2, emit=(1.0, 0.45, 0.02))
M_RED   = mat("lens_red",   (0.9, 0.02, 0.02), 0.0, 0.2, emit=(0.9, 0.02, 0.02))
M_WHITE = mat("lens_white", (0.95, 0.95, 0.95), 0.0, 0.2, emit=(0.9, 0.9, 0.9))

bpy.ops.object.empty_add(location=(0, 0, 0))
root = bpy.context.active_object; root.name = "param_trailer_root"
made = []

def register(o, material):
    o.data.materials.append(material)
    o.parent = root; o.show_wire = True
    made.append(o.name)

def box(name, sx, sy, sz, x, y, z, material, rz=0.0):
    bpy.ops.mesh.primitive_cube_add(size=1, location=(x, y, z))
    o = bpy.context.active_object; o.name = name
    o.scale = (sx, sy, sz)
    if rz: o.rotation_euler[2] = rz
    bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
    register(o, material)
    return o

def cyl(name, r, depth, x, y, z, material):
    bpy.ops.mesh.primitive_cylinder_add(radius=r, depth=depth, location=(x, y, z), vertices=48)
    o = bpy.context.active_object; o.name = name
    o.rotation_euler[1] = math.pi / 2
    bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)
    register(o, material)
    return o

def poly_prism(name, plan_pts, z0, z1, material):
    verts = [(x, y, z0) for x, y in plan_pts] + [(x, y, z1) for x, y in plan_pts]
    n = len(plan_pts)
    faces = [list(range(n))[::-1], list(range(n, 2 * n))]
    faces += [[i, (i + 1) % n, n + (i + 1) % n, n + i] for i in range(n)]
    me = bpy.data.meshes.new(name); me.from_pydata(verts, [], faces); me.update()
    o = bpy.data.objects.new(name, me)
    bpy.context.collection.objects.link(o)
    register(o, material)
    return o

def plate(name, pts_yz, x_c, width, material):
    xi, xo = x_c - width / 2, x_c + width / 2
    verts = [(xi, y, z) for y, z in pts_yz] + [(xo, y, z) for y, z in pts_yz]
    n = len(pts_yz)
    faces = [[i, i + 1, n + i + 1, n + i] for i in range(n - 1)]
    me = bpy.data.meshes.new(name); me.from_pydata(verts, [], faces); me.update()
    o = bpy.data.objects.new(name, me)
    bpy.context.collection.objects.link(o)
    sol = o.modifiers.new("thickness", "SOLIDIFY"); sol.thickness = mm(P["panel_t"])
    register(o, material)
    return o

# --- derived dims ---
L, W, H = mm(P["body_len"]), mm(P["body_wid"]), mm(P["body_hgt"])
wheel_r = mm(P["wheel_dia"]) / 2
base_z = wheel_r + mm(P["axle_below_base"])
rail_w, rail_h = mm(P["rail_w"]), mm(P["rail_h"])
rail_z = base_z - rail_h / 2
t = mm(P["panel_t"])
rear_y, boxf_y = -L / 2, L / 2
nose_y = boxf_y + mm(P["nose_depth"])
flat_x = mm(P["nose_flat"]) / 2
join_y = nose_y + mm(P["aframe_join"])
tip_y = join_y + mm(P["hitch_len"])
axle_y = rear_y + mm(P["axle_from_rear"])
rail_x = W / 2 - rail_w / 2 - mm(10)

# 1) CHASSIS — perimeter + crosses + A-frame joining 200 out, 400 hitch
box("chassis_rail_L", rail_w, L, rail_h, -rail_x, 0, rail_z, M_STEEL)
box("chassis_rail_R", rail_w, L, rail_h,  rail_x, 0, rail_z, M_STEEL)
box("chassis_front", 2 * rail_x - rail_w, rail_w, rail_h, 0, boxf_y - rail_w / 2, rail_z, M_STEEL)
box("chassis_rear",  2 * rail_x - rail_w, rail_w, rail_h, 0, rear_y + rail_w / 2, rail_z, M_STEEL)
for i in range(P["n_cross"]):
    y = rear_y + (i + 1) * L / (P["n_cross"] + 1)
    box("chassis_cross_%d" % (i + 1), 2 * rail_x - rail_w, rail_w, rail_h, 0, y, rail_z, M_STEEL)
for sgn, nm in ((-1, "aframe_L"), (1, "aframe_R")):
    x0, y0 = 0.0, join_y
    x1, y1 = sgn * rail_x, axle_y
    mlen = math.hypot(x1 - x0, y1 - y0)
    ang = math.atan2(x1 - x0, y0 - y1)
    box(nm, rail_w * 0.8, mlen, rail_h, (x0 + x1) / 2, (y0 + y1) / 2, rail_z, M_STEEL, rz=ang)
box("hitch_bar", rail_w * 0.8, mm(P["hitch_len"]) + mm(40), rail_h, 0, (join_y + tip_y) / 2, rail_z, M_STEEL)
box("coupler", mm(60), mm(180), mm(70), 0, tip_y, rail_z + mm(20), M_STEEL)
# jockey on the RIGHT A-member (15% down from the join)
s = 0.15
jx = s * rail_x
jy = join_y + s * (axle_y - join_y)
cyl("jockey_wheel", mm(60), mm(50), jx, jy, mm(60), M_RUB)
box("jockey_post", mm(35), mm(35), rail_z - mm(60), jx, jy, (rail_z + mm(60)) / 2, M_SS)

# 2) BODY SHELL — polygonal prow
plan = [(-W/2, rear_y), (W/2, rear_y), (W/2, boxf_y),
        (flat_x, nose_y), (-flat_x, nose_y), (-W/2, boxf_y)]
shell = poly_prism("body_shell", plan, base_z, base_z + H, M_ALU)
bv = shell.modifiers.new("turn_edge", "BEVEL")
bv.width = mm(P["turn_edge"]); bv.segments = 2; bv.limit_method = "ANGLE"
# door openings — boolean-cut pockets so open doors reveal a real reveal
def cutter_box(name, sx, sy, sz, x, y, z, rz=0.0):
    bpy.ops.mesh.primitive_cube_add(size=1, location=(x, y, z))
    o = bpy.context.active_object; o.name = name
    o.scale = (sx, sy, sz)
    if rz: o.rotation_euler[2] = rz
    bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
    o.display_type = "WIRE"; o.hide_render = True
    o.parent = root
    m = shell.modifiers.new(name, "BOOLEAN")
    m.operation = "DIFFERENCE"; m.object = o
    o.hide_set(True)
    return o

roof = poly_prism("panel_roof", plan, base_z + H, base_z + H + t, M_ALU)
floor = poly_prism("panel_floor", plan, base_z, base_z + t, M_ALU)

# 3) REAR DOORS — 900 + 600 off-centre, proud 20mm, RV paddles at 1200
dwL, dwR = mm(P["door_w_L"]), mm(P["door_w_R"])
split_x = -W / 2 + dwL                      # meeting line
door_h = H - mm(60)
door_z = base_z + mm(30) + door_h / 2
door_y = rear_y + mm(9)          # slab 20 thick -> outer face 1mm proud = flush with seam
box("door_rear_L", dwL - mm(10), mm(20), door_h, -W/2 + dwL/2, door_y, door_z, M_ALU)
box("door_rear_R", dwR - mm(10), mm(20), door_h,  W/2 - dwR/2, door_y, door_z, M_ALU)
# external BUTT hinges (leaf + knuckle pin, stainless): 2 per rear door, outer edges
def butt_hinge(base, x, y, z, rz=0.0):
    box(base + "_leaf", mm(70), mm(2), mm(100), x, y, z, M_SS, rz=rz)
    bpy.ops.mesh.primitive_cylinder_add(radius=mm(7), depth=mm(104), location=(x, y, z), vertices=24)
    o = bpy.context.active_object; o.name = base + "_pin"
    if rz: o.rotation_euler[2] = rz
    register(o, M_SS)
for hx, tagd, ksgn in ((-W/2 + mm(6), "L", -1), (W/2 - mm(6), "R", 1)):
    for k, hzz in enumerate((door_z - door_h * 0.3, door_z + door_h * 0.3)):
        butt_hinge("hinge_rear_%s_%d" % (tagd, k + 1), hx + ksgn * 0, rear_y - mm(8), hzz)
# cut the rear door openings (40mm pocket behind the flush doors)
_dwL, _dwR = mm(P["door_w_L"]), mm(P["door_w_R"])
_dh = H - mm(60)
_dz = base_z + mm(30) + _dh / 2
cutter_box("cut_door_L", _dwL - mm(30), mm(160), _dh - mm(20), -W/2 + _dwL/2, rear_y + mm(40), _dz)
cutter_box("cut_door_R", _dwR - mm(30), mm(160), _dh - mm(20),  W/2 - _dwR/2, rear_y + mm(40), _dz)

hz = base_z + mm(P["handle_h"])
box("handle_L", mm(90), mm(25), mm(130), split_x - mm(70), rear_y - mm(14), hz, M_BLACK)
box("handle_R", mm(90), mm(25), mm(130), split_x + mm(70), rear_y - mm(14), hz, M_BLACK)

# 4) STAINLESS TRIM — 25mm on the frame edges
tr = mm(P["trim"])
tt = mm(P["trim_t"])
n = len(plan)
for i in range(n):
    px, py = plan[i]
    for tag, (ax, ay), (bx, by) in (("a", plan[i - 1], plan[i]), ("b", plan[i], plan[(i + 1) % n])):
        ex, ey = bx - ax, by - ay
        el = math.hypot(ex, ey)
        ux, uy = ex / el, ey / el
        nx, ny = ey / el, -ex / el
        sgn = -1 if tag == "a" else 1
        box("trim_corner_%d%s" % (i + 1, tag), tt, tr + tt, H,
            px + sgn * ux * (tr - tt) / 2 + nx * tt / 2, py + sgn * uy * (tr - tt) / 2 + ny * tt / 2,
            base_z + H / 2, M_SS, rz=math.atan2(ex, -ey))
for i in range(n):
    x0, y0 = plan[i]; x1, y1 = plan[(i + 1) % n]
    seg = math.hypot(x1 - x0, y1 - y0)
    ang = math.atan2(x1 - x0, y0 - y1)
    nx, ny = (y1 - y0) / seg, -(x1 - x0) / seg
    box("trim_top_%d" % (i + 1), tt, seg - 2 * tr, tr, (x0 + x1) / 2 + nx * tt / 2, (y0 + y1) / 2 + ny * tt / 2,
        base_z + H - tr / 2, M_SS, rz=ang)

# 4b) HORIZONTAL BELT TRIM — 25mm stainless band at belt_h, proud of the wall
belt_z = base_z + mm(P["belt_h"])
for i in range(n):
    x0, y0 = plan[i]; x1, y1 = plan[(i + 1) % n]
    seg = math.hypot(x1 - x0, y1 - y0)
    ang = math.atan2(x1 - x0, y0 - y1)
    nx, ny = (y1 - y0) / seg, -(x1 - x0) / seg     # outward normal (plan is CCW)
    box("trim_belt_%d" % (i + 1), tt, seg - 2 * tr, tr,
        (x0 + x1) / 2 + nx * tt / 2, (y0 + y1) / 2 + ny * tt / 2, belt_z, M_SS, rz=ang)

# 4c) FRONT DOOR — on the RH angled prow facet, belt trim up to the ceiling
fx0, fy0 = plan[2]                                  # (W/2, boxf_y)
fx1, fy1 = plan[3]                                  # (flat_x, nose_y)
fseg = math.hypot(fx1 - fx0, fy1 - fy0)
fang = math.atan2(fx1 - fx0, fy0 - fy1)
fnx, fny = (fy1 - fy0) / fseg, -(fx1 - fx0) / fseg
fd_h = (base_z + H) - (belt_z + tr) - mm(40)
fd_z = belt_z + tr + mm(20) + fd_h / 2
fmx, fmy = (fx0 + fx1) / 2, (fy0 + fy1) / 2
box("door_front", mm(20), fseg - mm(80), fd_h,
    fmx - fnx * mm(9), fmy - fny * mm(9), fd_z, M_ALU, rz=fang)   # flush, 1mm proud
# handle on the LEFT edge of the door (box-end), 400 above the door base
ux, uy = (fx1 - fx0) / fseg, (fy1 - fy0) / fseg
h_along = mm(110)                                   # 40 door margin + 70 in from edge
h_z = (belt_z + tr + mm(20)) + mm(400)
box("handle_front", mm(25), mm(90), mm(130),
    fx0 + ux * h_along + fnx * mm(14), fy0 + uy * h_along + fny * mm(14), h_z, M_BLACK, rz=fang)

# 4d) front door hinges (nose-end edge, opposite the handle) + door rigging
fe_along = fseg - mm(40)                      # nose-end edge of the door
for k, hzz in enumerate((fd_z - fd_h * 0.3, fd_z + fd_h * 0.3)):
    butt_hinge("hinge_front_%d" % (k + 1),
        fx0 + ux * fe_along + fnx * mm(8), fy0 + uy * fe_along + fny * mm(8), hzz, rz=fang)

cutter_box("cut_door_F", mm(160), fseg - mm(110), fd_h - mm(20),
    fmx - fnx * mm(40), fmy - fny * mm(40), fd_z, rz=fang)

def set_pivot(obj, x, y, z):
    bpy.context.scene.cursor.location = (x, y, z)
    bpy.context.view_layer.objects.active = obj
    for oo in bpy.data.objects: oo.select_set(oo is obj)
    bpy.ops.object.origin_set(type="ORIGIN_CURSOR")

def limit_swing(obj, lo_deg, hi_deg):
    c = obj.constraints.new("LIMIT_ROTATION")
    c.use_limit_z = True
    c.min_z = math.radians(lo_deg); c.max_z = math.radians(hi_deg)
    c.owner_space = "LOCAL"

dL = bpy.data.objects["door_rear_L"]; dR = bpy.data.objects["door_rear_R"]; dF = bpy.data.objects["door_front"]
set_pivot(dL, -W/2 + mm(5), rear_y, door_z)          # hinge line: left edge
set_pivot(dR,  W/2 - mm(5), rear_y, door_z)          # hinge line: right edge
set_pivot(dF, fx0 + ux * fe_along, fy0 + uy * fe_along, fd_z)   # hinge line: nose edge
limit_swing(dL, -130, 0); limit_swing(dR, 0, 130); limit_swing(dF, 0, 130)
bpy.context.scene.cursor.location = (0, 0, 0)

# 5) RUNNING GEAR + 60-deg FOLDED MUDGUARDS
cyl("axle_beam", mm(30), W + mm(P["wheel_w"]) * 1.6, 0, axle_y, wheel_r, M_STEEL)
top_z = 2 * wheel_r + mm(60)
half_top = mm(P["fender_top_len"]) / 2
drop = mm(P["fender_drop"])
run = drop / math.tan(math.radians(P["fender_angle"]))
prof = [(axle_y - half_top - run, top_z - drop),
        (axle_y - half_top, top_z),
        (axle_y + half_top, top_z),
        (axle_y + half_top + run, top_z - drop)]
wx = W / 2 + mm(P["wheel_w"]) / 2 + mm(20)
for sgn, side in ((-1, "L"), (1, "R")):
    cyl("wheel_%s" % side, wheel_r, mm(P["wheel_w"]), sgn * wx, axle_y, wheel_r, M_RUB)
    cyl("rim_%s" % side, wheel_r * 0.55, mm(P["wheel_w"]) + mm(4), sgn * wx, axle_y, wheel_r, M_STEEL)
    plate("fender_%s" % side, prof, sgn * wx, mm(P["fender_wid"]), M_BLACK)

# 6) LED LIGHT BARS — below the doors, inset 100 from the sides
lw, lh = mm(P["light_w"]), mm(P["light_h"])
lz = base_z - mm(70)
ly = rear_y - mm(25)
for sgn, side in ((-1, "L"), (1, "R")):
    lx = sgn * (W / 2 - mm(P["light_inset"]) - lw / 2)
    box("light_%s" % side, lw, mm(35), lh, lx, ly, lz, M_BLACK)
    third = (lw - mm(30)) / 3
    for k, (mt, nm) in enumerate([(M_AMBER, "amber"), (M_RED, "red"), (M_WHITE, "white")]):
        cx = lx - lw/2 + mm(15) + third * (k + 0.5)
        box("light_%s_%s" % (side, nm), third - mm(8), mm(8), lh - mm(24), cx, ly - mm(16), lz, mt)

# 7) ROOF KIT
box("solar_panel", mm(P["solar_wid"]), mm(P["solar_len"]), mm(P["solar_t"]),
    0, 0, base_z + H + t + mm(P["solar_t"]) / 2, M_SOLAR)

# 8) ORIGINS — every part pivots at its own geometry
_door_names = {"door_rear_L", "door_rear_R", "door_front"}
for o in bpy.data.objects:
    if o.parent == root and o.type == "MESH" and o.name not in _door_names:
        bpy.context.view_layer.objects.active = o
        for oo in bpy.data.objects: oo.select_set(oo is o)
        bpy.ops.object.origin_set(type="ORIGIN_GEOMETRY", center="MEDIAN")

# 9) VERIFY + RENDER (rear 3/4 for doors/lights, front 3/4 for A-frame/jockey)
total_tris = sum(sum(len(p.vertices) - 2 for p in o.data.polygons)
                 for o in bpy.data.objects if o.parent == root and o.type == "MESH")
cam = bpy.data.objects.get("Camera")
scene = bpy.context.scene
scene.render.resolution_x = 1100; scene.render.resolution_y = 800

target = mathutils.Vector((0, -0.3, base_z + H / 2))
cam.location = mathutils.Vector((2.9, -3.6, 1.8))
cam.rotation_euler = (target - cam.location).to_track_quat("-Z", "Y").to_euler()
scene.render.filepath = "/tmp/v3_rear.png"
bpy.ops.render.render(write_still=True)

target = mathutils.Vector((0, 0.9, base_z + H / 3))
cam.location = mathutils.Vector((2.6, 4.4, 1.5))
cam.rotation_euler = (target - cam.location).to_track_quat("-Z", "Y").to_euler()
scene.render.filepath = "/tmp/v3_front.png"
bpy.ops.render.render(write_still=True)

result = {"parts": len(made), "total_tris": total_tris,
          "aframe": {"join_ahead_of_nose_mm": P["aframe_join"], "hitch_mm": P["hitch_len"],
                     "coupler_ahead_of_nose_mm": P["aframe_join"] + P["hitch_len"]},
          "doors_mm": [P["door_w_L"], P["door_w_R"]], "handle_h_mm": P["handle_h"],
          "renders": ["/tmp/v3_rear.png", "/tmp/v3_front.png"]}
