"""Both-sides wrapping servo holder (iteration v1) for the Feetech STS3215.

Design intent (Cameron): TOM's servo_end_mount bolts to ONE face (cantilevered).
This instead CRADLES the body — a sleeve wrapping the X-Z faces, closed by flanges
on BOTH the +Y (horn-side) and -Y (idler-side) mount faces, each bolted to the
servo's REAL extracted corner holes (hole_markers.json). TOM-inspired exactness,
two-sided robustness.

Servo STEP frame (mm): output axis +Y through (-25.5,0); horn protrudes +Y at x=-25.5.
Body bbox X[-35.6,9.6] Y[-28.2,9.6] Z[-12.4,12.4].
"""
from build123d import Box, Cylinder, Pos, Rot, export_stl

SERVO_STEP = "/data/cameron/repos/cad_experiments/parts/waveshare_feetech_st3215_servo.step"

# --- servo body extents (mm) ---
BX0, BX1 = -35.6, 9.6
BZ0, BZ1 = -12.4, 12.4
# --- mount-hole planes along the output axis ---
TOP_Y, BOT_Y = 6.4, -25.6
TOP_HOLES = [(-17.2, 10.2), (-17.2, -10.2), (3.5, 10.2), (3.5, -10.2)]   # (x,z) on +Y face
BOT_HOLES = [(-17.2, 10.2), (-17.2, -10.2), (7.2, 10.2), (7.2, -10.2)]   # (x,z) on -Y face
# --- design params (the loop tunes these) ---
CLEAR = 0.4      # print clearance to body (sleeve cavity)
SERVO_CLEAR = 0.3  # clearance of the servo-trim: 0 = surfaces touch; ~0.3 = real-print gap
WALL = 3.0       # sleeve wall thickness
FT = 3.0         # flange thickness (along Y)
BOLT_D = 3.6     # M3 clearance
CB_D = 6.4       # M3 socket-head counterbore dia
CB_DEPTH = 2.4   # counterbore depth into the flange outer face (head sits flush)
BOSS_R = 5.2     # screw-boss radius: full collar of material around each bolt hole
                 # (>= CB_D/2 + wall) so the hole is CLOSED, not open-faced
# The yoke grips BOTH ±Y faces across the whole -X half (horn plate AND idler plate are
# full-width, reaching to x~-15). So the holder can only bolt the OUTBOARD holes (x>=3.5);
# the inboard (-17.2) holes sit in the yoke's territory. Both flanges outboard-only.
TOP_X0 = -2.0    # +Y flange start (outboard; clears horn-side yoke plate)
BOT_X0 = -2.0    # -Y flange start (outboard; clears idler-side yoke plate)
FZ = 9.5         # flange open-center half-width: leaves z in (-FZ,FZ) open so the
                 # horn/idler + yoke (z<=~9) clear; bolt rows live at z=+-10.2 (outside FZ)
WIN_B = 4.5      # border left around the lightening window in each large +-Z side wall
# The yoke rotates about the output axis (+Y through x=-25.5); its spine orbits at
# radius ~19.5. Carve a cylindrical sweep-relief about that axis so the holder never
# obstructs the joint. Holder then wraps the body's non-output (+X) portion.
SWEEP_AX_X = -25.5
R_SWEEP = 24.0


