# source repo: https://github.com/jeff-dh/SolidPython (solidpython2, editable install via pixi)
"""
Hook for a Ø16 dowel — two parts, a French cleat, one screw into the rod.

The J curls in the plane that CONTAINS the dowel, so the hook runs out along the
rod rather than into the room, and you load it from the end. The collar is a
plain closed ring — not a clamp, it grips nothing. A single screw goes through
the hook, through the collar and into the dowel itself, on the dowel's own axis:
a diametral pin, not friction.

Coordinates (as installed):

    X   the dowel axis          Y   horizontal, out into the room
    Z   vertical, up            origin at the rod axis, x=0 at one end of the collar

Looking ALONG the rod — the joint. All of it is in this one section:

         y=0      12   15               23.5
          |        |    |                 |
          |        |    | ############### |  z=+13.3   plate top
          |  .-‾‾‾‾‾‾-. | #\  lip       # |
          | /          \  #  \  ######### |  z=+10.0   top of the bevel
          || |  .-‾-.  | | #   \  ####### |
          || | / Ø16 \ | | #    \  ###### |  z=+7.0    bottom of the bevel...
          || |  \   /  | | #     ######## |
          | \    '-'    / #      ######## |            ...and below it the two
      ----+--[======SCREW=========]-------+  z=0       parts are simply two flat
          | /          \  #      ######## |            faces, one on the other.
          | '----------'  #      ######## |            The screw crosses them on
          |               #      ######## |  z=-13.15  the rod's axis.
          |                      ######## |  z=-15.0   plate bottom

Looking ACROSS the rod — the hook. Its long axis is the ROD'S direction: it runs
about 1.6x further along the dowel than it hangs below it.

    x=0         x=22               x=35            x=48  x=53
     |           |                  |               |     |
     ======================================================      the dowel
     |###########|                                               collar, 22 long
     |###########|
     |###########|  <- and behind it the plate, the same 22 long, on the cleat
     |###########|                                  .-.
     |###########|                                 (   )  <- tip, back up to
      \##########|                                 /  /      about rod height
       \#########|                               /  /
        \########\                             .'  /
         '.#######'.                         .'  .'
           '-.######'-._                 _.-'  .'
              '-..#######‾‾--.......--‾‾  _.-'
                  ‾‾--..___________..--‾‾
                              bottom of the bight, x=35:
                              hang the load HERE, not out at the tip

One stroke from crown to tip: the throat's edge carries the plate's front edge
straight on down and eases into a curl that winds tighter as it goes (a
clothoid); the stroke's width is laid off it outward, full plate width at the
root narrowing to a slim rounded tip, so the back edge sweeps round by itself.

**One screw, and it is the whole fixing.** On the rod's axis it pins the dowel
diametrically: the collar cannot slide along the rod and cannot rotate on it,
neither of which a friction clamp does reliably on timber. It passes through the
hook's plate on the way, so it also locks the cleat shut. And the collar's own
hole is the drill jig — put the pair where you want them, drill through the
collar, and the pilot lands on the axis and cannot wander.

**The cleat is a real French cleat.** Below z=+7 the two parts are simply two
flat faces bearing on each other; above it, one 45° bevel facing up and out. The
hook drops on from above and its own weight slides it down the bevel, pulling it
in against the flat. Bevel and flat form a couple, so a loaded hook cannot pitch
forward. The cleat carries the load; the screw only stops it lifting.

The socket in the hook is not drawn, and it is not the collar's shape either: it
is the volume the collar sweeps as the hook comes OFF — straight up by
CLEAT_LIFT, then away. Cutting that guarantees the joint can be taken apart,
which conforming to the collar's outline would not. See _release_cut().

**Two planes, two extrusions.** The joint is a section in (Y, Z) run along the
dowel; the J is a profile in (X, Z) run across it. They meet because both occupy
the same slab of Y, between the collar's face and the plate's outer face, so the
bar leaves the plate as the plate — same thickness, no step, nothing to crack.

That gives each part one clean print, and two different ones:

  collar   stands on its section, ring axis vertical. Every layer identical, no
           overhang at all, round bore.
  hook     lies on its OUTER face, and builds inward toward the collar. The
           silhouette of plate-and-J is the first layer, and from there material
           only ever goes away — first the J's slab ends, then the lip tapers
           out on the bevel. Nothing overhangs anything.

In both, the layers stack the right way for the load: the hook bends within its
layers, not across them.

Run standalone:  pixi run python "curtain hook/rod_hook.py"
"""

from math import acos, asin, atan2, cos, hypot, pi, radians, sin
from pathlib import Path

from solid2 import (
    circle,
    cube,
    cylinder,
    hull,
    linear_extrude,
    offset,
    polygon,
    rotate,
    square,
    translate,
)

# ---------------------------------------------------------------------------
# Parameters — all in mm
# ---------------------------------------------------------------------------
# --- the dowel --------------------------------------------------------------
ROD_D = 16.0          # measured dowel diameter
BORE_CLEAR = 0.30     # a plain slip fit. Nothing clamps: the screw locates it,
                      # so the bore only has to slide on without slop.

# --- collar -----------------------------------------------------------------
COLLAR_WALL = 5.0     # radial wall
COLLAR_W = 22.0       # length along the dowel; the plate matches it
BORE_CHAMFER = 0.8    # 45° lead-in at both ends of the bore

