#!/usr/bin/env python
"""P-003 / BRIM-012 — daily-task chore board with a weekday indicator.

Source: r/3Drequests 1wakgy4 (u/LetsTryThisTwo, 2026-09-08): "a checklist
for daily tasks, but with a weekday-indicator, so I can tell if my ADHD
partner has done the task, or forgot to change to the current day."
Separate snap pieces preferred; no multi-material. Prior art holds the
halves (day indicators; per-task done-markers) but not the combination
(provenance in problems/QUEUE.md, decided wake 196).

Mechanism (committed in drafts/brim-012-forecasts.md BEFORE geometry):
  frame : flat plate, printed face-up. Per slider: a through SLOT, a
          shallow front CHANNEL (rotation lock for the knob base), and
          detent DIMPLES cut into the BACK face at +/-DET_OFF from the
          slot line at each detent x. 7-detent weekday track under
          embossed day letters; N task rows with TODO/DONE detents.
  knob  : front piece. Body flares >=50 deg (support-free top-down
          print); square base rides in the channel; slit-and-barb stem
          passes through the slot and snaps into the bar. The slit runs
          up INSIDE the body so snap strain stays elastic.
  bar   : spring behind the plate, printed flat bump-side-up. Wing-tip
          BUMPS (shallow spherical caps, P-002 rev-B doctrine: flank
          ~34 deg, dimple deeper+wider than the bump) engage the frame
          dimples. Escaping a detent needs ~WING_FLEX mm of wing flex
          beyond the rigid axial play — that flex IS the detent spring.

KINEMATIC CONTRACT (P-002's lesson: the missing check is always the
kinematic one; a check that cannot fail is not a check). The check
functions below were written before the detent geometry:
  K1 seated  : at EVERY detent, assembled slider vs frame ~ zero
  K2 mid-neg : between detents, unflexed bar MUST interfere (negative
               control — proves the measurement sees the bumps)
  K3 mid-flex: between detents, bar displaced by the flex the wings
               must supply clears the frame; knob at its rigid stop
               also clears. Wing strain (mesh-measured) <= 1.5 %.
  K4 travel  : knob past the last detent MUST hit the channel end
               (negative control — travel is actually bounded)
  K5 insert  : knob descends through slot+channel clean; knob offset
               sideways MUST interfere (negative control for the sweep)
  K6 snap    : assembled knob/bar clear; bar pulled off MUST catch the
               barb; mid-snap MUST interfere by the squeeze the slit
               absorbs; snap strain (one-time) <= 2.5 %.
"""
import sys, json, numpy as np, trimesh
from trimesh.creation import box, cylinder, icosphere, extrude_polygon
from shapely.geometry import Polygon

