"""SwitchBot Blind Tilt cylinder bracket, from spannerandbyte.com (free to use and change).
Needs build123d: pip install build123d, then: python switchbot-blind-tilt-bracket.py
Measure your own GAP (window frame face to the nearest side of the Blind Tilt body) first.

Cylinder (D=29.3) snaps into a C-clip; a flat plate screws/sticks to the window frame.
Axes: X = across the frame, Y = out from the wall (wall face at y=0), Z = up (cylinder axis).
"""
from build123d import *
import sys

# --- measured ---
CYL_D   = 29.3      # cylinder diameter
GAP     = 23.5      # wall face -> nearest surface of the cylinder
# --- design ---
CLR     = -0.3      # negative = crush fit: ring ID is 29.0 on a 29.3 cylinder
WALL    = 2.5       # clip wall
CLIP_H  = 30.0      # clip height along cylinder axis
OPEN_W  = 27.0      # mouth width (0.92 x D) -> ~1.15mm interference per side to snap over
PLATE_W, PLATE_T, PLATE_H = 50.0, 3.0, 30.0
ARM_W   = 20.0
SCREW_D, CSK_D, SCREW_X = 4.2, 8.5, 19.0

R_in  = (CYL_D + CLR) / 2
R_out = R_in + WALL
CY    = GAP + R_in                     # cylinder centre, measured out from the wall
ARM_END = CY                           # arm runs to the ring centre; the bore cut trims it -> corners filled solid

assert OPEN_W < CYL_D, "mouth must be narrower than the cylinder or it will not hold"
assert ARM_END > PLATE_T, "no room for an arm: GAP too small for plate + clip wall"

with BuildPart() as part:
    # plate on the wall
    Box(PLATE_W, PLATE_T, PLATE_H, align=(Align.CENTER, Align.MIN, Align.CENTER))
    # arm out to the clip
    with Locations((0, PLATE_T, 0)):
        Box(ARM_W, ARM_END - PLATE_T, CLIP_H, align=(Align.CENTER, Align.MIN, Align.CENTER))
    # C-clip ring
    with Locations((0, CY, 0)):
        Cylinder(R_out, CLIP_H)
        Cylinder(R_in, CLIP_H + 2, mode=Mode.SUBTRACT)
        # mouth, opening away from the wall
        Box(OPEN_W, R_out + 2, CLIP_H + 2, align=(Align.CENTER, Align.MIN, Align.CENTER), mode=Mode.SUBTRACT)
    # screw holes through the plate (along Y) with countersink on the front face
    with Locations((SCREW_X, 0, 0), (-SCREW_X, 0, 0)):
        Cylinder(SCREW_D / 2, PLATE_T * 3, rotation=(90, 0, 0), mode=Mode.SUBTRACT)
    with Locations((SCREW_X, PLATE_T, 0), (-SCREW_X, PLATE_T, 0)):
        Cone(CSK_D / 2, 0, CSK_D / 2, rotation=(90, 0, 0),
             align=(Align.CENTER, Align.CENTER, Align.MAX), mode=Mode.SUBTRACT)
    # soften the plate's outer vertical corners
    try:
        outer = part.edges().filter_by(Axis.Z).filter_by(lambda e: abs(abs(e.center().X) - PLATE_W/2) < 0.01)
        fillet(outer, 1.4)
    except Exception as e:
        print("plate fillet skipped:", e, file=sys.stderr)
    # lead-in on the mouth lips (inner vertical edges at the tips)
    try:
        lips = part.edges().filter_by(Axis.Z).filter_by(
            lambda e: abs(abs(e.center().X) - OPEN_W/2) < 0.01 and e.center().Y > CY)
        chamfer(lips, 1.2)
    except Exception as e:
        print("lip chamfer skipped:", e, file=sys.stderr)

solid = part.part
bb = solid.bounding_box()
print(f"cylinder centre {CY:.2f}mm from wall; ring inner {CY-R_in:.2f}..{CY+R_in:.2f}")
print(f"bbox X {bb.min.X:.1f}..{bb.max.X:.1f}  Y {bb.min.Y:.1f}..{bb.max.Y:.1f}  Z {bb.min.Z:.1f}..{bb.max.Z:.1f}")
print(f"volume {solid.volume/1000:.1f} cm3, valid={solid.is_valid}")

# guards: the real cylinder must not intersect the bracket; mouth narrower than cylinder
probe = Cylinder(CYL_D/2, CLIP_H - 1).moved(Location((0, CY, 0)))
clash = solid.intersect(probe)
cv = 0 if clash is None else sum(s.volume for s in clash) if isinstance(clash, list) else clash.volume
print(f"designed interference volume: {cv:.1f} mm3 (crush fit)")
assert (CY - CYL_D/2) >= GAP + min(CLR,0)/2 - 1e-6, "gap to wall shrank"

export_stl(solid, "switchbot-blind-tilt-bracket.stl", tolerance=0.02, angular_tolerance=0.1)
print("STL written")