# --- the cleat --------------------------------------------------------------
# A ring has nothing to bear on: its outer surface is a cylinder, so a flat plate
# lands on a tangent LINE. FACE_Y is what turns that into a face, and it is also
# the depth the screw needs to stay in solid material on its way to the dowel.
FACE_Y = 15.0         # the collar's flat mounting face, 1.85 proud of the OD
BEVEL_Z0 = 7.0        # bottom of the 45° bevel, at FACE_Y. Everything below this
                      # is flat-on-flat. It has to clear the screw hole — that,
                      # not the cleat, is what sets the number.
BEVEL_H = 3.0         # bevel rise == its depth of engagement, since it is 45°
CLEAT_CLEAR = 0.30    # slide clearance, as a true normal offset so the two 45°
                      # faces stay parallel and meet flat
CLEAT_LIFT = 4.0      # how far the hook rises to clear the bevel; > BEVEL_H
COLLAR_CHAMFER = 3.0  # 45° on the ring's outer edge at both ends. Seen from the
                      # side the collar IS the silhouette — the plate hides
                      # behind it — so this is the one number that stops the part
                      # reading as a brick. It is free: the tool only reaches
                      # COLLAR_R + 1, so it never touches the bore or the cleat.

# --- the hook's plate -------------------------------------------------------
PLATE_T = 8.2         # thickness of the whole hook in Y — plate AND bar, because
                      # the bar is just the plate carrying on downward
# Flush with the collar's underside, not 1.85 below it. Anything the plate does
# below the collar shows in the side view as a thin lip with a notch in it where
# the flank tapers away — a detail that reads as a mistake. Tucked level, the
# plate is invisible from the side and the silhouette is the collar's alone. The
# 1.85 mm of lost bearing is nothing against a 20 mm couple.
# PLATE_Z0 is derived below, from COLLAR_R.

# The plate is only a plate ABOVE the screw. The cleat needs full length over the
# bevel and the lip, z = +7 upward, and the screw head needs a seat; below that
# the plate is just the far half of an anti-pitch couple, and a couple cares
# about the lever arm, not the width. So below ROOT_Z there is no plate at all:
# the hook's own root, full width, is what bears on the collar there, and it
# narrows as it goes. One outline from the crown to the tip, no seam.
PLATE_ROUND = 3.0     # top corner rounds. 3 is the most they can take before
                      # they start eating the bevel at z = +10.

# --- hardware ---------------------------------------------------------------
SCREW_D = 4.0         # into timber. Ø4 rather than Ø5: a Ø16 dowel is not much
                      # meat, and a fat screw splits it.
SCREW_CLEAR_D = 4.4   # clearance through BOTH printed parts
SCREW_PILOT_D = 2.5   # pilot in the dowel — drilled through the collar's hole
SCREW_HEAD_D = 8.0    # pan/flange head, on the plate's flat outer face
SCREW_LEN_REC = 30    # see hardware()

# --- the hook, in (X, Z): the plane that contains the dowel ------------------
# Drawn the way a coat hook is: a straight shank that runs without a crease into
# a curl that winds TIGHTER as it goes, the whole stroke tapering root to tip.
#
# The curl is what makes it read as drawn rather than machined. A circular arc
# has the same curvature everywhere and looks like a ring cut in half; here the
# throat's edge is a clothoid — curvature growing steadily from zero, as in a
# road's transition curve — so the shank eases into the bend, the bend opens out
# low down, and it winds up at the tip.
#
# The throat edge is the curve that is drawn. It carries the plate's front edge
# straight on down, so plate and shank are one face; the stroke's width is laid
# off it outward, so the back edge follows by itself. The taper follows the
# bending moment, greatest at the root and nothing at the tip.
#
# The proportion is still the sketch's: about 1.6x further along the rod than it
# hangs below it. HOOK_LEN is the size knob.
ROOT_Z = 2.0          # where the plate ends and the stroke begins: the stroke's
                      # first disc is exactly as wide as the plate, so this edge
                      # is never seen
SHANK_L = 4.0         # straight run of the throat edge below ROOT_Z, before it
                      # starts to bend
HOOK_LEN = 46.0       # length of the throat edge's curl. THE size knob.
CURL = 2.5            # curvature at the tip / curvature where the curl starts.
                      # The curl starts at a THIRD of the tip's curvature, not at
                      # zero, or the first half of it would be nearly straight.
TIP_DEG = 34.0        # how far past bottom dead centre the tip carries on, and
                      # THE number that decides whether it hooks: it costs
                      # nothing in envelope, it only narrows the mouth.
                      # tip_clears_rod() keeps it from rising into the rod's
                      # outline, where it would read as a lump on the dowel.
NECK_L = 32.0         # length over which the stroke narrows from the plate's full
                      # width to the bar, on a cos² ease. Long on purpose: the
                      # back edge stays convex only while the width bends no
                      # faster than the throat edge does.
BAR_T = 7.5           # bar width where the neck ends...
BAR_TIP = 6.0         # ...tapering evenly to this just short of the tip
TIP_BULB_D = 7.0      # and a rounded end, a touch fuller than the bar: enough to
                      # stop a strap walking off, not so much it reads as a knob
TIP_SWELL = 0.10      # fraction of the stroke over which it swells to that
EDGE_CHAMFER = 1.5    # 45° on both faces of the stroke, and on the plate's outer
                      # face, all the way round. Not decoration only: it is the
                      # difference between a strap bearing on a face and on an
                      # edge.
ARC_STEPS = 72

SEGMENTS = 96
EPS = 0.01

