#!/usr/bin/env python3
from build123d import *
from math import sqrt, tan, radians, ceil

# ---------------- parameters ----------------
mount_width    = 18   # MINIMUM mount Y width (tape width); snapped up to fit the slot pattern
mount_fracs    = (0.25, 0.75)  # target mount centers as fractions of the full holder Y size
soap_width     = 100  # usable Y span of the interior
soap_height    = 60
soap_depth     = 25
thumb_slot     = 25   # thumb slot diameter in Y
wall_thickness = 2
spine_width    = 4
spine_height   = 4
wire_width     = 2    # positive material left between slots / thumb frame width
num_slots      = 19   # number of drainage slots
taper_angle    = 30   # slot tip half-angle, degrees from the X axis (in XY)
top_radius     = soap_depth  # fillet radius of the top corner of the end walls (XZ)

# Edge-fillet radii. Kept a little under half the feature width so opposing
# fillets on the same feature never consume it completely.
wall_fillet    = 0.45 * wall_thickness   # perimeter edges of the end walls
lip_fillet     = 1.5                     # free-end edges of the base plates
thumb_fillet   = 0.45 * wire_width       # rim of the thumb slot (frame is wire_width wide)
thumb_tip_gap  = 1.0                     # pull the thumb slot's spine-end tip back from the crease (fillet needs it)

# ---------------- derived ----------------
total_y = soap_width + 2 * wall_thickness
c = 1 / sqrt(2)                       # cos/sin of 45 degrees
tip = (0, spine_height)               # apex of the spine

base_t = (spine_height + spine_width / 2) * c

slot_y = (soap_width - (num_slots - 1) * wire_width) / num_slots
assert slot_y > 0, "wire_width/num_slots too large for soap_width"
slot_pitch = slot_y + wire_width

mount_k = max(1, ceil((mount_width - wire_width) / slot_pitch - 1e-9))
mount_w = wire_width + mount_k * slot_pitch
assert mount_k <= num_slots - 2, "mount too wide for the slot pattern"

assert 0 < top_radius <= min(soap_depth, soap_height), "top_radius too large for the box"

def pt(u, v):
    """XZ point from tilted local coords, relative to the spine tip.
    u runs up-left (soap depth direction), v runs up-right (soap height direction)."""
    return (tip[0] + (v - u) * c, tip[1] + (u + v) * c)

def xz_prism(points, y_center, y_len):
    """Extrude an XZ polygon in Y, centered at y_center with length y_len."""
    face = Plane.XZ * Polygon(*points, align=None)
    return Pos(0, y_center, 0) * extrude(face, amount=y_len / 2, both=True)

def xz_prism_face(face_xz, y_center, y_len):
    """Extrude an arbitrary face (drawn in XY as X,Z) as an XZ profile."""
    face = Plane.XZ * face_xz
    return Pos(0, y_center, 0) * extrude(face, amount=y_len / 2, both=True)

def safe_fillet(solid, edges, radius, label, tries=(1.0, 0.8, 0.6, 0.4)):
    """Fillet `edges` of `solid`; on failure retry with smaller radii, and if
    everything fails return the solid unchanged (never kills the build)."""
    edges = list(edges)
    if not edges:
        print(f"[fillet] {label}: no edges selected")
        return solid
    for k in tries:
        try:
            result = fillet(edges, radius * k)
            if result.is_valid and result.volume > 0:
                print(f"[fillet] {label}: {len(edges)} edges, r = {radius * k:.3f}")
                return result
        except Exception as e:
            pass
    print(f"[fillet] {label}: FAILED, left sharp")
    return solid

w = wall_thickness
left_corner  = (-spine_width / 2, 0)
right_corner = ( spine_width / 2, 0)

# ---------------- spine ----------------
# Shortened by the wall thickness at each end so its sharp bottom ends are
# hidden inside the (filleted) end walls.
spine = xz_prism([left_corner, right_corner, tip], 0, total_y - 2 * w)

# ---------------- base ----------------
base = xz_prism(
    [left_corner, pt(soap_depth, -base_t), pt(soap_depth, 0),
     pt(0, 0), pt(0, soap_height), pt(-base_t, soap_height), right_corner],
    0, total_y,
)

