#!/usr/bin/env python
"""P-004 / BRIM-013 — fridge case for >=6 Tezspire autoinjector pens.

Source: an anonymous US reader via /say (2026-09-30T15:48Z): "I need a
case designed that holds injection pens of my medication, Tezspire. I
would like it to hold at least 6 pens without its original packaging."
GO ruled wake 233 with conditions (research/016, QUEUE P-004). The pen's
exterior dimensions are unpublished; the SAME reader (plausibly) answered
the open measurement request the same day (19:29Z): length 152.5 mm,
cap Ø 25 mm (widest point), body Ø 22.5 mm. Those pin the defaults below.

What this is: a slotted cradle TRAY (six parallel U-channels, pens lie
flat, a transverse finger notch across the dividers to pinch a pen out)
plus a drop-on LID (flat plate, outside skirt) that closes the case
against light — the original carton's one real job. Print both in an
OPAQUE filament; geometry alone does not make darkness. The case does
NOTHING for temperature: it is an organizer that lives in the fridge,
not a cooler. No thermal claims, ever (binding condition, wake 233).

Design stance (P-002's lesson, via brim-012): a cradle holds, it does
not clamp. Slot diameter and length only need to exceed the pen with
sane clearance, so tolerance sensitivity is low and no snap, detent, or
spring exists to fail. All fit checks below were written before the
geometry and each measurement that can pass carries a negative control
that must FAIL — a check that cannot fail is not a check.

FIT CONTRACT (mesh-measured interference volumes, mm^3):
  F1 seated   : a nominal pen rests in EVERY slot ~ zero interference
                with the tray AND with the closed lid.
  F2 fat-neg  : a pen fatter than the slot (SLOT_D + 0.4) MUST interfere
                (negative control: the measurement sees the walls).
  F3 long-neg : a pen longer than the slot (SLOT_LEN + 2) MUST interfere
                with the end walls (travel is actually bounded).
  F4 roll-neg : a nominal pen displaced half a slot sideways MUST
                interfere with the divider (slots are actually separate).
  F5 lid      : the lid seats on the rim ~ zero interference; the lid
                LOWERED 0.5 MUST interfere (the rim actually carries it).
  F6 skirt-neg: the lid shifted sideways past its clearance MUST
                interfere (the skirt actually engages the walls).
  F7 contain  : with the lid closed, a pen RAISED 3 mm MUST interfere
                with the lid plate (the case actually encloses the pens).
Plus computed assertions: rim above pen top >= 1.0; skirt overlap >= 6
(light labyrinth); both parts fit a 250 mm bed; and the mesh-reload
watertight/manifold check from brim-012's generator.
"""
import sys, json, numpy as np, trimesh

# ---------------- PARAMS (mm) ----------------
# Top-level inputs — reader-measured 2026-09-30 (/say, anonymous, US):
PEN_L   = 152.5   # overall length, cap on
PEN_D   = 25.0    # widest diameter (the cap; body measures 22.5)
N_PENS  = 6       # the ask: at least six

CLR_D   = 1.5     # diametral slot clearance (cradle, not clamp)
CLR_L   = 3.0     # length clearance
WALL    = 2.0     # divider wall between slots
END     = 2.5     # end walls
FLOOR   = 2.0     # tray floor
LID_T   = 2.4     # lid plate thickness
SK_T    = 1.8     # lid skirt thickness
SK_H    = 8.0     # lid skirt height (light-labyrinth overlap)
LID_CLR = 0.4     # per-side lateral clearance, skirt inside face to wall
NOTCH_R = 18.0    # finger-notch cutter radius (transverse cylinder)
FEAT_H, FEAT_SINK = 0.6, 0.2   # emboss height / sink into wall
STK     = 1.1     # letter stroke width

# derived
SLOT_D   = PEN_D + CLR_D               # 26.5
R        = SLOT_D / 2
SLOT_LEN = PEN_L + CLR_L               # 155.5
W        = N_PENS*SLOT_D + (N_PENS+1)*WALL   # 173.0
L        = SLOT_LEN + 2*END                  # 160.5
TRAY_H   = FLOOR + SLOT_D              # 28.5 — rim clears pen top by CLR_D
Z_AX     = FLOOR + R                   # channel axis height
NOTCH_BOT = FLOOR + PEN_D/2 + 1.0      # notch floor just above pen centre
SEG = 96

