#!/usr/bin/env python3
"""
Up-and-over tool chest – four-bar hinge simulator
=================================================

Simulates the lid motion of the tool chest (side view, 2D) for a given set of
pivot points and checks the things that matter in practice:

  * does the lid end up exactly flat on the body (rotated 180° about P)?
  * does the lid collide with the body during the swing?
  * how far does it swing forward / behind the back panel?
  * how close does it come to the handle?
  * minimum transmission angle (how easily it moves; > ~35-40° is comfortable)

Coordinates (mm): origin = lower front corner of the closed chest,
x = towards the back, y = up. The lid is the front wedge, the body the rear
trapezoid, separated by a parting line slanted ALPHA from vertical.

Requirements: numpy, matplotlib (pip install numpy matplotlib)

Usage:
    python hinge_sim.py              # print report + save side_view.png
    python hinge_sim.py --gif        # also save motion.gif
"""
import sys
import numpy as np
from matplotlib.path import Path

# ----------------------------------------------------------------------------
# Box geometry
# ----------------------------------------------------------------------------
D = 268.0                     # overall depth, closed
H = 350.0                     # height of lid and body
ALPHA = np.radians(15)        # parting line, angle from vertical
TAN = np.tan(ALPHA)
T0 = 133.8                    # lid depth at the top
LB0 = T0 - H * TAN            # lid depth at the bottom (≈ 40.0)
G = 1.0                       # design gap used for the 180° pose (0.5 mm per side in practice)

# Pole of the 180° rotation: rotating the lid 180° about P puts its top face
# exactly onto the body's top face.
P = np.array([T0 + (G / 2) * TAN, H + G / 2])

LID = np.array([[0, 0], [LB0, 0], [T0, H], [0, H]])                    # closed lid
BODY = np.array([[LB0 + 0.5, 0], [D, 0], [D, H], [T0 + 0.5, H]])       # body

# ----------------------------------------------------------------------------
# Hinge (final design)
# ----------------------------------------------------------------------------
A = np.array([62.7, 283.2])      # lid pivot A  (on lid side panel)
B = np.array([88.0, 239.3])      # lid pivot B
A0 = np.array([100.28, 386.12])  # fixed pivot A0 (on handle plate)
B0 = np.array([118.36, 356.93])  # fixed pivot B0
DOWEL = np.array([112.0, 408.0]) # handle axis
DOWEL_R = 15.0                   # handle radius (Ø30)


# ----------------------------------------------------------------------------
# Synthesis helpers
# ----------------------------------------------------------------------------
def open_position(M):
    """Where a lid point ends up after the 180° rotation about P."""
    return 2 * P - M


def project_on_bisector(M, F):
    """Snap a fixed-pivot guess F onto the perpendicular bisector of
    M(closed) – M(open). Any fixed pivot on that line gives a link of equal
    length in both end positions, i.e. a valid two-position design."""
    v = M - P
    n = np.array([-v[1], v[0]]) / np.linalg.norm(v)
    return P + ((F - P) @ n) * n


def circumcenter(p1, p2, p3):
    """Three-position (Burmester) synthesis: the fixed pivot for a moving
    pivot that passes through p1, p2, p3 is the centre of their circle."""
    ax, ay = p1; bx, by = p2; cx, cy = p3
    d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
    ux = ((ax**2 + ay**2) * (by - cy) + (bx**2 + by**2) * (cy - ay) + (cx**2 + cy**2) * (ay - by)) / d
    uy = ((ax**2 + ay**2) * (cx - bx) + (bx**2 + by**2) * (ax - cx) + (cx**2 + cy**2) * (bx - ax)) / d
    return np.array([ux, uy])


# ----------------------------------------------------------------------------
# Kinematics
# ----------------------------------------------------------------------------
def edge_points(poly, step=4.0):
    out = []
    for i in range(len(poly)):
        a, b = poly[i], poly[(i + 1) % len(poly)]
        n = max(2, int(np.linalg.norm(b - a) / step))
        out.append(a + np.linspace(0, 1, n, endpoint=False)[:, None] * (b - a))
    return np.vstack(out)


LIDPTS = edge_points(LID)


def lid_world(pts, ang, t):
    c, s = np.cos(ang), np.sin(ang)
    return pts @ np.array([[c, -s], [s, c]]).T + t


def simulate(A, B, A0, B0, n=181):
    """Drive link A from the closed to the open angle, solve link B by
    circle intersection (staying on the assembly branch of the closed pose).
    Returns a list of poses (lid angle, lid translation, A_world, B_world)
    for each driving direction that actually reaches the open pose."""
    la = np.linalg.norm(A - A0)
    lb = np.linalg.norm(B - B0)
    d = np.linalg.norm(B - A)
    a_closed = np.arctan2(*(A - A0)[::-1])
    a_open = np.arctan2(*(open_position(A) - A0)[::-1])
    beta = np.arctan2(*(B - A)[::-1])

    def solve_B(Aw, sg):
        v = B0 - Aw
        r = np.linalg.norm(v)
        if r > d + lb or r < abs(d - lb) or r < 1e-9:
            return None
        a = (d * d - lb * lb + r * r) / (2 * r)
        h2 = d * d - a * a
        if h2 < 0:
            return None
        u = v / r
        return Aw + a * u + sg * np.sqrt(h2) * np.array([-u[1], u[0]])

    sign = next((sg for sg in (1, -1)
                 if (Bw := solve_B(A, sg)) is not None and np.linalg.norm(Bw - B) < 1e-6), None)
    if sign is None:
        raise ValueError("closed pose is not assemblable")

    results = []
    for direction in (1, -1):
        da = (a_open - a_closed) % (2 * np.pi)
        if direction < 0:
            da -= 2 * np.pi
        poses = []
        for al in a_closed + np.linspace(0, da, n):
            Aw = A0 + la * np.array([np.cos(al), np.sin(al)])
            Bw = solve_B(Aw, sign)
            if Bw is None:          # linkage cannot reach -> branch breaks
                poses = None
                break
            ang = np.arctan2(*(Bw - Aw)[::-1]) - beta
            c, s = np.cos(ang), np.sin(ang)
            t = Aw - np.array([c * A[0] - s * A[1], s * A[0] + c * A[1]])
            poses.append((ang, t, Aw, Bw))
        if poses and np.linalg.norm(poses[-1][3] - open_position(B)) < 0.5:
            results.append(poses)
    return results