# ---------------- end walls: top corner filleted ----------------
r = top_radius
t1  = pt(soap_depth, soap_height - r)
t2  = pt(soap_depth - r, soap_height)
mid = pt(soap_depth - r + r * c, soap_height - r + r * c)

wall_wire = (
    Polyline(left_corner, pt(soap_depth, -base_t), t1)
    + ThreePointArc(t1, mid, t2)
    + Polyline(t2, pt(-base_t, soap_height), right_corner, left_corner)
)
wall_profile = Face(wall_wire)

wall_pos = xz_prism_face(wall_profile,  (soap_width / 2 + w / 2), w)
wall_neg = xz_prism_face(wall_profile, -(soap_width / 2 + w / 2), w)

# ---------------- shell + STAGE 1 fillets (simple solid: robust) ----------------
shell = base + wall_pos + wall_neg

max_pt = pt(-base_t, soap_height)

def on_mount_edge(e):
    """The down-right edge (right_corner -> max_pt) is where the mounts attach."""
    m = e.center()
    return abs((m.X - right_corner[0]) - (m.Z - right_corner[1])) < 1e-3 and m.Z > 1e-3 \
        and m.X > right_corner[0] - 1e-3 and m.X < max_pt[0] + 1e-3

def outer_perimeter_edges(s):
    """Edges on the outer Y faces (|Y| = total_y/2): the whole XZ outline,
    minus the plate-side (z = 0) and mount-side edges."""
    out = []
    for e in s.edges():
        a, b = e.start_point(), e.end_point()
        if abs(a.Y) < total_y / 2 - 1e-4 or abs(b.Y) < total_y / 2 - 1e-4:
            continue
        if abs(a.Y - b.Y) > 1e-4:
            continue
        if a.Z < 1e-4 and b.Z < 1e-4:
            continue
        if on_mount_edge(e):
            continue
        out.append(e)
    return out

def uv(p):
    """World XZ point -> tilted (u, v) coordinates about the spine tip."""
    return ((-(p.X - tip[0]) + (p.Z - tip[1])) * c,
            ((p.X - tip[0]) + (p.Z - tip[1])) * c)

def near(a, b, tol=1e-3):
    return abs(a - b) < tol

def lip_edges(s):
    """Long edges parallel to Y along the two free ends of the base plates."""
    out = []
    for e in s.edges():
        a, b = e.start_point(), e.end_point()
        if not (near(a.X, b.X) and near(a.Z, b.Z)) or e.length < 5:
            continue
        u, v = uv(e.center())
        if (near(u, soap_depth) and (near(v, -base_t) or near(v, 0))) or \
           (near(v, soap_height) and (near(u, -base_t) or near(u, 0))):
            out.append(e)
    return out

def inner_wall_edges(s):
    """Convex free edges of the wall's inner face (|Y| = soap_width/2)."""
    out = []
    for e in s.edges():
        a, b = e.start_point(), e.end_point()
        if not (near(abs(a.Y), soap_width / 2) and near(abs(b.Y), soap_width / 2)):
            continue
        if not near(a.Y, b.Y):
            continue
        u, v = uv(e.center())
        if near(u, 0) or near(v, 0):          # concave joint with the base
            continue
        if u < -1e-3 or v < -1e-3:
            continue
        out.append(e)
    return out

shell = safe_fillet(shell, lip_edges(shell), lip_fillet, "base free-end lips")

# ---------------- thumb slot (cut + filleted on the simple solid: robust) ----------------
thumb_plane = Plane(
    origin=(pt(soap_depth, 0)[0], 0, pt(soap_depth, 0)[1]),
    x_dir=(-c, 0, c),
    z_dir=(-c, 0, -c),
)
ta = soap_depth - thumb_tip_gap   # semi-axis along the face
tb = thumb_slot / 2

thumb_cut = extrude(thumb_plane.offset(-1) * Ellipse(ta, tb), amount=base_t + 2)
shell = shell - thumb_cut