# ---------------- PARAMS (mm) ----------------
T        = 3.0     # plate thickness (back z=0, front z=T)
CH_D     = 1.2     # front channel depth -> web under channel = T-CH_D
WEB      = T - CH_D
SLOT_W   = 4.2     # through-slot width (Y)
STEM     = 3.4     # square stem side
PLAY     = 0.15    # rigid axial play (knob underside above channel floor)
BAR_L, BAR_W, BAR_T = 28.0, 3.5, 1.0
BOSS     = 6.0     # bar centre boss (square), holds the snap hole
HOLE     = 3.6     # bar square hole (STEM + 0.2)
BUMP_Y   = 11.0    # bump centres at +/-BUMP_Y along the bar (= DET_OFF)
BUMP_R, BUMP_SINK = 3.0, 2.5   # cap: protrusion 0.5, base r 1.66, flank ~34 deg
DIMPLE_R, DIMPLE_C = 2.6, -1.8 # depth 0.8 > protrusion; opening r 1.88 > 1.66
PROTR    = BUMP_R - BUMP_SINK  # 0.5 nominal (also mesh-measured in checks)
WING_FLEX = None   # computed: PROTR - PLAY = 0.35
BAR_SLOP = 0.05    # axial slop between catch and bar
BARB_OH  = 0.5     # barb overhang per side (X); effective catch = this - (HOLE-STEM)/2 - chamfer
SLIT_W   = 1.2     # stem slit (X)
KNOB_B   = 12.0    # knob base square (channel-guided)
KNOB_TOP = 16.0    # knob top square
KNOB_BH  = 1.0     # base height
FLARE_H  = 2.4     # 12->16 over 2.4 -> 50 deg from horizontal
KNOB_TH  = 1.45    # straight top wall height
CH_W     = 12.8    # channel width (KNOB_B + 0.8)
CH_EXT   = KNOB_B/2 + 0.8      # channel end beyond last detent
SLOT_EXT = 2.6     # slot end beyond last detent
FEAT_H, FEAT_SINK = 0.6, 0.2   # emboss height / sink (coincident-face doctrine)
STK      = 1.1     # letter stroke width
# board layout
W, H     = 110.0, 175.0
N_TASK   = 5
Y_WK     = H - 24.0            # weekday slot line
WK_X0, WK_PITCH, WK_N = 19.0, 12.0, 7
ROW_PITCH = 26.0
X_TODO, X_DONE = 50.0, 82.0
LBL = (6.0, 38.0)              # label recess x-span, depth FEAT_H
CSK = [(12.0, H-8.0), (98.0, H-8.0), (55.0, 7.0)]  # M4 countersunk mounts
# derived assembly datums (seated state)
Z_UNDER  = WEB + PLAY          # knob underside (1.95)
Z_BARTOP = -0.02               # bar top face, seated (flush - epsilon)
Z_CATCH  = Z_BARTOP - BAR_T - BAR_SLOP   # barb catch face (-1.07)
SEG = 96
# ----------------------------------------------

def cz(r, z0, z1, x=0, y=0, seg=SEG):
    c = cylinder(radius=r, height=z1-z0, sections=seg); c.apply_translation([x, y, (z0+z1)/2]); return c
def bx(x0,x1,y0,y1,z0,z1):
    b = box(extents=[x1-x0, y1-y0, z1-z0]); b.apply_translation([(x0+x1)/2,(y0+y1)/2,(z0+z1)/2]); return b
def sph(r, x, y, z):
    s = icosphere(subdivisions=3, radius=r); s.apply_translation([x,y,z]); return s
def union(ms): return trimesh.boolean.union(ms, engine="manifold")
def diff(a, ms): return trimesh.boolean.difference([a]+ms, engine="manifold")
def inter(a, b): return trimesh.boolean.intersection([a,b], engine="manifold")
def ivol(a, b):
    m = inter(a, b); return float(m.volume) if len(m.faces) else 0.0
def stadium(x0, x1, yc, w, z0, z1):
    r = w/2
    return union([bx(x0, x1, yc-r, yc+r, z0, z1), cz(r, z0, z1, x0, yc, 48), cz(r, z0, z1, x1, yc, 48)])
def seg3(x1, y1, x2, y2, z0, z1):
    """Stroke segment as a rotated box, ends extended by STK/2 (round-ish joints)."""
    dx, dy = x2-x1, y2-y1; L = float(np.hypot(dx, dy)); a = float(np.degrees(np.arctan2(dy, dx)))
    b = box(extents=[L+STK, STK, z1-z0])
    b.apply_transform(trimesh.transformations.rotation_matrix(np.radians(a), [0,0,1]))
    b.apply_translation([(x1+x2)/2, (y1+y2)/2, (z0+z1)/2]); return b