def _box(x0, x1, y0, y1, z0, z1):
    return Pos((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2) * Box(x1 - x0, y1 - y0, z1 - z0)


_SERVO = None


def _servo():
    """Cached servo solid for trimming (so screw bosses conform to the real body)."""
    global _SERVO
    if _SERVO is None:
        from build123d import import_step
        _SERVO = import_step(SERVO_STEP)
    return _SERVO


def _trim_servo(part):
    """Subtract the servo (+SERVO_CLEAR gap) from the holder. OCP offset() fails on the
    detailed geared servo, so dilate by subtracting the servo plus 6 axis-shifted copies
    (octahedral Minkowski — robust, guaranteed to contain the raw servo)."""
    part = part - _servo()
    if SERVO_CLEAR > 0:
        c = SERVO_CLEAR
        for d in [(c, 0, 0), (-c, 0, 0), (0, c, 0), (0, -c, 0), (0, 0, c), (0, 0, -c)]:
            part = part - (Pos(*d) * _servo())
    return part


def make_wrap_holder(trim=True):
    ix0, ix1 = BX0 - CLEAR, BX1 + CLEAR
    iz0, iz1 = BZ0 - CLEAR, BZ1 + CLEAR
    ox0, ox1 = ix0 - WALL, ix1 + WALL
    oz0, oz1 = iz0 - WALL, iz1 + WALL

    # sleeve: 4 walls wrapping X-Z over the body's Y span
    part = _box(ox0, ox1, BOT_Y, TOP_Y, oz0, oz1) - _box(ix0, ix1, BOT_Y - 1, TOP_Y + 1, iz0, iz1)

    # flanges = two outer-z strips per face (open center clears horn/idler + yoke; lighter)
    for (x0, y0, y1) in [(TOP_X0, TOP_Y, TOP_Y + FT), (BOT_X0, BOT_Y - FT, BOT_Y)]:
        part = part + _box(x0, ox1, y0, y1, FZ, oz1)
        part = part + _box(x0, ox1, y0, y1, oz0, -FZ)

    # lightening window through each large +-Z side wall (framed; +-Y ends already open
    # for cable routing). Border WIN_B keeps the frame rigid; doesn't touch the flanges.
    wx0, wx1 = ox0 + WIN_B, ox1 - WIN_B
    wy0, wy1 = BOT_Y + WIN_B, TOP_Y - WIN_B
    part = part - _box(wx0, wx1, wy0, wy1, iz1 - 1, oz1 + 1)
    part = part - _box(wx0, wx1, wy0, wy1, oz0 - 1, iz0 + 1)

    # cylindrical sweep-relief about the output axis so the rotating yoke clears
    part = part - (Pos(SWEEP_AX_X, 0, 0) * Rot(90, 0, 0) * Cylinder(R_SWEEP, 80))

    # bolt holes into the real servo holes (axis +Y); only those covered by their flange
    # screw bosses FIRST (full collar of material around each hole), then drill/counterbore
    for (x, z) in TOP_HOLES:
        if x >= TOP_X0:
            part = part + (Pos(x, TOP_Y + FT / 2, z) * Rot(90, 0, 0) * Cylinder(BOSS_R, FT))
            part = part - (Pos(x, TOP_Y + FT / 2, z) * Rot(90, 0, 0) * Cylinder(BOLT_D / 2, FT + 2))
            part = part - (Pos(x, (TOP_Y + FT) - CB_DEPTH / 2, z) * Rot(90, 0, 0) * Cylinder(CB_D / 2, CB_DEPTH))
    for (x, z) in BOT_HOLES:
        if x >= BOT_X0:
            part = part + (Pos(x, BOT_Y - FT / 2, z) * Rot(90, 0, 0) * Cylinder(BOSS_R, FT))
            part = part - (Pos(x, BOT_Y - FT / 2, z) * Rot(90, 0, 0) * Cylinder(BOLT_D / 2, FT + 2))
            part = part - (Pos(x, (BOT_Y - FT) + CB_DEPTH / 2, z) * Rot(90, 0, 0) * Cylinder(CB_D / 2, CB_DEPTH))

    # trim against the real servo so the bosses/walls conform to the body (max closed
    # collar that physically fits; never intrudes into the servo)
    if trim:
        part = _trim_servo(part)
    return part


if __name__ == "__main__":
    p = make_wrap_holder()
    # drop zero-volume boolean slivers from the servo-trim; keep the real holder solid
    real = [s for s in p.solids() if s.volume > 1.0]
    part = max(real, key=lambda s: s.volume) if real else p
    export_stl(part, "wrap_holder.stl", tolerance=0.1, ascii_format=False)
    print(f"wrote wrap_holder.stl  vol={part.volume:.0f}mm3  real_solids={len(real)} (raw {len(p.solids())})")
