"""Build a measured-core study of the 1975 Midget 1500 as a STEP assembly.

Install CadQuery (tested with 2.8), then run this file. Factory dimensions are
read from ../cad-data.json. All other dimensions below are provisional and are
grouped here so measured replacements can be substituted without redrawing.
This is a visualization and reconstruction starting point, not a service part.
"""

from __future__ import annotations

import json
import math
from pathlib import Path

import cadquery as cq


HERE = Path(__file__).resolve().parent
DATA = json.loads((HERE.parent / "cad-data.json").read_text())
M = DATA["all_dimensions_mm"]
GASKET = DATA["photo_scale_anchors"][0]["dimensions_mm"]
FILLER_CAP = DATA["photo_scale_anchors"][1]["dimensions_mm"]

# The workshop manual does not dimension these casting and layout surfaces.
# Values here are visual placeholders, never claimed as factory dimensions.
P = {
    "bore_pitch": 82.5,
    "block_width": 192.0,
    "block_length": 365.0,
    "deck_to_crank_axis": 225.0,
    "block_below_crank": 48.0,
    "head_height": 82.0,
    "cover_height": 40.0,
    "cover_lip_overhang": 5.0,
    "sump_height": 105.0,
    "piston_height": 53.0,
    "piston_pin_to_crown": 35.0,
    "main_journal_width": 27.0,
    "crankpin_width": 23.0,
    "crank_web_width": 17.0,
    "crank_web_radius": 56.0,
    "rod_big_end_outer_radius": 32.0,
    "rod_small_end_outer_radius": 17.0,
    "valve_included_angle_placeholder_degrees": 0.0,
    "rocker_cover_wall": 5.0,
    "rocker_cover_top": 7.0,
    "alternator_case_radius": 43.0,
    "alternator_case_length": 98.0,
    "distributor_cap_radius": 31.0,
    "fan_radius": 88.0,
    "water_pump_center_y": -24.0,
}


def mid(key: str) -> float:
    a, b = M[key]
    return (a + b) / 2


def cyl(radius: float, length: float, base: tuple[float, float, float], axis=(0, 0, 1)):
    return cq.Solid.makeCylinder(radius, length, cq.Vector(*base), cq.Vector(*axis))


def union_solids(*solids):
    result = solids[0]
    for solid in solids[1:]:
        result = result.fuse(solid)
    return result


def make_block(centers):
    bottom = -(P["deck_to_crank_axis"] + P["block_below_crank"])
    height = -bottom
    block = cq.Workplane("XY").box(P["block_width"], height, P["block_length"])
    block = block.translate((0, bottom + height / 2, 0)).edges("|Y").fillet(12).val()
    for z in centers:
        block = block.cut(cyl(M["bore_nominal"] / 2, height + 4, (0, 2, z), (0, -1, 0)))
    # The three semicircular bearing tunnel openings establish topology only.
    for z in (-160, 0, 160):
        block = block.cut(cyl(mid("crank_main_journal_diameter") / 2 + 5,
                              P["main_journal_width"],
                              (0, -P["deck_to_crank_axis"], z - P["main_journal_width"] / 2)))
    return block


def make_piston(z: float, throw_up: bool):
    throw = M["crank_throw_radius_derived"] * (1 if throw_up else -1)
    pin_y = -P["deck_to_crank_axis"] + throw + mid("connecting_rod_center_distance")
    crown_y = pin_y + P["piston_pin_to_crown"]
    shell = cyl(M["bore_nominal"] / 2 - 0.4, P["piston_height"],
                (0, crown_y - P["piston_height"], z), (0, 1, 0))
    pin = cyl(mid("gudgeon_pin_outer_diameter") / 2, M["bore_nominal"] - 5,
              (0, pin_y, z - (M["bore_nominal"] - 5) / 2))
    return cq.Compound.makeCompound([shell, pin]), pin_y