# ----------------------------------------------------------------------------
# Checks
# ----------------------------------------------------------------------------
BODY_IN = Path(np.array([[LB0 + 2, 1], [D - 1, 1], [D - 1, H - 2], [T0 + 2, H - 2]]))
LID_IN = Path(np.array([[1, 1], [LB0 - 1, 1], [T0 - 1, H - 1], [1, H - 1]]))
BODY_CORNERS = np.array([[T0 + 0.5, H], [D, H]])


def collides(poses):
    for k, (ang, t, Aw, Bw) in enumerate(poses):
        if k < 2 or k > len(poses) - 3:       # ignore touching in end poses
            continue
        W = lid_world(LIDPTS, ang, t)
        if BODY_IN.contains_points(W).any():
            return True
        c, s = np.cos(-ang), np.sin(-ang)      # body corners inside the lid?
        L = (BODY_CORNERS - t) @ np.array([[c, -s], [s, c]]).T
        if LID_IN.contains_points(L).any():
            return True
    return False


def transmission_angle(Aw, Bw, B0):
    """Angle between coupler (A-B) and output link (B0-B), folded to 0..90°."""
    u, v = Aw - Bw, B0 - Bw
    a = np.degrees(np.arccos(np.clip(u @ v / np.linalg.norm(u) / np.linalg.norm(v), -1, 1)))
    return min(a, 180 - a)


def report(poses):
    W = np.array([lid_world(LIDPTS, a, t) for a, t, _, _ in poses])
    dowel_gap = min(np.linalg.norm(w - DOWEL, axis=1).min() for w in W) - DOWEL_R
    mu = [transmission_angle(Aw, Bw, B0) for _, _, Aw, Bw in poses]
    end = lid_world(LID, poses[-1][0], poses[-1][1])
    target = lid_world(LID, np.pi, 2 * P)       # LID rotated 180° about P
    print(f"link A length          {np.linalg.norm(A - A0):7.1f} mm")
    print(f"link B length          {np.linalg.norm(B - B0):7.1f} mm")
    print(f"open-pose error        {np.abs(end - target).max():7.2f} mm  (lid flat on body)")
    print(f"max forward swing      {-W[:, :, 0].min():7.1f} mm  (ahead of the front)")
    print(f"max behind the back    {W[:, :, 0].max() - D:7.1f} mm")
    print(f"min clearance handle   {dowel_gap:7.1f} mm")
    print(f"min transmission angle {min(mu):7.1f} °")
    print(f"collision with body    {'YES' if collides(poses) else 'no'}")


# ----------------------------------------------------------------------------
# Plot / animation
# ----------------------------------------------------------------------------
def draw(ax, pose, title=""):
    import matplotlib.pyplot as plt
    ang, t, Aw, Bw = pose
    ax.add_patch(plt.Polygon(BODY, fc="#e2c290", ec="#5a4632"))
    ax.add_patch(plt.Circle(DOWEL, DOWEL_R, fc="#b8834f", ec="#3a2a1a", zorder=4))
    L = lid_world(LID, ang, t)
    ax.add_patch(plt.Polygon(L, fc="#edd3a6", ec="#5a4632", alpha=.95, zorder=2))
    for F, M, col in ((A0, Aw, "#a0a7b0"), (B0, Bw, "#5b6470")):
        ax.plot([F[0], M[0]], [F[1], M[1]], "-", color=col, lw=6, solid_capstyle="round", zorder=5)
        for Q in (F, M):
            ax.add_patch(plt.Circle(Q, 3, fc="w", ec="k", zorder=6))
    ax.set_xlim(-280, D + 48); ax.set_ylim(-10, 2 * H + 80); ax.set_aspect("equal")
    ax.set_xticks([]); ax.set_yticks([]); ax.set_title(title, fontsize=10)


def main():
    cands = [p for p in simulate(A, B, A0, B0) if not collides(p)]
    if not cands:
        sys.exit("no collision-free branch found")
    # of the valid driving directions, take the one that swings least behind the back
    poses = min(cands, key=lambda p: max(lid_world(LIDPTS, a, t)[:, 0].max() for a, t, _, _ in p))
    report(poses)

    import matplotlib; matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    fig, axs = plt.subplots(1, 4, figsize=(16, 6))
    for ax, k, tt in zip(axs, (0, 60, 120, 180), ("closed", "opening", "over the handle", "open – flat")):
        draw(ax, poses[k], tt)
    plt.tight_layout(); plt.savefig("side_view.png", dpi=100)
    print("saved side_view.png")

    if "--gif" in sys.argv:
        from matplotlib.animation import FuncAnimation, PillowWriter
        fig, ax = plt.subplots(figsize=(6, 7.5))
        seq = poses + poses[::-1]
        anim = FuncAnimation(fig, lambda k: (ax.clear(), draw(ax, seq[k])), frames=len(seq), interval=40)
        anim.save("motion.gif", writer=PillowWriter(fps=25))
        print("saved motion.gif")


if __name__ == "__main__":
    main()