# ---------------------------------------------------------------------------
# Derived
# ---------------------------------------------------------------------------
BORE_D = ROD_D + BORE_CLEAR                 # 16.30
BORE_R = BORE_D / 2                         # 8.15
COLLAR_OD = BORE_D + 2 * COLLAR_WALL        # 26.30
COLLAR_R = COLLAR_OD / 2                    # 13.15

# The pad has to stop short of the chamfer, or its square corner pokes out past
# the ring's chamfered one and the silhouette keeps its square corner after all.
# This is the whole reason the collar's corners can be softened at no cost.
PAD_Z0 = -(COLLAR_R - COLLAR_CHAMFER)       # -10.15, clear of the end chamfer
PLATE_Z0 = -COLLAR_R                        # -13.15, level with the collar's underside
BEVEL_Z1 = BEVEL_Z0 + BEVEL_H               # 10.0, top of the bevel
BEVEL_Y1 = FACE_Y - BEVEL_H                 # 12.0, and 45° because they match

PLATE_INNER = FACE_Y + CLEAT_CLEAR          # 15.30, the face that lands on the pad
PLATE_Y = PLATE_INNER + PLATE_T             # 23.50, outer face — screw head seat
# Flush with the collar's crown, not 0.15 proud of it. A step that small is not a
# tolerance, it is a line you can see down the whole length of the part.
PLATE_Z1 = COLLAR_R                         # 13.15, top of the plate
LIP_T = PLATE_Z1 - BEVEL_Z1 - CLEAT_CLEAR   # 2.85, what is left over the bevel
BLANK_INNER = BEVEL_Y1 + CLEAT_CLEAR        # 12.30, how far the lip reaches in

SCREW_Z = 0.0                               # on the rod's axis: a diametral pin
SCREW_X = COLLAR_W / 2                      # 11.0
SCREW_GRIP = PLATE_Y - BORE_R               # 15.35 of printed material to cross

STROKE_L = SHANK_L + HOOK_LEN               # whole throat edge, shank and curl
TURN = radians(180.0 + TIP_DEG)             # heading change, straight down to the tip
# Over the curl, curvature runs linearly from K0 to CURL*K0, and has to add up to
# TURN over HOOK_LEN.
K0 = TURN / (HOOK_LEN * (1.0 + CURL) / 2.0)

assert CLEAT_LIFT > BEVEL_H, "the lift does not clear the bevel"
assert PLATE_INNER > COLLAR_R, "the face does not stand proud of the ring"
assert SCREW_Z + SCREW_CLEAR_D / 2 < BEVEL_Z0 - 1.0, "screw hole runs into the bevel"
# The stroke's first disc is as wide as the plate and centred on its bottom edge:
# it must not bulge out over the plate's top, and the screw head must seat on it.
assert ROOT_Z + COLLAR_W / 2 <= PLATE_Z1, "the root disc bulges above the plate"
assert abs(SCREW_Z - ROOT_Z) + SCREW_HEAD_D / 2 <= COLLAR_W / 2, "no seat for the screw head"
assert BAR_TIP > 2 * EDGE_CHAMFER + 2.0, "the tip is too thin to chamfer"
# The J lives entirely outboard of the collar's face, so it can never be in the
# way when the hook is lifted off. That is the whole reason this arrangement is
# simpler than running the hook across the rod.
assert PLATE_INNER > FACE_Y, "the J would foul the collar on the way off"
# If either edge of the pad reaches past the ring's chamfer, the side view gets
# its square corner back and COLLAR_CHAMFER is wasted.
assert BEVEL_Z1 <= COLLAR_R - COLLAR_CHAMFER + 1e-9, "pad top pokes past the chamfer"
assert PAD_Z0 >= -(COLLAR_R - COLLAR_CHAMFER) - 1e-9, "pad bottom pokes past it"
assert PLATE_Z0 >= -COLLAR_R, "plate hangs below the collar and will show"


# ---------------------------------------------------------------------------
# The joint — 2D in (Y, Z), run along the dowel
# ---------------------------------------------------------------------------
def _collar_parts():
    """The collar's OUTSIDE, as CONVEX pieces, bore not yet cut.

    Convex on purpose. _release_cut() has to sweep this shape along a path, and
    the sweep of a convex piece is just hull(piece, piece moved) — exact, and two
    hulls per move rather than a hundred stacked copies. Sweeping distributes
    over union, so doing the pieces separately and unioning is the same as
    sweeping the whole.

    The pad is drawn from y=0 so its inner half is swallowed by the ring: it
    grows out of the ring's wall with no seam, wherever the OD falls.
    """
    ring = circle(d=COLLAR_OD, _fn=SEGMENTS)
    pad = polygon(
        [
            [0.0, PAD_Z0],
            [FACE_Y, PAD_Z0],
            [FACE_Y, BEVEL_Z0],
            [BEVEL_Y1, BEVEL_Z1],   # <- the cleat: 45°, solid below and inboard,
            [0.0, BEVEL_Z1],        #    so the face looks up and out
        ]
    )
    return [ring, pad]


def collar_envelope():
    ring, pad = _collar_parts()
    return ring + pad


def collar_profile():
    """Ring + pad + bevel, bore cut last."""
    return collar_envelope() - circle(d=BORE_D, _fn=SEGMENTS)