# stroke font, unit cell (x right, y up), rendered h=height, w=0.7h
GLYPH = {
 'M': [(0,0,0,1),(0,1,.5,.45),(.5,.45,1,1),(1,1,1,0)],
 'T': [(.5,0,.5,1),(0,1,1,1)],
 'W': [(0,1,.25,0),(.25,0,.5,.55),(.5,.55,.75,0),(.75,0,1,1)],
 'F': [(0,0,0,1),(0,1,1,1),(0,.55,.75,.55)],
 'S': [(1,1,0,1),(0,1,0,.55),(0,.55,1,.55),(1,.55,1,0),(1,0,0,0)],
 'B': [(0,0,0,1),(0,1,.85,1),(.85,1,.85,.55),(0,.55,1,.55),(1,.55,1,0),(1,0,0,0)],
 'R': [(0,0,0,1),(0,1,1,1),(1,1,1,.55),(1,.55,0,.55),(.35,.55,1,0)],
 'I': [(.5,0,.5,1),(.2,1,.8,1),(.2,0,.8,0)],
 '0': [(0,0,0,1),(0,1,1,1),(1,1,1,0),(1,0,0,0)],
 '1': [(.5,0,.5,1),(.2,.75,.5,1)],
 '2': [(0,1,1,1),(1,1,1,.55),(1,.55,0,.55),(0,.55,0,0),(0,0,1,0)],
 '-': [(.15,.5,.85,.5)],
}
def glyph(ch, cx, y_base, h, z0, z1):
    w = 0.7*h
    return [seg3(cx-w/2+s[0]*w, y_base+s[1]*h, cx-w/2+s[2]*w, y_base+s[3]*h, z0, z1) for s in GLYPH[ch]]

def detents():
    """[(y_line, [detent x...]), ...] — weekday first, then task rows."""
    rows = [(Y_WK, [WK_X0 + k*WK_PITCH for k in range(WK_N)])]
    for i in range(N_TASK):
        rows.append((Y_WK - ROW_PITCH*(i+1), [X_TODO, X_DONE]))
    return rows

# ---------------- CHECKS (written before the detent geometry) ----------------

def place(m, x, y, dz=0.0):
    c = m.copy(); c.apply_translation([x, y, dz]); return c