def slot_cx(i): return WALL + SLOT_D/2 + i*(SLOT_D + WALL)

# ---------------- helpers (brim-012 lineage) ----------------
def bx(x0,x1,y0,y1,z0,z1):
    b = trimesh.creation.box(extents=[x1-x0, y1-y0, z1-z0])
    b.apply_translation([(x0+x1)/2,(y0+y1)/2,(z0+z1)/2]); return b
def cyl_y(r, y0, y1, x, z, seg=SEG):
    c = trimesh.creation.cylinder(radius=r, height=y1-y0, sections=seg)
    c.apply_transform(trimesh.transformations.rotation_matrix(np.pi/2, [1,0,0]))
    c.apply_translation([x, (y0+y1)/2, z]); return c
def cyl_x(r, x0, x1, y, z, seg=SEG):
    c = trimesh.creation.cylinder(radius=r, height=x1-x0, sections=seg)
    c.apply_transform(trimesh.transformations.rotation_matrix(np.pi/2, [0,1,0]))
    c.apply_translation([(x0+x1)/2, y, z]); return c
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 place(m, dx=0.0, dy=0.0, dz=0.0):
    c = m.copy(); c.apply_translation([dx, dy, dz]); return c

# stroke font (XZ on the front wall), brim-012's segment style
GLYPH = {
 '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)],
 'M': [(0,0,0,1),(0,1,.5,.45),(.5,.45,1,1),(1,1,1,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)],
 '3': [(0,1,1,1),(1,1,1,.55),(.3,.55,1,.55),(1,.55,1,0),(1,0,0,0)],
 '-': [(.15,.5,.85,.5)],
}
def seg_wall(x1, z1, x2, z2):
    """Stroke segment as a rotated box lying on the y=0 front wall,
    protruding FEAT_H out (-y) and sunk FEAT_SINK in (+y)."""
    dx, dz = x2-x1, z2-z1; Ln = float(np.hypot(dx, dz)); a = float(np.degrees(np.arctan2(dz, dx)))
    b = trimesh.creation.box(extents=[Ln+STK, STK, FEAT_H+FEAT_SINK])
    b.apply_transform(trimesh.transformations.rotation_matrix(np.radians(a), [0,0,1]))
    b.apply_transform(trimesh.transformations.rotation_matrix(np.pi/2, [1,0,0]))
    # span y in [-FEAT_H, +FEAT_SINK]: protrude out of the y=0 wall, sunk in
    b.apply_translation([(x1+x2)/2, (FEAT_SINK-FEAT_H)/2, (z1+z2)/2])
    return b
def glyph_wall(ch, cx, z_base, h):
    w = 0.7*h
    return [seg_wall(cx-w/2+s[0]*w, z_base+s[1]*h, cx-w/2+s[2]*w, z_base+s[3]*h)
            for s in GLYPH[ch]]

def pen_model(d=PEN_D, l=PEN_L, slot=0, dx=0.0, dz=0.0):
    """A pen resting in slot i: cylinder along Y on the trough floor."""
    p = cyl_y(d/2, L/2 - l/2, L/2 + l/2, slot_cx(slot) + dx, FLOOR + d/2 + dz)
    return p

# ---------------- GEOMETRY ----------------
def make_tray():
    body = bx(0, W, 0, L, 0, TRAY_H)
    cuts = []
    for i in range(N_PENS):
        cx = slot_cx(i)
        y0, y1 = END, END + SLOT_LEN
        cuts.append(cyl_y(R, y0, y1, cx, Z_AX))                      # trough
        cuts.append(bx(cx-R, cx+R, y0, y1, Z_AX, TRAY_H+1))          # open top
    # transverse finger notch across the dividers (interior only — the
    # outer walls stay full height so the lid skirt seals the light path)
    notch = cyl_x(NOTCH_R, WALL, W-WALL, L/2, NOTCH_BOT + NOTCH_R)
    cuts.append(inter(notch, bx(WALL, W-WALL, 0, L, NOTCH_BOT, TRAY_H+1)))
    tray = diff(body, cuts)
    # raised mark on the front wall
    adds = []
    label = "BRIM-013"
    x0 = W/2 - (len(label)-1)*5.6/2
    for k, ch in enumerate(label):
        adds += glyph_wall(ch, x0 + k*5.6, 9.0, 6.0)
    return union([tray] + adds)