def _release_cut(reach=90.0):
    """The volume the collar sweeps THROUGH THE HOOK as the hook comes off.

    Taking the hook off is two moves: lift it CLEAT_LIFT straight up, which frees
    the bevel, then pull it away. Seen from the hook, the collar goes down and
    then inward. Whatever it passes through cannot be hook — so this swept volume
    is the socket, and cutting it is what makes the joint come apart.

    Cutting the collar's *outline* instead would give a socket that fits like a
    glove and cannot be undone: with a 45° undercut all round, the only way out
    of a conformal pocket is endways, along the dowel. That is a sliding joint,
    not a cleat.
    """
    out = None
    for p in _collar_parts():
        g = offset(delta=CLEAT_CLEAR)(p)
        low = translate([0, -CLEAT_LIFT, 0])(g)
        seg = hull()(g, low) + hull()(low, translate([-reach, 0, 0])(low))
        out = seg if out is None else out + seg
    return out


def plate_section():
    """The hook's cross-section where it meets the collar.

    The blank reaches in to BLANK_INNER — past the top of the bevel — because
    that overhanging corner is the lip, and it is the only thing holding the hook
    on. Everything else the release cut takes back.
    """
    blank = translate([BLANK_INNER, PLATE_Z0, 0])(
        square([PLATE_Y - BLANK_INNER, PLATE_Z1 - PLATE_Z0])
    )
    return blank - _release_cut()


# ---------------------------------------------------------------------------
# The hook — 2D in (X, Z), run across the dowel
# ---------------------------------------------------------------------------
def _width(s):
    """Width of the stroke at distance s along the throat edge.

    Three terms: an even taper from BAR_T to BAR_TIP; the neck, the plate's full
    width falling away over NECK_L on a cos² ease — flat where it leaves the
    plate, flat where it joins the bar; and the rounded end swelling over the
    last TIP_SWELL on a sine ease, so it reads as a rounded end, not a cone.
    """
    t = s / STROKE_L
    w = BAR_T + (BAR_TIP - BAR_T) * t
    n = min(s / NECK_L, 1.0)
    w += (COLLAR_W - BAR_T) * (1.0 - n**3 * (n * (6.0 * n - 15.0) + 10.0))
    u = (t - (1.0 - TIP_SWELL)) / TIP_SWELL
    return w + (TIP_BULB_D - BAR_TIP) * sin(max(0.0, u) * pi / 2)


def _throat_edge():
    """The throat's edge, as (x, z, heading, s) at each step.

    Starts at the plate's front corner (COLLAR_W, ROOT_Z) heading straight down,
    runs straight for SHANK_L, then curls through 180° + TIP_DEG with a curvature
    growing linearly from K0 to CURL*K0.

    Integrated in small steps at the mid-step heading, which is exact for the
    heading and good to well under a print layer for position.
    """
    out, ds = [], STROKE_L / ARC_STEPS
    x, z, th = float(COLLAR_W), ROOT_Z, -pi / 2

    def k(s):
        c = (s - SHANK_L) / HOOK_LEN
        return 0.0 if c < 0 else K0 * (1.0 + (CURL - 1.0) * c)

    for i in range(ARC_STEPS + 1):
        s = i * ds
        out.append((x, z, th, s))
        # heading integrated exactly over the step, position at its mid-heading
        k0, k1 = k(s), k(s + ds)
        dth = (k0 + k1) / 2 * ds
        th_mid = th + (k0 * 3 + k1) / 8 * ds
        x, z, th = x + ds * cos(th_mid), z + ds * sin(th_mid), th + dth
    return out


def _bar_path(f=0.5):
    """The stroke as (x, z, width): each disc laid off the throat edge outward
    by half its width. f=1 gives the back edge instead of the centreline."""
    pts = []
    for x, z, th, s in _throat_edge():
        w = _width(s)
        # outward normal: the hook turns anticlockwise, so it is the right-hand one
        pts.append((x + sin(th) * w * f, z - cos(th) * w * f, w))
    return pts


def _bar():
    """The stroke's 2D silhouette — a circle at every path point, hulled to its
    neighbour. Used for checks and reporting; the printed stroke is the
    chamfered 3D version below."""
    pts = _bar_path()
    out = None
    for (x0, z0, w0), (x1, z1, w1) in zip(pts, pts[1:]):
        seg = hull()(
            translate([x0, z0, 0])(circle(d=w0, _fn=SEGMENTS)),
            translate([x1, z1, 0])(circle(d=w1, _fn=SEGMENTS)),
        )
        out = seg if out is None else out + seg
    return out


def _chamfered_disc(x, z, w):
    """One slice of the stroke: a disc of diameter w in the (X, Z) plane,
    PLATE_T deep in Y, with a 45° chamfer on each face.

    Narrow-wide-wide-narrow along its own axis, so it is convex — which is the
    whole point, because hull() of two convex slices is exactly the swept segment
    between them. Chamfering the 2D profile and extruding could not work: hull on
    a hook-shaped profile would fill the throat.
    """
    c, r = EDGE_CHAMFER, w / 2
    stack = (
        cylinder(h=c, r1=r - c, r2=r, _fn=SEGMENTS)
        + translate([0, 0, c])(cylinder(h=PLATE_T - 2 * c, r=r, _fn=SEGMENTS))
        + translate([0, 0, PLATE_T - c])(
            cylinder(h=c, r1=r, r2=r - c, _fn=SEGMENTS)
        )
    )
    # +z of the stack becomes -Y, so it runs from the plate's outer face inward
    return translate([x, PLATE_Y, z])(rotate([90, 0, 0])(stack))