def thumb_rim_edges(s):
    """Edges of the thumb hole wall: the two elliptical arcs (interior and
    underside surfaces) and the two short lines where it meets the free end."""
    out = []
    for e in s.edges():
        p = e @ 0.5
        d = p - thumb_plane.origin
        xl, yl, zl = d.dot(thumb_plane.x_dir), d.dot(thumb_plane.y_dir), d.dot(thumb_plane.z_dir)
        on_ellipse = abs((xl / ta) ** 2 + (yl / tb) ** 2 - 1) < 1e-3
        if on_ellipse and (near(zl, 0) or near(zl, base_t)):
            out.append(e)                                   # arcs
        elif near(xl, 0) and near(abs(yl), tb) and -1e-3 < zl < base_t + 1e-3 \
                and e.length < base_t + 1e-3:
            out.append(e)                                   # end lines
    return out

shell = safe_fillet(shell, thumb_rim_edges(shell), thumb_fillet, "thumb slot rim")

shell = safe_fillet(shell, outer_perimeter_edges(shell), wall_fillet, "wall outer perimeter")
shell = safe_fillet(shell, inner_wall_edges(shell), wall_fillet, "wall inner edges")

# The rim (wire_width wide, all round the thumb slot) must stay solid, so the
# drainage slots are kept out of the whole frame zone.
# Starts exactly at the interior surface (not above it): a zone poking above the
# surface would shield slot material inside the other plate near the spine tip.
frame_zone = extrude(thumb_plane * Ellipse(ta + wire_width, tb + wire_width),
                     amount=base_t + 1)
above_plate = Box(1000, 1000, 1000, align=(Align.CENTER, Align.CENTER, Align.MIN))
big = 1000
base_side = xz_prism(
    [pt(soap_depth, -big), pt(soap_depth, big), pt(-big, big), pt(-big, -big)],
    0, total_y + 2 * big,
)
frame_zone = frame_zone & above_plate & base_side

# ---------------- mounts (added BEFORE the slot cut) ----------------
mount_tri = [right_corner, (max_pt[0], 0), max_pt]
first_start = -soap_width / 2 + slot_y
max_i = num_slots - 2 - mount_k
mount_ys = []
for f in mount_fracs:
    target = -total_y / 2 + f * total_y
    i = round((target - (first_start + mount_w / 2)) / slot_pitch)
    i = min(max(i, 0), max_i)
    y_c = first_start + i * slot_pitch + mount_w / 2
    mount_ys.append(y_c)
    shell = shell + xz_prism(mount_tri, y_c, mount_w)

# ---------------- drainage slots ----------------
x0 = pt(soap_depth, 0)[0]
x1 = pt(0, soap_height)[0]
h  = slot_y / 2
taper_len = h / tan(radians(taper_angle))
assert 2 * taper_len < (x1 - x0), "taper too long for slot"

slot_profile = Polygon(
    (x0, 0), (x0 + taper_len, -h), (x1 - taper_len, -h),
    (x1, 0), (x1 - taper_len,  h), (x0 + taper_len,  h),
    align=None,
)

z_top = shell.bounding_box().max.Z + 1
slot = Pos(0, 0, -1) * extrude(slot_profile, amount=z_top + 1)

y_first = -soap_width / 2 + slot_y / 2
slots = Compound([Pos(0, y_first + i * slot_pitch, 0) * slot
                  for i in range(num_slots)])

shell = shell - (slots - frame_zone)

# ---------------- spine back ----------------
soap_dish = shell + spine

# ---------------- export ----------------
if __name__ == "__main__":
    export_stl(soap_dish, "soap_dish.stl")
    print("Wrote soap_dish.stl")
    if False:
        print(f"slot_y = {slot_y:.3f} mm, pitch = {slot_pitch:.3f} mm, "
              f"taper_len = {taper_len:.3f} mm, base_t = {base_t:.3f} mm")
        print("valid:", soap_dish.is_valid, " volume:", round(soap_dish.volume, 1))
        print(soap_dish.bounding_box())
    print(f"Actual mount width = {mount_w:.3f} mm (k = {mount_k}, requested >= {mount_width})"
          #f", centers at Y = {mount_ys[0]:.3f}, {mount_ys[1]:.3f}"
          )
    print(f"(Adjusting num_slots impacts the actual mount width.  Use trial and error to get best result.)")