def run_checks(frame, knob, bar):
    """knob+bar are built in assembly coords at slot position (0,0), seated."""
    ck = {}
    # mesh-measured spring facts
    protr = round(float(bar.vertices[:,2].max()) - Z_BARTOP, 3)
    wing_flex = round(protr - PLAY, 3)
    wing_L = BUMP_Y - BOSS/2 - np.sqrt(BUMP_R**2 - BUMP_SINK**2)  # root to cap base
    eps_wing = 1.5 * wing_flex * BAR_T / wing_L**2
    ck.update(bump_protrusion_mm=protr, wing_flex_mm=wing_flex,
              wing_arm_mm=round(float(wing_L),2), wing_strain_pct=round(100*eps_wing,2),
              wing_PASS=bool(eps_wing <= 0.015 and wing_flex >= 0.25))
    lip = float(np.degrees(np.arctan2(np.sqrt(BUMP_R**2-BUMP_SINK**2), BUMP_SINK)))
    dim_open = float(np.sqrt(DIMPLE_R**2 - DIMPLE_C**2)); cap_base = float(np.sqrt(BUMP_R**2-BUMP_SINK**2))
    ck.update(bump_flank_deg=round(lip,1), ramp_PASS=bool(lip <= 55.0),
              dimple_depth_mm=round(DIMPLE_R+DIMPLE_C,2),
              dimple_deeper_PASS=bool(DIMPLE_R+DIMPLE_C > protr + 0.15),
              dimple_open_r=round(dim_open,2), cap_base_r=round(cap_base,2),
              dimple_wider_PASS=bool(dim_open > cap_base + 0.15))
    rows = detents()
    # K1 seated at every detent
    worst_k, worst_b = 0.0, 0.0
    for y_line, xs in rows:
        for x in xs:
            worst_k = max(worst_k, ivol(place(knob, x, y_line), frame))
            worst_b = max(worst_b, ivol(place(bar, x, y_line), frame))
    ck.update(K1_seated_knob_mm3=round(worst_k,3), K1_seated_bar_mm3=round(worst_b,3),
              K1_PASS=bool(worst_k < 0.05 and worst_b < 0.05))
    # K2/K3 mid-travel on the longest span (weekday) and a task row
    mids = [(Y_WK, WK_X0 + WK_PITCH/2), (rows[1][0], (X_TODO+X_DONE)/2)]
    v_neg = min(ivol(place(bar, x, y), frame) for y, x in mids)
    v_flex = max(ivol(place(bar, x, y, dz=-(protr+0.07)), frame) for y, x in mids)
    v_knb = max(ivol(place(knob, x, y, dz=-(PLAY-0.02)), frame) for y, x in mids)
    ck.update(K2_midtravel_unflexed_mm3=round(v_neg,3), K2_NEGCTRL_PASS=bool(v_neg > 0.5),
              K3_midtravel_flexed_mm3=round(v_flex,3), K3_knob_at_stop_mm3=round(v_knb,3),
              K3_PASS=bool(v_flex < 0.05 and v_knb < 0.05))
    # K4 travel bounded: knob 1.5 past the last weekday detent hits the channel end
    v_end = ivol(place(knob, WK_X0 + (WK_N-1)*WK_PITCH + 1.5, Y_WK), frame)
    ck.update(K4_past_end_mm3=round(v_end,3), K4_NEGCTRL_PASS=bool(v_end > 0.5))
    # K5 insertion sweep (knob descends from the front at detent 0) + offset control
    v_sw = max(ivol(place(knob, WK_X0, Y_WK, dz=d), frame) for d in (6.0, 3.0, 1.0, 0.4))
    v_off = ivol(place(knob, WK_X0, Y_WK + 1.6, dz=3.0), frame)  # stem off-slot
    ck.update(K5_sweep_mm3=round(v_sw,3), K5_offset_ctrl_mm3=round(v_off,3),
              K5_PASS=bool(v_sw < 0.05 and v_off > 0.5))
    # K6 snap: nominal clear; pull-off catches; mid-snap squeezes what the slit absorbs
    kb0, br0 = place(knob, 0, -200), None   # isolated pair, away from frame features
    br_nom  = place(bar, 0, -200)
    br_off  = place(bar, 0, -200, dz=-0.5)
    br_mid  = place(bar, 0, -200, dz=Z_CATCH - Z_BARTOP + BAR_T/2)  # hole at barb widest
    v_nom, v_ret, v_mid = ivol(kb0, br_nom), ivol(kb0, br_off), ivol(kb0, br_mid)
    eff_catch = STEM/2 + BARB_OH - HOLE/2 - 0.25   # minus entry chamfer
    slit_need = 2*BARB_OH + 0.1
    t_half = (STEM - SLIT_W)/2
    L_slit = (KNOB_BH + FLARE_H + KNOB_TH - 1.5) + Z_UNDER - Z_CATCH  # slit top to catch
    eps_snap = 1.5 * BARB_OH * t_half / L_slit**2
    ck.update(K6_nominal_mm3=round(v_nom,3), K6_pulloff_mm3=round(v_ret,3),
              K6_midsnap_mm3=round(v_mid,3), K6_eff_catch_mm=round(eff_catch,2), K6_slit_w=SLIT_W,
              K6_slit_need=round(slit_need,2), K6_snap_strain_pct=round(100*eps_snap,2),
              K6_PASS=bool(v_nom < 0.05 and v_ret > 0.3 and v_mid > 0.1
                           and eff_catch >= 0.15 and SLIT_W > slit_need and eps_snap <= 0.025))
    ck["ALL_PASS"] = bool(all(v for k, v in ck.items() if k.endswith("PASS")))
    return ck

# ---------------- GEOMETRY ----------------