def make_rod(z: float, pin_y: float, throw_up: bool):
    crank_y = -P["deck_to_crank_axis"] + M["crank_throw_radius_derived"] * (1 if throw_up else -1)
    big = cyl(P["rod_big_end_outer_radius"], 20, (0, crank_y, z - 10))
    small = cyl(P["rod_small_end_outer_radius"], 20, (0, pin_y, z - 10))
    stem = cq.Workplane("XY").box(19, pin_y - crank_y, 16).translate((0, (pin_y + crank_y) / 2, z)).val()
    rod = union_solids(big, small, stem)
    rod = rod.cut(cyl(mid("crankpin_journal_diameter") / 2, 24, (0, crank_y, z - 12)))
    rod = rod.cut(cyl(mid("gudgeon_pin_outer_diameter") / 2, 24, (0, pin_y, z - 12)))
    return rod


def make_crank(centers):
    y = -P["deck_to_crank_axis"]
    shaft_radius = mid("crank_main_journal_diameter") / 2
    pieces = [cyl(shaft_radius * .65, P["block_length"] + 38,
                  (0, y, -(P["block_length"] + 38) / 2))]
    for z in (-160, 0, 160):
        pieces.append(cyl(shaft_radius, P["main_journal_width"],
                          (0, y, z - P["main_journal_width"] / 2)))
    for i, z in enumerate(centers):
        offset = M["crank_throw_radius_derived"] * (1 if i in (0, 3) else -1)
        pieces.append(cyl(mid("crankpin_journal_diameter") / 2,
                          P["crankpin_width"], (0, y + offset, z - P["crankpin_width"] / 2)))
        for delta in (-23, 23):
            web = cq.Workplane("XY").box(P["crank_web_width"], abs(offset) + 26, 12)
            web = web.translate((0, y + offset / 2, z + delta)).val()
            pieces.append(web)
    return cq.Compound.makeCompound(pieces)


def make_head(centers):
    head = cq.Workplane("XY").box(P["block_width"] - 6, P["head_height"], P["block_length"] - 5)
    head = head.translate((0, P["head_height"] / 2, 0)).edges("|Y").fillet(10).val()
    for z in centers:
        chamber = cyl(M["bore_nominal"] / 2 - 3, 7, (0, -1, z), (0, 1, 0))
        head = head.cut(chamber)
    return head


def make_valves(centers):
    valves = []
    for z in centers:
        for x, size in ((-21, mid("inlet_valve_head_diameter")),
                        (21, mid("exhaust_valve_head_diameter"))):
            head = cyl(size / 2, 5, (x, 6, z), (0, 1, 0))
            stem = cyl(3.95, 67, (x, 11, z), (0, 1, 0))
            valves.append(union_solids(head, stem))
    return cq.Compound.makeCompound(valves)


def make_rocker_cover():
    """Sheet-metal envelope: supplier footprint, provisional section and details."""
    base_y = P["head_height"] + GASKET["thickness"]
    width = GASKET["width"] + 2 * P["cover_lip_overhang"]
    length = GASKET["length"] + 2 * P["cover_lip_overhang"]
    outer = (cq.Workplane("XY").box(width, P["cover_height"], length)
             .translate((0, base_y + P["cover_height"] / 2, 0))
             .edges("|Y").fillet(18).val())
    inner = (cq.Workplane("XY").box(width - 2 * P["rocker_cover_wall"],
                                     P["cover_height"] - P["rocker_cover_top"] + 2,
                                     length - 2 * P["rocker_cover_wall"])
             .translate((0, base_y + (P["cover_height"] - P["rocker_cover_top"] + 2) / 2 - 1, 0))
             .edges("|Y").fillet(13).val())
    shell = outer.cut(inner)
    # The shallow stamped ridge makes the photographed red cover easier to read.
    # Its profile and the fastener locations are not workshop dimensions.
    top = base_y + P["cover_height"]
    ridge = (cq.Workplane("XY").box(37, 3, 212)
             .translate((0, top + 1.5, 24)).edges("|Y").fillet(14).val())
    return shell.fuse(ridge)