def _bar_3d():
    """The stroke as printed: chamfered all the way round, both faces."""
    pts = _bar_path()
    out = None
    for p0, p1 in zip(pts, pts[1:]):
        seg = hull()(_chamfered_disc(*p0), _chamfered_disc(*p1))
        out = seg if out is None else out + seg
    return out


def plate_outline():
    """The plate's silhouette in (X, Z): the full length of the collar, from its
    crown down to ROOT_Z, with the top corners rounded.

    It stops square at ROOT_Z, and that edge is never seen: the stroke's first
    disc is exactly as wide as the plate and centred on that edge, so it carries
    both sides on down without a step.
    """
    r = PLATE_ROUND
    return hull()(
        translate([r, PLATE_Z1 - r, 0])(circle(r=r, _fn=SEGMENTS)),
        translate([COLLAR_W - r, PLATE_Z1 - r, 0])(circle(r=r, _fn=SEGMENTS)),
        translate([0.0, ROOT_Z, 0])(square([COLLAR_W, EPS])),
    )


def hook_profile():
    """The hook's whole silhouette, plate and stroke, unchamfered."""
    return plate_outline() + _bar()


# ---------------------------------------------------------------------------
# 3D — built in world coordinates, because the two halves of the hook run along
# different axes and only the world frame holds both.
# ---------------------------------------------------------------------------
def _screw_hole(d=SCREW_CLEAR_D, reach=70.0):
    """Clearance hole on the rod's axis, in WORLD coordinates (axis = Y)."""
    return translate([SCREW_X, reach / 2, SCREW_Z])(
        rotate([90, 0, 0])(cylinder(h=reach, d=d, _fn=SEGMENTS // 2))
    )


def _bore_chamfers():
    """45° lead-in at each end of the bore, in world coordinates."""
    lo = translate([-EPS, 0, 0])(
        rotate([0, 90, 0])(
            cylinder(h=BORE_CHAMFER + EPS, r1=BORE_R + BORE_CHAMFER, r2=BORE_R, _fn=SEGMENTS)
        )
    )
    hi = translate([COLLAR_W - BORE_CHAMFER, 0, 0])(
        rotate([0, 90, 0])(
            cylinder(h=BORE_CHAMFER + EPS, r1=BORE_R, r2=BORE_R + BORE_CHAMFER, _fn=SEGMENTS)
        )
    )
    return lo + hi


def _along_rod(section2d, length=COLLAR_W, x0=0.0):
    """Run a (Y, Z) section along the dowel. (x, y, z)_2d -> (z, x, y)."""
    return translate([x0, 0, 0])(rotate([90, 0, 90])(linear_extrude(height=length)(section2d)))


def _across_rod(profile2d, thickness=PLATE_T, y1=PLATE_Y):
    """Run an (X, Z) profile across the dowel, ending at y1 and growing inward.
    (x, y, z)_2d -> (x, -z, y)."""
    return translate([0, y1, 0])(rotate([90, 0, 0])(linear_extrude(height=thickness)(profile2d)))


def _collar_end_chamfers():
    """45° chamfer on the ring's OUTER edge at both ends.

    The tool only reaches COLLAR_R + 1, so it takes the ring's edge and leaves
    the pad alone — which is what is wanted: above z = +10 the collar is pure
    ring, and those are the corners that show above the plate in the side view.
    Reaching further would chamfer the cleat's bevel at its ends and cost bearing
    length for no visible gain, since the plate hides the pad from that side.
    """
    c, outer = COLLAR_CHAMFER, COLLAR_R + 1.0

    def ring(r1, r2):
        return cylinder(h=c + 2 * EPS, r=outer, _fn=SEGMENTS) - cylinder(
            h=c + 2 * EPS, r1=r1, r2=r2, _fn=SEGMENTS
        )

    lo = translate([-EPS, 0, 0])(
        rotate([0, 90, 0])(ring(COLLAR_R - c - EPS, COLLAR_R + EPS))
    )
    hi = translate([COLLAR_W - c - EPS, 0, 0])(
        rotate([0, 90, 0])(ring(COLLAR_R + EPS, COLLAR_R - c - EPS))
    )
    return lo + hi


def collar_world():
    """The collar as installed."""
    return (
        _along_rod(collar_profile())
        - _screw_hole()
        - _bore_chamfers()
        - _collar_end_chamfers()
    )


def _plate_prism():
    """plate_outline() run across the rod, with the same 45° chamfer on its outer
    face as the stroke has, so where the two meet the edge carries straight on.

    The outline is convex, so the chamfer is just a hull of the outline and the
    outline shrunk by EDGE_CHAMFER. It spans the plate's WHOLE depth in Y, back to
    BLANK_INNER — cut it at PLATE_T and it would shear the lip off. The inner face
    stays square: it is the face that bears on the collar.
    """
    c, depth = EDGE_CHAMFER, PLATE_Y - BLANK_INNER
    outline = plate_outline()
    prism = hull()(
        translate([0, 0, c])(linear_extrude(height=depth - c)(outline)),
        linear_extrude(height=depth)(offset(delta=-c)(outline)),
    )
    return translate([0, PLATE_Y, 0])(rotate([90, 0, 0])(prism))


def hook_world():
    """The hook as installed: the plate run along the rod, the stroke run across
    it, sharing one slab of Y so they merge into a single body."""
    # The plate is the (Y, Z) section run along the rod, then trimmed to its
    # silhouette.
    plate = _along_rod(plate_section()) * _plate_prism()
    return (plate + _bar_3d()) - _screw_hole()


def _to_bed(part):
    """World -> collar bed orientation: (x, y, z) -> (y, z, x), so the section
    lies on the bed and the ring axis points up.

    _along_rod() maps the other way, (x,y,z) -> (z,x,y). That is a 3-cycle, so
    its inverse is itself applied twice — which is all this is.
    """
    return rotate([90, 0, 90])(rotate([90, 0, 90])(part))


def collar_print():
    """Bed orientation: section on the bed, ring axis up, extruded COLLAR_W up.
    Slice as-is — every layer is identical."""
    return _to_bed(collar_world())


def hook_print():
    """Bed orientation: lying on its OUTER face, building inward toward the
    collar. World (x,y,z) -> (x, z, PLATE_Y - y). Slice as-is — the first layer
    is the whole silhouette and material only ever goes away above it."""
    return translate([0, 0, PLATE_Y])(rotate([-90, 0, 0])(hook_world()))


# ---------------------------------------------------------------------------
# Ghosts — fit check only, never printed
# ---------------------------------------------------------------------------
def rod_ghost(overhang=25.0, drilled=True):
    """The Ø16 dowel, with the pilot hole the collar drills for you."""
    rod = translate([-overhang, 0, 0])(
        rotate([0, 90, 0])(cylinder(h=COLLAR_W + 2 * overhang, d=ROD_D, _fn=SEGMENTS))
    )
    if drilled:
        rod -= translate([SCREW_X, -BORE_R, SCREW_Z])(
            rotate([-90, 0, 0])(cylinder(h=ROD_D, d=SCREW_PILOT_D, _fn=SEGMENTS // 2))
        )
    return rod


def screw_ghost(length=SCREW_LEN_REC, head_h=3.0):
    """Ø4 wood screw as fitted: head on the plate, tip buried in the dowel."""
    head = translate([SCREW_X, PLATE_Y, SCREW_Z])(
        rotate([-90, 0, 0])(cylinder(h=head_h, d=SCREW_HEAD_D, _fn=SEGMENTS // 2))
    )
    shank = translate([SCREW_X, PLATE_Y, SCREW_Z])(
        rotate([90, 0, 0])(cylinder(h=length, d=SCREW_D, _fn=SEGMENTS // 2))
    )
    return head + shank


def fit_check(section=False):
    """Both parts on the dowel with the screw. The section is taken across the
    rod on the screw's axis — the plane the whole joint lives in."""
    asm = collar_world() + hook_world() + rod_ghost() + screw_ghost()
    if section:
        asm -= translate([SCREW_X, -70, -80])(cube([120, 140, 120]))
    return asm


def exploded(lift=None, out=30.0):
    """The hook taken off the way it actually comes off: up to clear the bevel,
    then away."""
    lift = CLEAT_LIFT if lift is None else lift
    return (
        collar_world()
        + translate([0, out, lift])(hook_world())
        + rod_ghost(drilled=False)
    )


# ---------------------------------------------------------------------------
# Coupon — print this first
# ---------------------------------------------------------------------------
def coupon_cleat(length=8.0, gap=10.0):
    """A short slice of the joint, both halves, each in its own print
    orientation and laid out ready to slice.

    It settles CLEAT_CLEAR, the one number that cannot be calculated for your
    printer. The slice is taken clear of the screw hole and the J, so it tests
    the cleat alone: drop one onto the other and it should land with a click and
    no rock.
    """
    win = translate([-EPS, -60, PLATE_Z0])(cube([length + EPS, 140, 120]))
    col = collar_world() * win
    hk = hook_world() * win
    col = _to_bed(col)
    hk = translate([0, 0, PLATE_Y])(rotate([-90, 0, 0])(hk))
    return col + translate([COLLAR_R + PLATE_Y + gap, 0, 0])(hk)


def plate(gap=12.0):
    """Both parts on one bed, each already in its own print orientation."""
    return collar_print() + translate([COLLAR_R + mouth()[3] + gap, 0, 0])(
        hook_print()
    )


# ---------------------------------------------------------------------------
# Info
# ---------------------------------------------------------------------------
def hardware(bite=None):
    """Screw length: head face to tip, for a given bite into the dowel.

    The default takes the tip to 1.5 mm short of the far side of the dowel — as
    much thread as the timber has to give, without breaking out.
    """
    bite = (BORE_R + ROD_D / 2 - 1.5) if bite is None else bite
    return SCREW_GRIP, SCREW_GRIP + bite


def _seg_dist(x, z, p0, p1):
    """Distance from (x, z) to the surface of the swept segment p0-p1, negative
    inside it."""
    (x0, z0, w0), (x1, z1, w1) = p0, p1
    dx, dz = x1 - x0, z1 - z0
    L2 = dx * dx + dz * dz
    t = 0.0 if L2 == 0 else max(0.0, min(1.0, ((x - x0) * dx + (z - z0) * dz) / L2))
    return hypot(x - (x0 + t * dx), z - (z0 + t * dz)) - (w0 + t * (w1 - w0)) / 2


def _hook_dist(x, z):
    """Distance from (x, z) to the hook's silhouette, negative inside it.
    Analytic, so capacity() can search without meshing anything. The plate's
    corner rounds are ignored, which only makes it answer 'nearer' slightly too
    often — the safe direction."""
    dx = max(0.0 - x, 0.0, x - COLLAR_W)
    dz = max(ROOT_Z - z, 0.0, z - PLATE_Z1)
    d = hypot(dx, dz) if (dx or dz) else -1.0
    pts = _bar_path()
    return min([d] + [_seg_dist(x, z, p0, p1) for p0, p1 in zip(pts, pts[1:])])


def _held(x, z, reach=80.0, step=0.5):
    """Would something at (x, z) be held by the hook — is there hook below it,
    and on both sides of it? Without this, the search would happily report a
    big circle out in the open, under or beside the hook."""
    def hits(ux, uz):
        return any(
            _hook_dist(x + ux * t, z + uz * t) <= 0.0
            for t in [step * i for i in range(1, int(reach / step))]
        )
    return hits(0, -1) and hits(-1, 0) and hits(1, 0)


_CAPACITY = None


def capacity():
    """The largest circle that actually FITS inside the hook, and where.

    Found by search rather than derived, because no single radius describes a
    stroke whose curvature changes along it — and because the plate overhangs
    the throat's upper-left, which no formula for the curve would see.

    Coarse sweep for the best centre, then refine. Cached, because summary()
    asks for it every build.
    """
    global _CAPACITY
    if _CAPACITY is not None:
        return _CAPACITY

    bx, bz, _, _, _ = _envelope()
    best_r, best_c, step = 0.0, (bx, bz + 10.0), 1.0
    for _ in range(3):                      # sweep, then twice around the winner
        cx, cz = best_c
        span = 16.0 if step == 1.0 else 2 * step
        n = int(2 * span / step) + 1
        for i in range(n):
            for j in range(n):
                x, z = cx - span + i * step, cz - span + j * step
                r = _hook_dist(x, z)
                if r > best_r and _held(x, z):
                    best_r, best_c = r, (x, z)
        step /= 4
    _CAPACITY = (2 * best_r, best_c)
    return _CAPACITY


def tip_clears_rod():
    """How far the tip's top sits below the rod's own outline, seen from the side.

    Not a clearance — the hook is 15 mm out in Y and can never touch the dowel.
    It is a composition check: once the tip breaks the rod's silhouette it stops
    reading as the end of a hook and starts reading as a lump on the dowel.
    Positive is good, and TIP_DEG is what moves it.
    """
    return ROD_D / 2 - max(z + w / 2 for _, z, w in _bar_path()[-ARC_STEPS // 6:])


def _envelope():
    """(bight_x, bight_z, drop, reach, tip_z), scanned off the swept path rather
    than derived, so they are the real envelope of the stroke, taper and all.
    The bight is the lowest point of the centreline: where a load ends up."""
    pts = _bar_path()
    bx, bz, _ = min(pts, key=lambda p: p[1])
    reach = max(x + w / 2 for x, _, w in pts)
    drop = abs(min(z - w / 2 for _, z, w in pts))
    return bx, bz, drop, reach, pts[-1][1]


def mouth():
    """Clear opening, what fits inside, and the envelope.

    The mouth is the narrowest gap between the tip and the rest of the hook —
    the plate, or the stroke's own root. It is the plate that closes this hook's
    mouth, exactly as the collar closes the reference part's.
    """
    pts = _bar_path()
    tip = pts[-ARC_STEPS // 8:]
    root = pts[: ARC_STEPS // 2]
    clear = float("inf")
    for p in tip:
        x, z, w = p
        # to the plate's rectangle
        dx = max(0.0 - x, 0.0, x - COLLAR_W)
        dz = max(ROOT_Z - z, 0.0, z - PLATE_Z1)
        clear = min(clear, hypot(dx, dz) - w / 2)
        for q0, q1 in zip(root, root[1:]):
            clear = min(clear, _seg_dist(x, z, q0, q1) - w / 2)
    _, _, drop, reach, tip_z = _envelope()
    return clear, capacity()[0], drop, reach, tip_z


# Purely about looks: keep the tip inside the dowel's own silhouette or it reads
# as a lump growing out of the rod.
assert tip_clears_rod() >= 0.5, "the tip breaks the rod's outline — drop TIP_DEG"


def summary():
    grip, screw_len = hardware()
    clear, fits, drop, reach, tip_z = mouth()
    bight_x = _envelope()[0]
    return "\n".join(
        [
            f"collar           Ø{COLLAR_OD:.1f} x {COLLAR_W:.0f} long, plain bore "
            f"Ø{BORE_D:.2f} on a Ø{ROD_D} dowel — no clamp, it slides on",
            f"hook             runs ALONG the rod: {reach:.0f} mm from x=0 (the collar "
            f"ends at {COLLAR_W:.0f}), hanging {drop:.0f} mm below the axis",
            f"                 along/deep = {reach / drop:.2f} — the rod's direction is the "
            f"long axis, which is the whole point",
            f"                 {PLATE_T} mm thick, so it stands only "
            f"{PLATE_Y - COLLAR_R:.1f} mm proud of the collar",
            f"cleat            French cleat: flat face to z=+{BEVEL_Z0:.0f}, then one 45° "
            f"bevel {BEVEL_H:.0f} mm deep x {COLLAR_W:.0f} long",
            f"screw            1x Ø{SCREW_D:.0f} x {screw_len:.0f} into the dowel, ON ITS "
            f"AXIS — a diametral pin, not a friction grip",
            "",
            "the hook itself",
            f"  mouth          {clear:.0f} mm clear, opening upward and along the rod",
            f"  swallows       Ø{fits:.0f} — the largest thing that actually fits, found",
            f"                 by search. See capacity().",
            f"  stroke         one curve, curling {CURL}x tighter at the tip than where it",
            f"                 leaves the shank;",
            f"                 off the plate at its full {COLLAR_W:.0f} mm, narrowing to "
            f"{BAR_T} and on to {BAR_TIP} at the tip,",
            f"                 {PLATE_T} deep, both faces chamfered {EDGE_CHAMFER}x45°",
            f"  tip            Ø{TIP_BULB_D} rounded end, turned {TIP_DEG:.0f}° back up past "
            f"bottom dead centre, to z = {tip_z:+.0f}",
            f"  mouth/fits     {clear / fits:.2f} — under 1, so what goes in cannot fall "
            f"straight back out",
            f"  tip vs the rod {tip_clears_rod():+.1f} mm inside the dowel's own outline, seen "
            f"from the side",
            f"                 — past 0 the tip reads as a lump on the rod. TIP_DEG sets it.",
            f"  bottom of the   x = {bight_x:.0f}, which is {bight_x - COLLAR_W / 2:.0f} mm "
            f"past the middle of the collar",
            f"  bight           — hang the load THERE, not out at the tip",
            "",
            "how the load gets to the rod",
            f"  down           onto the 45° bevel, over the full {COLLAR_W:.0f} mm",
            f"  pitching out   as a couple — the bevel pulls in at z = +{BEVEL_Z1:.0f}, the",
            f"                 plate bears out on the flat down to z = {PLATE_Z0:.0f}. The load",
            f"                 hangs only {(PLATE_INNER + PLATE_Y) / 2 - FACE_Y:.1f} mm off "
            f"that face, so this couple is small.",
            f"  twisting       THE one to watch, now the hook runs along the rod. A load",
            f"                 in the bight acts {bight_x - COLLAR_W / 2:.0f} mm off the "
            f"collar's centre and only the",
            f"                 cleat's {COLLAR_W:.0f} mm of length resists it — about "
            f"{(bight_x - COLLAR_W / 2) / COLLAR_W:.1f}x the load at each",
            f"                 end of the bevel. At the tip ({reach:.0f} mm) it is "
            f"{(reach - COLLAR_W / 2) / COLLAR_W:.1f}x. That is the",
            f"                 reason the plate is the full {COLLAR_W:.0f} mm and not a "
            f"narrow lug.",
            f"  into the rod   the screw, in shear across the dowel's axis",
            f"  the screw      never carries the hook's weight, only stops it lifting",
            "",
            "taking it off",
            f"  lift {CLEAT_LIFT:.0f} mm, then pull away. That release path is cut out of the",
            f"  hook by construction. The J is entirely outboard of the collar's face,",
            f"  so it is never in the way — nothing else to check.",
            "",
            "printing — two parts, two orientations, no supports, nothing to rotate",
            f"  collar         stands on its section, ring axis up. Every layer",
            f"                 identical: no overhang at all, and a round bore.",
            f"  hook           lies on its OUTER face and builds inward. First layer is",
            f"                 the whole silhouette; above it material only goes away.",
            f"  the exception  the Ø{SCREW_CLEAR_D} screw hole is horizontal in the collar. It",
            f"                 bridges. In the hook it is vertical.",
            f"  layers         the hook bends within its layers, not across them",
            f"  settings       0.2 mm, 4 perimeters, >=40% infill. PETG over PLA:",
            f"                 PLA creeps under a standing load.",
            "",
            "print coupon_cleat.stl first",
            f"  an {8.0:.0f} mm slice of both halves, clear of the screw and the J. Drop one",
            f"  onto the other: it should land with a click and no rock. Sticks -> raise",
            f"  CLEAT_CLEAR (now {CLEAT_CLEAR}); rocks -> lower it.",
            "",
            "assembly",
            f"  1. hook onto collar FIRST, on the bench — it drops on from above, so",
            f"     it wants {CLEAT_LIFT:.0f} mm of headroom you may not have on the rod",
            f"  2. slide the pair on from the end of the dowel, to where you want it",
            f"  3. drill Ø{SCREW_PILOT_D} through the collar's own hole — it is the jig, so",
            f"     the pilot lands on the rod's axis and cannot wander",
            f"  4. Ø{SCREW_D:.0f} x {screw_len:.0f} screw; {grip:.1f} mm of it crosses the "
            f"printed parts",
            f"  If the rod is metal, tap it M{SCREW_D:.0f} instead — same hole.",
        ]
    )


def build(out_dir=None):
    """Write every scad/stl next to this file."""
    path = Path(out_dir) if out_dir else Path(__file__).parent

    col = collar_print()
    col.save_as_scad(filename=str(path / "rod_hook_collar.scad"))
    col.save_as_stl(filename=str(path / "rod_hook_collar.stl"))

    hk = hook_print()
    hk.save_as_scad(filename=str(path / "rod_hook_hook.scad"))
    hk.save_as_stl(filename=str(path / "rod_hook_hook.stl"))

    plate().save_as_stl(filename=str(path / "rod_hook_plate.stl"))
    coupon_cleat().save_as_stl(filename=str(path / "coupon_cleat.stl"))
    fit_check().save_as_stl(filename=str(path / "fit_check.stl"))
    fit_check(section=True).save_as_stl(filename=str(path / "fit_check_section.stl"))
    exploded().save_as_stl(filename=str(path / "exploded.stl"))
    return path


if __name__ == "__main__":
    print(summary())
    print(f"\nwritten to {build()}")