def make_frame():
    plate = bx(0, W, 0, H, 0, T)
    cuts, adds = [], []
    for y_line, xs in detents():
        x0, x1 = xs[0], xs[-1]
        cuts.append(bx(x0-CH_EXT, x1+CH_EXT, y_line-CH_W/2, y_line+CH_W/2, T-CH_D, T+1))
        cuts.append(stadium(x0-SLOT_EXT, x1+SLOT_EXT, y_line, SLOT_W, -1, T+1))
        for x in xs:
            for sy in (+1, -1):
                cuts.append(sph(DIMPLE_R, x, y_line + sy*BUMP_Y, DIMPLE_C))
    # task-row extras: label recess + todo/done marks
    for y_line, xs in detents()[1:]:
        cuts.append(stadium(LBL[0]+2, LBL[1]-2, y_line, 10.0, T-FEAT_H, T+1))
        my = y_line + 11.5
        ring = diff(cz(2.5, T-FEAT_SINK, T+FEAT_H, X_TODO, my, 48), [cz(1.5, T-1, T+1, X_TODO, my, 48)])
        adds.append(ring)
        adds.append(seg3(X_DONE-2.2, my+0.2, X_DONE-0.6, my-1.6, T-FEAT_SINK, T+FEAT_H))
        adds.append(seg3(X_DONE-0.6, my-1.6, X_DONE+2.2, my+2.0, T-FEAT_SINK, T+FEAT_H))
    # weekday letters
    for k, ch in enumerate("MTWTFSS"):
        adds += glyph(ch, WK_X0 + k*WK_PITCH, Y_WK + 11.5, 6.0, T-FEAT_SINK, T+FEAT_H)
    # brand
    for i, ch in enumerate("BRIM-012"):
        adds += glyph(ch, 76.0 + i*3.8, 2.5, 4.0, T-FEAT_SINK, T+FEAT_H)
    # countersunk mounts (M4): through hole r2.0 + 45deg csk opening r4.2 at the front
    for x, y in CSK:
        cuts.append(cz(2.0, -1, T+1, x, y, 48))
        cone = trimesh.creation.cone(radius=8.0, height=8.0, sections=64)
        cone.apply_transform(trimesh.transformations.rotation_matrix(np.pi, [1,0,0]))
        cone.apply_translation([x, y, T + 1.2])   # apex at z = T-6.8... opening r4.2 at front
        cuts.append(cone)
    return union([diff(plate, cuts)] + adds)

def stem_profile():
    """Stem+barb XZ profile (built at slot position 0,0), extruded BAR-hole-deep in Y."""
    hs, hb = STEM/2, STEM/2 + BARB_OH
    tip_z, tip_h = Z_CATCH - (hb - 0.9), 0.08
    pts = [(-hs, Z_UNDER), (hs, Z_UNDER), (hs, Z_CATCH), (hb, Z_CATCH),
           (0.9, tip_z), (0.9, tip_z - tip_h), (-0.9, tip_z - tip_h),
           (-0.9, tip_z), (-hb, Z_CATCH), (-hs, Z_CATCH)]
    poly = Polygon(pts)
    m = extrude_polygon(poly, height=STEM)   # extrudes +Z; profile is XY -> rotate to XZ
    m.apply_transform(trimesh.transformations.rotation_matrix(np.radians(90), [1,0,0]))
    m.apply_translation([0, STEM/2, 0])
    return m

def make_knob(pointer=False):
    z0 = Z_UNDER
    base = bx(-KNOB_B/2, KNOB_B/2, -KNOB_B/2, KNOB_B/2, z0, z0+KNOB_BH)
    a, b = KNOB_B/2, KNOB_TOP/2
    z1, z2 = z0+KNOB_BH, z0+KNOB_BH+FLARE_H
    pts = [(sx*a, sy*a, z1) for sx in (-1,1) for sy in (-1,1)] + \
          [(sx*b, sy*b, z2) for sx in (-1,1) for sy in (-1,1)]
    flare = trimesh.convex.convex_hull(np.array(pts))
    top = bx(-b, b, -b, b, z2, z2+KNOB_TH)
    parts = [base, flare, top, stem_profile()]
    if pointer:
        tri = Polygon([(-3.0, b-0.2), (3.0, b-0.2), (0.0, b+3.0)])
        p = extrude_polygon(tri, height=KNOB_TH)
        p.apply_translation([0, 0, z2])
        parts.append(p)
    m = union(parts)
    slit_top = z2 + KNOB_TH - 1.5
    return diff(m, [bx(-SLIT_W/2, SLIT_W/2, -STEM/2-1, STEM/2+1, Z_CATCH-(STEM/2+BARB_OH), slit_top)])