def make_rocker_cover_fasteners():
    top = P["head_height"] + GASKET["thickness"] + P["cover_height"] + 3
    pieces = []
    for z in (-40, 112):
        pieces.extend((cyl(13, 3, (0, top, z), (0, 1, 0)),
                       cyl(7, 7, (0, top + 3, z), (0, 1, 0))))
    return cq.Compound.makeCompound(pieces)


def make_distributor():
    """Visible four-outlet distributor; position and outside sizes are provisional."""
    x, z = 115, 35
    body = cyl(24, 61, (x, -18, z), (0, 1, 0))
    cap = cyl(P["distributor_cap_radius"], 30, (x, 43, z), (0, 1, 0))
    lip = cyl(P["distributor_cap_radius"] + 2, 5, (x, 43, z), (0, 1, 0))
    terminals = [cyl(7, 20, (x + dx, 73, z + dz), (0, 1, 0))
                 for dx, dz in ((-16, -15), (16, -15), (-16, 15), (16, 15))]
    return cq.Compound.makeCompound([body, cap, lip, *terminals])


def make_alternator():
    """Finned case and pulley in the photographed charging-system position."""
    x, y, z = 136, -89, 70
    radius = P["alternator_case_radius"]
    length = P["alternator_case_length"]
    pieces = [cyl(radius, length, (x, y, z))]
    for end in (z, z + length - 8):
        pieces.append(cyl(radius + 4, 8, (x, y, end)))
    for i in range(12):
        a = i * math.tau / 12
        rib = cq.Workplane("XY").box(6, 6, length - 20).translate(
            (x + (radius + 1) * math.cos(a), y + (radius + 1) * math.sin(a), z + length / 2)).val()
        pieces.append(rib)
    pieces.extend((cyl(11, 23, (x, y, z + length)),
                   cyl(27, 12, (x, y, z + length + 16))))
    return cq.Compound.makeCompound(pieces)


def make_front_pulleys():
    """Three visible belt pulleys; pitch diameters and axial locations provisional."""
    crank_y = -P["deck_to_crank_axis"]
    water_y = P["water_pump_center_y"]
    return cq.Compound.makeCompound([
        cyl(37, 17, (0, crank_y, 185)),
        cyl(36, 16, (0, water_y, 188)),
        cyl(29, 12, (136, -89, 188)),
    ])


def make_fan():
    """Four-blade pale fan from the owner cooling photograph; outline estimated."""
    water_y = P["water_pump_center_y"]
    blades = [cyl(23, 13, (0, water_y, 220))]
    profile = [(-13, 14), (9, 13), (28, 78), (4, P["fan_radius"]), (-17, 64)]
    for angle in (0, 90, 180, 270):
        blade = (cq.Workplane("XY").polyline(profile).close().extrude(5)
                 .rotate((0, 0, 0), (0, 0, 1), angle)
                 .translate((0, water_y, 232)).val())
        blades.append(blade)
    return cq.Compound.makeCompound(blades)


def make_fan_belt():
    """Visible three-pulley loop. Its length and cross-section are not a fitment spec."""
    # Approximate tangent polygon around the crank, alternator and water pump.
    # Keep it as a separate STEP part so a measured route can replace it.
    outer = [(-38, -232), (-20, -255), (28, -248), (169, -106),
             (167, -76), (150, -58), (18, 10), (-23, 6), (-38, -16)]
    inner = [(-30, -229), (-15, -247), (24, -240), (160, -102),
             (158, -80), (145, -66), (16, 2), (-17, -2), (-30, -20)]
    outer_solid = cq.Workplane("XY").polyline(outer).close().extrude(8).translate((0, 0, 193)).val()
    inner_solid = cq.Workplane("XY").polyline(inner).close().extrude(10).translate((0, 0, 192)).val()
    return outer_solid.cut(inner_solid)