def make_lid():
    ox0, ox1 = -(LID_CLR+SK_T), W + LID_CLR + SK_T
    oy0, oy1 = -(LID_CLR+SK_T), L + LID_CLR + SK_T
    plate = bx(ox0, ox1, oy0, oy1, TRAY_H, TRAY_H + LID_T)
    skirt = diff(bx(ox0, ox1, oy0, oy1, TRAY_H - SK_H, TRAY_H),
                 [bx(-LID_CLR, W+LID_CLR, -LID_CLR, L+LID_CLR, TRAY_H - SK_H - 1, TRAY_H + 1)])
    return union([plate, skirt])

# ---------------- CHECKS ----------------
def run_checks(tray, lid):
    ck = {}
    # F1 seated: nominal pen clean in every slot, against tray and closed lid
    worst_t = max(ivol(pen_model(slot=i), tray) for i in range(N_PENS))
    worst_l = max(ivol(pen_model(slot=i), lid) for i in range(N_PENS))
    ck.update(F1_seated_tray_mm3=round(worst_t,3), F1_seated_lid_mm3=round(worst_l,3),
              F1_PASS=bool(worst_t < 0.05 and worst_l < 0.05))
    # F2 negative control: fat pen must hit the slot walls
    v = ivol(pen_model(d=SLOT_D+0.4, slot=2), tray)
    ck.update(F2_fat_pen_mm3=round(v,3), F2_NEGCTRL_PASS=bool(v > 0.5))
    # F3 negative control: long pen must hit the end walls
    v = ivol(pen_model(l=SLOT_LEN+2.0, slot=2), tray)
    ck.update(F3_long_pen_mm3=round(v,3), F3_NEGCTRL_PASS=bool(v > 0.5))
    # F4 negative control: displaced pen must hit the divider
    v = ivol(pen_model(slot=2, dx=SLOT_D/2), tray)
    ck.update(F4_rolled_pen_mm3=round(v,3), F4_NEGCTRL_PASS=bool(v > 0.5))
    # F5 lid seats clean; lowered lid must land on the rim
    v_seat = ivol(lid, tray)
    v_low  = ivol(place(lid, dz=-0.5), tray)
    ck.update(F5_lid_seated_mm3=round(v_seat,3), F5_lid_lowered_mm3=round(v_low,3),
              F5_PASS=bool(v_seat < 0.05 and v_low > 0.5))
    # F6 negative control: shifted lid's skirt must engage the wall
    v = ivol(place(lid, dx=LID_CLR+0.3), tray)
    ck.update(F6_lid_shifted_mm3=round(v,3), F6_NEGCTRL_PASS=bool(v > 0.5))
    # F7 containment: raised pen must hit the closed lid
    v = ivol(pen_model(slot=2, dz=3.0), lid)
    ck.update(F7_raised_pen_mm3=round(v,3), F7_NEGCTRL_PASS=bool(v > 0.5))
    # computed assertions
    rim_over = TRAY_H - (FLOOR + PEN_D)
    ck.update(rim_above_pen_mm=round(rim_over,2), rim_PASS=bool(rim_over >= 1.0),
              skirt_overlap_mm=SK_H, skirt_PASS=bool(SK_H >= 6.0),
              tray_bbox=[W, L, TRAY_H],
              lid_bbox=[W+2*(LID_CLR+SK_T), L+2*(LID_CLR+SK_T), LID_T+SK_H],
              bed_PASS=bool(max(W, L) + 2*(LID_CLR+SK_T) <= 250.0))
    ck["ALL_PASS"] = bool(all(v for k, v in ck.items() if k.endswith("PASS")))
    return ck

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 "."
    tray = make_tray(); lid = make_lid()
    checks = run_checks(tray, lid)
    # exports in PRINT orientation: tray floor-down as built; lid flipped
    # plate-down (skirt up) — both self-supporting
    exports = {"p004_tray": tray.copy()}
    e = lid.copy()
    e.apply_transform(trimesh.transformations.rotation_matrix(np.pi, [1,0,0]))
    e.apply_translation([0, 0, -float(e.bounds[0][2])])
    exports["p004_lid"] = 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))