def make_bar():
    body = union([bx(-BAR_W/2, BAR_W/2, -BAR_L/2, BAR_L/2, Z_BARTOP-BAR_T, Z_BARTOP),
                  bx(-BOSS/2, BOSS/2, -BOSS/2, BOSS/2, Z_BARTOP-BAR_T, Z_BARTOP)])
    bumps = []
    for sy in (+1, -1):
        s = sph(BUMP_R, 0, sy*BUMP_Y, Z_BARTOP - BUMP_SINK)
        bumps.append(inter(s, bx(-3, 3, sy*BUMP_Y-3, sy*BUMP_Y+3, Z_BARTOP-BAR_T+0.05, 5)))
    m = union([body] + bumps)
    hole = bx(-HOLE/2, HOLE/2, -HOLE/2, HOLE/2, Z_BARTOP-BAR_T-1, Z_BARTOP+1)
    ch = 0.25  # entry chamfer, small: K6 caught 0.5 eating the barb catch
    pts = [(sx*(HOLE/2+ch*(1-t)), sy*(HOLE/2+ch*(1-t))) for t in (0,1) for sx in (-1,1) for sy in (-1,1)]
    entry = trimesh.convex.convex_hull(np.array(
        [(sx*(HOLE/2+ch), sy*(HOLE/2+ch), Z_BARTOP-BAR_T) for sx in (-1,1) for sy in (-1,1)] +
        [(sx*(HOLE/2), sy*(HOLE/2), Z_BARTOP-BAR_T+ch) for sx in (-1,1) for sy in (-1,1)]))
    return diff(m, [hole, entry])

def report(name, m):
    return dict(part=name, watertight=bool(m.is_watertight), volume_mm3=round(float(m.volume),1),
                bbox=[round(float(v),2) for v in m.extents], faces=int(len(m.faces)))

if __name__ == "__main__":
    out = sys.argv[1] if len(sys.argv) > 1 else "."
    frame = make_frame(); knob = make_knob(False); knob_day = make_knob(True); bar = make_bar()
    checks = run_checks(frame, knob, bar)
    # export in PRINT orientation: frame face-up as built; bar bump-up as built
    # (translate z to 0); knob flipped top-face-down.
    exports = {}
    fr = frame.copy(); exports["p003_frame"] = fr
    for nm, k in (("p003_knob_task", knob), ("p003_knob_day", knob_day)):
        e = k.copy(); e.apply_transform(trimesh.transformations.rotation_matrix(np.pi, [1,0,0]))
        e.apply_translation([0, 0, -float(e.bounds[0][2])]); exports[nm] = e
    e = bar.copy(); e.apply_translation([0, 0, -float(e.bounds[0][2])]); exports["p003_bar"] = e
    mesh_ok = {}
    import collections as _c
    for k, v in exports.items():
        v.merge_vertices(digits_vertex=6)
        v.update_faces(v.nondegenerate_faces(height=1e-7)); v.remove_unreferenced_vertices()
        v.export(f"{out}/{k}.stl")
        m = trimesh.load(f"{out}/{k}.stl"); m.merge_vertices(digits_vertex=6)
        nm2 = sum(1 for c in _c.Counter(map(tuple, m.edges_sorted)).values() if c != 2)
        mesh_ok[k] = bool(m.is_watertight and nm2 == 0 and (m.area_faces < 1e-9).sum() == 0)
    checks["mesh_reload_PASS"] = mesh_ok
    rep = [report(k, v) for k, v in exports.items()]
    print(json.dumps(dict(parts=rep, checks=checks), indent=1))