def main():
    centers = [(-1.5 + i) * P["bore_pitch"] for i in range(4)]
    assy = cq.Assembly(name="Midget_1500_measured_core_study")
    assy.add(make_block(centers), name="block_provisional_casting", color=cq.Color(.22, .25, .24))
    assy.add(make_head(centers), name="head_provisional_casting", color=cq.Color(.30, .33, .32))
    assy.add(make_valves(centers), name="valves_factory_head_diameters", color=cq.Color(.72, .74, .69))
    assy.add(make_crank(centers), name="crank_factory_journal_diameters_and_stroke", color=cq.Color(.69, .70, .66))
    for i, z in enumerate(centers):
        piston, pin_y = make_piston(z, i in (0, 3))
        assy.add(piston, name=f"piston_{i + 1}_factory_bore", color=cq.Color(.76, .77, .70))
        assy.add(make_rod(z, pin_y, i in (0, 3)),
                 name=f"rod_{i + 1}_factory_center_distance", color=cq.Color(.58, .61, .59))
    gasket = cq.Workplane("XY").box(GASKET["width"], GASKET["thickness"], GASKET["length"])
    gasket = gasket.translate((0, P["head_height"] + GASKET["thickness"] / 2, 0)).val()
    gasket = gasket.cut(cq.Workplane("XY").box(GASKET["width"] - 16,
                                                    GASKET["thickness"] + 2,
                                                    GASKET["length"] - 16)
                        .translate((0, P["head_height"] + GASKET["thickness"] / 2, 0)).val())
    assy.add(gasket, name="rocker_gasket_supplier_footprint", color=cq.Color(.22, .16, .12))
    assy.add(make_rocker_cover(), name="rocker_cover_photo_scaled", color=cq.Color(.70, .19, .13))
    assy.add(make_rocker_cover_fasteners(), name="rocker_cover_fasteners_provisional", color=cq.Color(.57, .59, .56))
    cover_top = P["head_height"] + GASKET["thickness"] + P["cover_height"]
    neck = cyl(20, 12, (0, cover_top, -128), (0, 1, 0))
    cap = cyl(FILLER_CAP["diameter"] / 2, 12, (0, cover_top + 12, -128), (0, 1, 0))
    assy.add(cq.Compound.makeCompound([neck, cap]),
             name="oil_filler_cap_candidate_58mm", color=cq.Color(.10, .12, .12))
    sump = cq.Workplane("XY").box(P["block_width"] * .86, P["sump_height"],
                                   P["block_length"] * .91)
    sump = sump.translate((0, -(P["deck_to_crank_axis"] + P["block_below_crank"] + P["sump_height"] / 2), 0))
    assy.add(sump.val(), name="sump_provisional_casting", color=cq.Color(.19, .22, .21))
    assy.add(make_distributor(), name="distributor_photo_reference_provisional", color=cq.Color(.10, .11, .10))
    assy.add(make_alternator(), name="alternator_photo_reference_provisional", color=cq.Color(.48, .51, .49))
    assy.add(make_front_pulleys(), name="front_pulleys_provisional", color=cq.Color(.24, .25, .23))
    assy.add(make_fan_belt(), name="fan_belt_photo_reference_provisional", color=cq.Color(.10, .11, .10))
    assy.add(make_fan(), name="mechanical_fan_photo_reference_provisional", color=cq.Color(.77, .72, .53))
    output = HERE / "midget-1500-measured-core.step"
    assy.save(str(output))
    # OCCT emits harmless trailing spaces on STEP continuation lines.
    output.write_text("\n".join(line.rstrip() for line in output.read_text().splitlines()) + "\n")
    web_parts = []
    for name, item in assy.objects.items():
        if item.obj is None:
            continue
        vertices, triangles = item.obj.tessellate(1.4, 0.28)
        web_parts.append({
            "name": name,
            "vertices": [round(c, 3) for v in vertices for c in v.toTuple()],
            "triangles": [index for face in triangles for index in face],
            "color": [round(c, 3) for c in item.color.toTuple()[:3]],
        })
    web_output = HERE / "midget-1500-measured-core.json"
    web_output.write_text(json.dumps({"units": "mm", "parts": web_parts}, separators=(",", ":")))
    print(f"Wrote {output} ({output.stat().st_size:,} bytes)")
    print(f"Wrote {web_output} ({web_output.stat().st_size:,} bytes)")


if __name__ == "__main__":
    main()
