"""
Drip bowl: a stainless mixing bowl holding a little water hangs in a V-slot
cradle and rocks slowly toward and away from the audience. A trip-hammer
mallet strikes it just below the rim. A pump drips water in from above, and a
bell siphon in the middle of the bowl drains it back to a reservoir underneath
each time it fills. The sloshing water bends the pitch while the bowl rings.

Run from the cad/ folder:

    uv run drip-bowl/model.py

It writes, for the site:
    site/public/files/drip-bowl/stl/*.stl              printed parts, laid flat for printing
    site/public/files/drip-bowl/drip-bowl-stl.zip
    site/public/files/drip-bowl/drip-bowl.step         the whole assembly, for editing
    site/public/files/drip-bowl/drip-bowl.glb          the 3D model shown on the page
    site/public/files/drip-bowl/drip-bowl-motion.json  how it moves, for the Play button
    site/public/files/drip-bowl/drip-bowl-cad.py       this script
    site/src/content/instruments/drip-bowl/drawings/drip-bowl-render.svg

Coordinates, in millimeters: X runs left to right as the audience sees it,
with 0 under the middle of the bowl. Y runs front to back; the audience is at
negative Y. Z is up from the floor. The bowl rocks about the X axis, on an
axle level with the top of its rim, so the point on the rim where the mallet
strikes (on that axis) hardly moves as it rocks.

This is one version: where the recipe started. The bowl is a generic 22 cm
mixing bowl (BOWL_PROFILE); measure yours and change those numbers. LOW and
HIGH set the water range the siphon cycles through, ROCK how far it rocks.

All numbers are a first design, untested in print. Expect one round of fitting.
"""

from pathlib import Path
import math
import re
import shutil
import sys
import zipfile

from build123d import (
    Axis,
    Box,
    Color,
    Compound,
    Cylinder,
    Edge,
    ExportSVG,
    Face,
    Plane,
    Pos,
    RegularPolygon,
    Rot,
    Unit,
    Vector,
    Wire,
    export_gltf,
    export_step,
    export_stl,
    extrude,
    revolve,
)

HERE = Path(__file__).resolve().parent
REPO = HERE.parent.parent
OUT = REPO / "site/public/files/drip-bowl"
DRAWINGS = REPO / "site/src/content/instruments/drip-bowl/drawings"

sys.path.insert(0, str(HERE.parent))
from recipe import compact_glb, slide, turn, write_motion  # noqa: E402

# ---------------------------------------------------------------------------
# The design, in one place
# ---------------------------------------------------------------------------

# The bowl: the outside of a stainless mixing bowl about 22 cm across, as (radius, height)
# from the middle of its bottom. Drawn as a flat bottom and three bands turning 30 degrees
# each, because the site's viewer draws only the creases between faces.
BOWL_PROFILE = [(0.0, 0.0), (60.0, 0.0), (88.0, 16.2), (110.8, 55.7), (110.8, 95.0)]
WALL = 0.8
LIP = 4.0  # the rolled rim, drawn as a flat lip this wide
BOWL_H = BOWL_PROFILE[-1][1]
RIM_R = BOWL_PROFILE[-1][0]  # outside radius of the wall at the rim

Z_BOWL = 258.0  # the underside of the bowl
Z_PIVOT = Z_BOWL + BOWL_H  # the axle, level with the top of the rim
INNER_BOTTOM = Z_BOWL + WALL
DEPTH = Z_PIVOT - INNER_BOTTOM  # inside depth

LOW, HIGH = 0.40, 0.70  # the siphon drains to the bell's edge and fills to the standpipe's top, as fractions of the depth
SHOWN = 0.62  # the level drawn in the model
Z_LOW, Z_HIGH, Z_WATER = (INNER_BOTTOM + f * DEPTH for f in (LOW, HIGH, SHOWN))
WATER_R = 100.0  # drawn a little inside the wall, so it can stay level while the bowl tilts

ROCK = 7.0  # degrees either way

# The frame: 20 x 40 mm V-slot posts at each side, a top bar across them, feet and floor bars.
POST_X = 260.0  # inner faces of the posts
POST_Y0, POST_Y1 = -10.0, 30.0  # a slot on the posts' inner faces lines up with the axle at Y = 0
TOP_Z = 540.0  # underside of the top bar
FOOT_Y = 160.0

# The cradle: two 20 x 20 arms and a bar under the bowl. The arms stand well clear of the bowl
# so the mallet fits between the left arm and the wall.
ARM_IN = 175.0  # inner faces of the arms
CROSS_Z = Z_BOWL - 30.0  # underside of the bar under the bowl
ARM_TOP = Z_PIVOT + 15.0

# The mallet: a yarn head on a rattan handle, clamped in the printed arm, which turns on a 625
# bearing on the hanger. A spring pulls it toward the bowl; a stop screw holds it 2 mm clear.
HEAD_R = 16.0
GAP = 2.0
Z_STRIKE = Z_PIVOT - 18.0  # just below the rim's bead
ARM_X = -(RIM_R + GAP + HEAD_R)  # the mallet's line, through the middle of the head at rest
PIVOT_ARM = (ARM_X, 0.0, 465.0)
TAIL = 35.0  # from the arm's pivot up to its roller
ROLLER_R = 8.0  # a second 625 bearing
ROLLER_C = (ARM_X + 3.0, PIVOT_ARM[2] + TAIL)
CLAMP_Z = 428.0  # bottom of the printed arm, where the handle enters it
HANGER_X = (ARM_X - 10.0, ARM_X + 10.0)

# The cam: a snail on the second stepper. It rises over 300 degrees, holds for 30, then drops.
CAM_C = (-163.0, 0.0, ROLLER_C[1])
R_REST = (ROLLER_C[0] - ROLLER_R) - CAM_C[0]  # where the roller sits when the mallet is at rest
LIFT = 6.5
R_MIN, R_MAX = R_REST - 2.0, R_REST + LIFT
RISE_END, DWELL_END = 300.0, 330.0
PARK = 315.0  # the cam waits here, cocked, until a strike is cued

NOZZLE_X, NOZZLE_TIP = -45.0, 512.0  # off center, so drops land in open water and not on the bell

M3, M3_HEAD = 1.7, 3.1  # clearance and counterbore radii
M5, M5_HEAD = 2.75, 4.6

# The water
G = 9810.0  # mm/s^2
SLOSH_HZ = 1.8  # the first sloshing mode of this much water in this bowl, roughly
SLOSH_DAMPING = 0.05
SLOSH_GAIN = 0.65  # how strongly the bowl's swing drives the slosh, from the pivot's height above the water


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


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)


def cyl_x(y, z, r, x0, x1):
    return Pos((x0 + x1) / 2, y, z) * Rot(0, 90, 0) * Cylinder(r, x1 - x0)


def cyl_y(x, z, r, y0, y1):
    return Pos(x, (y0 + y1) / 2, z) * Rot(90, 0, 0) * Cylinder(r, y1 - y0)


def cyl_z(x, y, r, z0, z1):
    return Pos(x, y, (z0 + z1) / 2) * Cylinder(r, z1 - z0)


def rod(p0, p1, r, sides=6):
    """A straight rod or tube with flat sides, from p0 to p1. The site's viewer draws only the creases
    between faces, so a round rod would show as its two end circles and nothing between."""
    a, b = Vector(*p0), Vector(*p1)
    return Plane(origin=a, z_dir=b - a) * extrude(RegularPolygon(r, sides), amount=(b - a).length)


def path(points, r, sides=6):
    """A bent tube through the points, one flat-sided rod per leg, with a knuckle at each bend."""
    legs = [rod(a, b, r, sides) for a, b in zip(points, points[1:])]
    shape = legs[0]
    for leg in legs[1:]:
        shape += leg
    for p in points[1:-1]:
        shape += Pos(*p) * faceted_ball(r)
    return shape


def faceted_ball(r):
    """A ball drawn faceted, a cube cut by three turned cubes, so the viewer has creases to draw."""
    cube = Box(2 * r, 2 * r, 2 * r)
    return cube & (Rot(45, 0, 0) * cube) & (Rot(0, 45, 0) * cube) & (Rot(0, 0, 45) * cube)


def vslot(x0, x1, y0, y1, z0, z1):
    """Aluminum V-slot extrusion, simplified: a plain slot every 20 mm along each long face."""
    sizes = (x1 - x0, y1 - y0, z1 - z0)
    along = sizes.index(max(sizes))
    e = box(x0, x1, y0, y1, z0, z1)
    lo, hi = (x0, y0, z0), (x1, y1, z1)
    for face in range(3):
        if face == along:
            continue
        other = 3 - along - face
        for c in range(int(round((hi[other] - lo[other]) / 20))):
            mid = lo[other] + 10 + 20 * c
            for at_low in (True, False):
                a, b = [list(lo), list(hi)]
                a[other], b[other] = mid - 3.1, mid + 3.1
                if at_low:
                    b[face] = lo[face] + 6
                else:
                    a[face] = hi[face] - 6
                e -= box(a[0], b[0], a[1], b[1], a[2], b[2])
    return e


def corner_bracket(x0, x1, y0, y1, z0, z1, keep):
    """A corner bracket for 20 mm V-slot, as a plain L inside this box. keep names its two
    4 mm legs by the sides of the box they lie on: '+y-z' is a leg on the high-Y side and one on the low-Z side."""
    lo, hi = [x0, y0, z0], [x1, y1, z1]
    a, b = list(lo), list(hi)
    for sign, axis in zip(keep[0::2], keep[1::2]):
        i = "xyz".index(axis)
        if sign == "+":
            b[i] = hi[i] - 4
        else:
            a[i] = lo[i] + 4
    return box(x0, x1, y0, y1, z0, z1) - box(a[0], b[0], a[1], b[1], a[2], b[2])


def offset_profile(points, w):
    """The inside of the bowl: each leg of the outside profile moved w inward, joined where they meet."""
    lines = []
    for (r0, z0), (r1, z1) in zip(points, points[1:]):
        length = math.hypot(r1 - r0, z1 - z0)
        nr, nz = -(z1 - z0) / length, (r1 - r0) / length
        lines.append(((r0 + w * nr, z0 + w * nz), (r1 - r0, z1 - z0)))
    out = [(0.0, w)]
    for (p, d), (q, e) in zip(lines, lines[1:]):
        det = d[0] * -e[1] + e[0] * d[1]
        s = ((q[0] - p[0]) * -e[1] + e[0] * (q[1] - p[1])) / det
        out.append((p[0] + s * d[0], p[1] + s * d[1]))
    out.append((lines[-1][0][0], points[-1][1]))
    return out


INNER_PROFILE = offset_profile(BOWL_PROFILE, WALL)


def inner_radius(z_rel):
    """The inside radius of the bowl at a height above its underside."""
    for (r0, z0), (r1, z1) in zip(INNER_PROFILE, INNER_PROFILE[1:]):
        if z0 <= z_rel <= z1 and z1 > z0:
            return r0 + (r1 - r0) * (z_rel - z0) / (z1 - z0)
    return INNER_PROFILE[-1][0]


def volume_between(z0_rel, z1_rel, steps=400):
    """Liters of water between two heights above the bowl's underside."""
    dz = (z1_rel - z0_rel) / steps
    return sum(math.pi * inner_radius(z0_rel + (k + 0.5) * dz) ** 2 * dz for k in range(steps)) / 1e6


def revolved(points, top):
    """A solid of revolution about the bowl's axis, from (radius, height) points closed along the axis."""
    pts = [(0.0, points[0][1])] + list(points[1:]) + [(points[-1][0], top), (0.0, top)]
    face = Face(Wire.make_polygon([(r, 0, z) for r, z in pts], close=True))
    return Pos(0, 0, Z_BOWL) * revolve(face, Axis.Z, 360)


# ---------------------------------------------------------------------------
# Printed parts, in assembly position
# ---------------------------------------------------------------------------


def saddle():
    """Holds the bowl at the middle of its bottom, on the cradle's bar. The drain fitting's nut sits in the
    pocket underneath and clamps the bowl down onto its rubber pad."""
    z0, z1 = CROSS_Z + 20, Z_BOWL - 2
    p = cyl_z(0, 0, 30, z0, z1)
    p -= cyl_z(0, 0, 8.2, z0 - 1, z1 + 1)  # the fitting's thread
    p -= Pos(0, 0, z0 + 2.5) * extrude(RegularPolygon(12.9, 6), amount=3.5, both=True)  # its nut
    for x in (-22, 22):  # M5 screws down into T-nuts in the bar's top slot
        p -= cyl_z(x, 0, M5, z0 - 1, z1 + 1)
        p -= cyl_z(x, 0, M5_HEAD, z1 - 4.5, z1 + 1)
    return p


def mallet_arm():
    """Clamps the mallet's handle and turns on a 625 bearing. Its tail carries the roller the cam pushes;
    its tab carries the stop screw, which rests on the hanger and holds the mallet 2 mm clear of the bowl."""
    x, pz = ARM_X, PIVOT_ARM[2]
    p = box(x - 7, x + 7, -7, 7, CLAMP_Z, pz + 5)  # body
    p += cyl_y(x, pz, 12, -7, 7)  # bearing boss
    tail_x = ROLLER_C[0] - M5 - 2.5  # the roller stands proud of the tail, so only the roller meets the cam
    p += box(tail_x, x + 9, -7, 7, pz, ROLLER_C[1] + 10)  # tail
    p -= box(tail_x - 1, x + 11.5, -2.8, 2.8, pz + 11, ROLLER_C[1] + 11)  # fork for the roller, deep enough to clear the cam
    p -= cyl_y(ROLLER_C[0], ROLLER_C[1], M5, -8, 8)  # roller axle
    p -= cyl_y(x, pz, 8.05, -8, -1.8)  # 625 bearing, pressed in from the front
    p -= cyl_y(x, pz, 5.0, -8, 8)  # room for the spacer behind it
    p -= cyl_z(x, 0, 4.4, CLAMP_Z - 1, CLAMP_Z + 26)  # the handle
    p -= box(x, x + 8, -0.75, 0.75, CLAMP_Z - 1, CLAMP_Z + 24)  # slit, so the clamp screw grips it
    p -= cyl_y(x + 4.5, CLAMP_Z + 12, M3, -8, 8)
    p -= cyl_y(x + 4.5, CLAMP_Z + 12, M3_HEAD, -8, -4.5)
    p -= cyl_y(x + 4.5, CLAMP_Z + 4, 1.2, -8, 8)  # the spring hooks through here
    tab_z = (440.0, 452.0)
    p += box(x - 17, x - 7, -7, 7, *tab_z)  # out to the tab
    p += box(x - 25, x - 17, -7, 28, *tab_z)  # the tab, reaching back beside the hanger
    p -= cyl_x(16, sum(tab_z) / 2, 1.7, x - 26, x - 16)  # the M4 stop screw cuts its own thread here
    return p


def cam_radius(a):
    """The cam's radius at an angle from its start, measured the way it turns."""
    a %= 360
    if a < RISE_END:
        return R_MIN + (R_MAX - R_MIN) * a / RISE_END
    return R_MAX if a < DWELL_END else R_MIN


def cam():
    """A snail cam: it pushes the mallet's roller out over 300 degrees, holds it for 30, then drops it."""
    def at(r, a):
        return Vector(r * math.cos(math.radians(a)), r * math.sin(math.radians(a)), 0)

    ramp = Edge.make_spline([at(cam_radius(a), a) for a in range(0, int(RISE_END) + 1, 10)])
    outline = Wire([
        ramp,
        Edge.make_circle(R_MAX, Plane.XY, RISE_END, DWELL_END),  # it holds the mallet cocked here
        Edge.make_line(at(R_MAX, DWELL_END), at(R_MIN, DWELL_END)),  # the drop, a straight step
        Edge.make_circle(R_MIN, Plane.XY, DWELL_END, 360),
    ])
    cx, cy, cz = CAM_C
    p = Pos(cx, cy, cz) * Rot(90, 0, 0) * extrude(Face(outline), amount=2, both=True)
    p += cyl_y(cx, cz, 7, 2, 6.8)  # hub, reaching into the bracket's hole up to the motor's boss
    p -= cyl_y(cx, cz, 2.55, -3, 8)  # 5 mm motor shaft
    p -= cyl_z(cx, 4.4, 1.25, cz, cz + 8)  # an M3 set screw cuts its own thread here, onto the shaft's flat
    return p


# (name, builder, rotation that lays it flat for printing)
PRINTED = [
    ("saddle", saddle, Rot(180, 0, 0)),
    ("mallet-arm", mallet_arm, Rot(90, 0, 0)),
    ("cam", cam, Rot(90, 0, 0)),
]

# Parts that rock with the cradle, and the parts of the mallet that swing on its pivot
CRADLE = [
    "bought:cradle",
    "bought:cradle-brackets",
    "printed:saddle",
    "bought:pad",
    "bought:bowl",
    "bought:drain-fitting",
    "bought:standpipe",
    "bought:bell",
    "bought:flange-couplings",
    "bought:axle",
]
MALLET = ["printed:mallet-arm", "bought:mallet", "bought:roller", "bought:stop-screw"]


# ---------------------------------------------------------------------------
# Bought parts, simplified, for the assembly only
# ---------------------------------------------------------------------------


def frame():
    parts = [vslot(-POST_X - 20, POST_X + 20, -10, 10, TOP_Z, TOP_Z + 40)]  # top bar
    for s in (-1, 1):
        x0, x1 = sorted((s * POST_X, s * (POST_X + 20)))
        parts.append(vslot(x0, x1, POST_Y0, POST_Y1, 20, TOP_Z))  # post
        parts.append(vslot(x0 - 10, x1 + 10, -FOOT_Y, FOOT_Y, 0, 20))  # foot
    for y0 in (-FOOT_Y, FOOT_Y - 20):
        parts.append(vslot(-POST_X + 10, POST_X - 10, y0, y0 + 20, 0, 20))  # floor bar
    return parts


def brackets():
    b = []
    for s in (-1, 1):
        x0, x1 = sorted((s * POST_X, s * (POST_X + 20)))
        xi0, xi1 = sorted((s * (POST_X - 20), s * POST_X))
        side = "+x" if s > 0 else "-x"
        b.append(corner_bracket(xi0, xi1, -10, 10, TOP_Z - 20, TOP_Z, side + "+z"))  # post to top bar
        b.append(corner_bracket(x0, x1, POST_Y0 - 20, POST_Y0, 20, 40, "+y-z"))  # post to foot, front
        b.append(corner_bracket(x0, x1, POST_Y1, POST_Y1 + 20, 20, 40, "-y-z"))  # and back
        for y0, keep in ((-FOOT_Y, "-z"), (FOOT_Y - 20, "-z")):  # floor bars to feet, on top
            fx0, fx1 = sorted((s * (POST_X - 30), s * (POST_X - 10)))
            b.append(corner_bracket(fx0, fx1, y0, y0 + 20, 20, 40, ("+x" if s > 0 else "-x") + keep))
    # the shelf the rocking motor sits on, under it and behind it
    b.append(corner_bracket(POST_X - 20, POST_X, -10, 10, Z_PIVOT - 65, Z_PIVOT - 45, "+x+z"))
    b.append(corner_bracket(POST_X - 20, POST_X, 10, 30, Z_PIVOT - 45, Z_PIVOT - 25, "+x-y"))
    # the mallet's hanger to the back of the top bar, and the spring's anchor on the hanger
    hx0, hx1 = HANGER_X
    b.append(corner_bracket(hx0 - 20, hx0, 10, 30, TOP_Z + 5, TOP_Z + 25, "-y+x"))
    b.append(corner_bracket(hx1, hx1 + 20, 10, 30, TOP_Z + 5, TOP_Z + 25, "-y-x"))
    b.append(corner_bracket(hx1, hx1 + 20, 10, 30, CLAMP_Z - 5, CLAMP_Z + 15, "-x-y"))
    return b


def cradle_parts():
    arms = [vslot(x0, x0 + 20, -10, 10, CROSS_Z + 20, ARM_TOP) for x0 in (-ARM_IN - 20, ARM_IN)]
    bar = vslot(-ARM_IN - 20, ARM_IN + 20, -10, 10, CROSS_Z, CROSS_Z + 20) - cyl_z(0, 0, 8, CROSS_Z - 1, CROSS_Z + 21)
    cradle = Compound(arms + [bar])
    corners = Compound([corner_bracket(ARM_IN - 20, ARM_IN, -10, 10, CROSS_Z + 20, CROSS_Z + 40, "+x-z"),
                        corner_bracket(-ARM_IN, -ARM_IN + 20, -10, 10, CROSS_Z + 20, CROSS_Z + 40, "-x-z")])
    pad = cyl_z(0, 0, 28, Z_BOWL - 2, Z_BOWL) - cyl_z(0, 0, 8.2, Z_BOWL - 3, Z_BOWL + 1)
    # flange couplings on the arms' outer faces: 5 mm bore on the motor's shaft at the right, 8 mm on the axle at the left
    right = cyl_x(0, Z_PIVOT, 11, ARM_IN + 20, ARM_IN + 24) + cyl_x(0, Z_PIVOT, 6, ARM_IN + 24, ARM_IN + 35)
    left = cyl_x(0, Z_PIVOT, 12.5, -ARM_IN - 24, -ARM_IN - 20) + cyl_x(0, Z_PIVOT, 7, -ARM_IN - 36, -ARM_IN - 24)
    axle = rod((-ARM_IN - 29, 0, Z_PIVOT), (-POST_X + 2, 0, Z_PIVOT), 4.2, 8)
    return [
        ("cradle", cradle, (0.78, 0.8, 0.83)),
        ("cradle-brackets", corners, (0.5, 0.52, 0.55)),
        ("pad", pad, (0.12, 0.12, 0.12)),
        ("flange-couplings", Compound([right, left]), (0.6, 0.6, 0.63)),
        ("axle", axle, (0.6, 0.6, 0.63)),
    ]


def bowl_shape():
    outer = revolved(BOWL_PROFILE, BOWL_H)
    inner = revolved(INNER_PROFILE, BOWL_H + 1)
    lip = cyl_z(0, 0, RIM_R + LIP, Z_PIVOT - 1.2, Z_PIVOT) - cyl_z(0, 0, RIM_R - WALL, Z_PIVOT - 2, Z_PIVOT + 1)
    return (outer - inner - cyl_z(0, 0, 8.2, Z_BOWL - 1, Z_BOWL + 2)) + lip


def siphon_parts():
    """A push-fit bulkhead union through the bowl's bottom, the standpipe pushed into it, and the bell over that."""
    fitting = Pos(0, 0, INNER_BOTTOM + 1.5) * extrude(RegularPolygon(11, 6), amount=1.5, both=True)
    fitting += cyl_z(0, 0, 7, INNER_BOTTOM + 3, INNER_BOTTOM + 15)  # upper collet
    fitting += cyl_z(0, 0, 6, CROSS_Z + 22, Z_BOWL)  # thread
    fitting += Pos(0, 0, CROSS_Z + 22.5) * extrude(RegularPolygon(12.7, 6), amount=2.5, both=True)  # nut, in the saddle
    fitting += cyl_z(0, 0, 7, CROSS_Z + 6, CROSS_Z + 20)  # lower collet, in the bar's hole
    stub = rod((0, 0, CROSS_Z + 8), (0, 0, CROSS_Z - 22), 3, 6)  # 6 mm tube down to the hose
    standpipe = rod((0, 0, INNER_BOTTOM + 5), (0, 0, Z_HIGH), 3, 8) - rod((0, 0, INNER_BOTTOM + 4), (0, 0, Z_HIGH + 1), 2.4, 8)
    bell = rod((0, 0, Z_LOW), (0, 0, Z_HIGH + 2), 8.1, 8) - rod((0, 0, Z_LOW - 1), (0, 0, Z_HIGH), 7.0, 8)
    return [
        ("drain-fitting", fitting + stub, (0.85, 0.85, 0.85)),
        ("standpipe", standpipe, (0.75, 0.6, 0.3)),
        ("bell", bell, (0.72, 0.42, 0.25)),
    ]


HOSE_BOTTOM = (0.0, 25.0, 124.0)
HOSE_K = (Z_PIVOT - (CROSS_Z - 22)) / ((CROSS_Z - 22) - HOSE_BOTTOM[2])  # how much the hose swings for each degree of rock


def water_parts():
    hose = path([(0, 0, CROSS_Z - 20), (0, 6, CROSS_Z - 50), (0, 20, CROSS_Z - 80), HOSE_BOTTOM], 4.5)
    reservoir = box(-100, 100, -75, 75, 3, 118) - box(-98, 98, -73, 73, 5, 119)
    lid = box(-103, 103, -78, 78, 118, 124) + cyl_z(60, -50, 4, 124, 130)  # with its vent plug
    tray = box(-240, 240, -135, 135, 0, 40) - box(-238, 238, -133, 133, 2, 41)
    pz = 425.0
    pump = box(-POST_X, -POST_X + 3, -20, 20, pz - 30, pz + 30)  # bracket on the post
    pump += cyl_x(0, pz, 14, -POST_X + 3, -232) + box(-232, -212, -22, 22, pz - 22, pz + 22)  # motor and head
    suction = path([(-80, -50, 124), (-80, -50, 140), (-221, -50, 140), (-221, -16, 140), (-221, -16, pz - 22)], 2.5)
    supply = path([(-221, -16, pz + 22), (-221, -16, 528), (-55, -16, 528), (NOZZLE_X, -4, 532)], 2.5)
    clip = box(NOZZLE_X - 7, NOZZLE_X + 7, -8, 8, TOP_Z - 6, TOP_Z) - cyl_z(NOZZLE_X, 0, 3, TOP_Z - 7, TOP_Z + 1)
    needle = rod((NOZZLE_X, 0, 536), (NOZZLE_X, 0, 522), 4.5, 6) + rod((NOZZLE_X, 0, 522), (NOZZLE_X, 0, NOZZLE_TIP), 0.9, 6)
    return [
        ("drain-hose", hose, (0.9, 0.9, 0.88)),
        ("reservoir", Compound([reservoir, lid]), (0.85, 0.88, 0.9)),
        ("tray", tray, (0.3, 0.3, 0.32)),
        ("pump", pump, (0.2, 0.2, 0.22)),
        ("pump-tubes", Compound([suction, supply]), (0.9, 0.9, 0.88)),
        ("nozzle", Compound([clip, needle]), (0.6, 0.6, 0.63)),
    ]


def rocker_parts():
    """The rocking stepper on a shelf on the right post, turning the cradle through the right flange coupling."""
    shelf = vslot(ARM_IN + 30, POST_X, -10, 10, Z_PIVOT - 45, Z_PIVOT - 25)
    leg_x = ARM_IN + 37
    bracket = box(leg_x, leg_x + 3, -21, 21, Z_PIVOT - 25, Z_PIVOT + 21) + box(leg_x, leg_x + 44, -21, 21, Z_PIVOT - 25, Z_PIVOT - 22)
    bracket -= cyl_x(0, Z_PIVOT, 11.5, leg_x - 1, leg_x + 4)
    motor = box(leg_x + 3, leg_x + 43, -21, 21, Z_PIVOT - 21, Z_PIVOT + 21) + cyl_x(0, Z_PIVOT, 2.5, ARM_IN + 16, leg_x + 3)
    bearing = box(-POST_X, -POST_X + 4, -13.5, 13.5, Z_PIVOT - 24, Z_PIVOT + 24) + cyl_x(0, Z_PIVOT, 13, -POST_X + 4, -POST_X + 10)
    bearing += cyl_x(0, Z_PIVOT, 8, -POST_X + 10, -POST_X + 13)
    for dz in (-18.5, 18.5):
        bearing += cyl_x(0, Z_PIVOT + dz, 4.25, -POST_X + 4, -POST_X + 7.5)
    return [
        ("shelf", shelf, (0.78, 0.8, 0.83)),
        ("rocker-bracket", bracket, (0.35, 0.36, 0.38)),
        ("rocker-motor", motor, (0.2, 0.2, 0.22)),
        ("flange-bearing", bearing, (0.45, 0.46, 0.5)),
    ]


def mallet_parts():
    x, _, pz = PIVOT_ARM
    hx0, hx1 = HANGER_X
    hanger = vslot(hx0, hx1, 10, 30, CLAMP_Z - 13, TOP_Z + 40)
    head = Pos(x, 0, Z_STRIKE) * faceted_ball(HEAD_R)
    mallet = head + rod((x, 0, Z_STRIKE + HEAD_R - 2), (x, 0, CLAMP_Z + 25), 4.3, 8)
    roller = cyl_y(ROLLER_C[0], ROLLER_C[1], ROLLER_R, -2.5, 2.5) - cyl_y(ROLLER_C[0], ROLLER_C[1], 4, -3, 3)
    roller += cyl_y(ROLLER_C[0], ROLLER_C[1], 2.5, -9, 9) + cyl_y(ROLLER_C[0], ROLLER_C[1], 4.25, -12, -9)
    pivot = (
        cyl_y(x, pz, 4.6, -11.5, -7.5)  # bolt head and washer
        + cyl_y(x, pz, 2.5, -7.5, 16)
        + cyl_y(x, pz, 4.0, -1.8, 10)  # spacer
        + (cyl_y(x, pz, 8.0, -7, -2) - cyl_y(x, pz, 2.5, -8, -1))  # 625 bearing
    )
    stop = cyl_x(16, 446, 3.5, x - 28, x - 25) + cyl_x(16, 446, 2, x - 25, hx0 - 2.5) + cyl_x(16, 446, 3, hx0 - 2.5, hx0)
    switch = box(hx0 - 6, hx0, 19, 29, CLAMP_Z - 12, CLAMP_Z + 8) + box(x - 17, x - 15.8, 21, 27, CLAMP_Z + 8, CLAMP_Z + 21)
    spring = rod((x + 4.5, 0, CLAMP_Z + 4), (hx1 + 16, 12, CLAMP_Z + 4), 1.6, 6)
    cx, _, cz = CAM_C
    leg_y = 6.0
    bracket = box(cx - 21, cx + 21, leg_y, leg_y + 3, cz - 21, TOP_Z) + box(cx - 21, cx + 21, -10, leg_y + 3, TOP_Z - 3, TOP_Z)
    bracket -= cyl_y(cx, cz, 11.5, leg_y - 1, leg_y + 4)
    motor = box(cx - 21, cx + 21, leg_y + 3, leg_y + 43, cz - 21, cz + 21) + cyl_y(cx, cz, 2.5, -4, leg_y + 3)
    return [
        ("hanger", hanger, (0.78, 0.8, 0.83)),
        ("mallet", mallet, (0.85, 0.75, 0.55)),
        ("roller", roller, (0.6, 0.6, 0.63)),
        ("mallet-pivot", pivot, (0.5, 0.5, 0.52)),
        ("stop-screw", stop, (0.15, 0.15, 0.15)),
        ("rest-switch", switch, (0.15, 0.15, 0.15)),
        ("spring", spring, (0.6, 0.6, 0.63)),
        ("striker-bracket", bracket, (0.35, 0.36, 0.38)),
        ("striker-motor", motor, (0.2, 0.2, 0.22)),
    ]


def control_parts():
    box_x = (POST_X - 28, POST_X + 48)
    electronics = box(*box_x, POST_Y1, POST_Y1 + 55, 380, 500)
    for gx in (box_x[0] + 20, box_x[0] + 38, box_x[0] + 56):  # cable glands, all on the underside
        electronics += cyl_z(gx, POST_Y1 + 28, 6, 372, 380)
    battery = box(POST_X + 20, POST_X + 92, -40, POST_Y1 - 2, 360, 470)
    stop = box(POST_X - 8, POST_X + 18, POST_Y0 - 36, POST_Y0, 482, 518) + cyl_y(POST_X + 10, 500, 15, POST_Y0 - 46, POST_Y0 - 36)
    return [
        ("electronics-box", electronics, (0.25, 0.3, 0.45)),
        ("battery", battery, (0.75, 0.45, 0.15)),
        ("stop-button", stop, (0.75, 0.15, 0.15)),
    ]


def bought_parts():
    parts = [("frame", Compound(frame()), (0.78, 0.8, 0.83)), ("corner-brackets", Compound(brackets()), (0.5, 0.52, 0.55))]
    parts += [("bowl", bowl_shape(), (0.8, 0.82, 0.85))]
    return parts + cradle_parts() + siphon_parts() + water_parts() + rocker_parts() + mallet_parts() + control_parts()


def water_shapes():
    """Not parts: the water's surface, and one drop, for the 3D model only."""
    surface = cyl_z(0, 0, WATER_R, Z_WATER - 1, Z_WATER + 1) - cyl_z(0, 0, 8.6, Z_WATER - 2, Z_WATER + 2)
    drop = Pos(NOZZLE_X, 0, NOZZLE_TIP - 2.5) * faceted_ball(2.5)
    return [("water", surface, (0.55, 0.7, 0.85)), ("water-drop", drop, (0.55, 0.7, 0.85))]


# ---------------------------------------------------------------------------
# How it moves
# ---------------------------------------------------------------------------

DURATION = 12.5
ROCK_KEYS = [(0, 0), (1.3, 0), (2.8, ROCK), (4.3, -ROCK), (5.8, ROCK), (7.3, 0), (8.0, 0), (8.15, 3.0), (8.35, 0), (DURATION, 0)]
# Each strike: the cam leaves its park, crosses the drop, and re-cocks back to the next park.
STRIKES = [(0.40, 2.0), (4.03, 5.6), (8.75, 10.3)]  # (when the cam starts to turn, when it is cocked again)
RELEASE = 0.1  # the cam takes this long to turn 30 degrees, from its park past the drop
FLIGHT = 0.04  # the spring flings the mallet in this long after the drop


def eased(keys, t):
    """Position, speed and acceleration at time t, eased between keyframes as the site's viewer does."""
    if t <= keys[0][0]:
        return keys[0][1], 0.0, 0.0
    for (t0, v0), (t1, v1) in zip(keys, keys[1:]):
        if t <= t1:
            d, dv = t1 - t0, v1 - v0
            if d == 0:
                continue
            u = (t - t0) / d
            return v0 + dv * (3 * u * u - 2 * u**3), dv * (6 * u - 6 * u * u) / d, dv * (6 - 12 * u) / (d * d)
    return keys[-1][1], 0.0, 0.0


def smooth(u):
    u = min(1.0, max(0.0, u))
    return u * u * (3 - 2 * u)


def slosh(t_end, dt=0.0005):
    """The water's tilt from level, in degrees, as one damped sloshing mode driven by the bowl's swing."""
    w = 2 * math.pi * SLOSH_HZ
    phi = dphi = 0.0
    out, t = [], 0.0
    while t <= t_end + 1e-9:
        out.append((t, phi))
        acc = eased(ROCK_KEYS, t)[2]
        dphi += (-w * w * phi - 2 * SLOSH_DAMPING * w * dphi - SLOSH_GAIN * acc) * dt
        phi += dphi * dt
        t += dt
    return out


def cam_angle(t):
    """The cam's angle at time t, in degrees, counting whole turns so it never runs backwards."""
    angle = PARK
    for start, cocked in STRIKES:
        if t < start:
            break
        if t < start + RELEASE:
            return angle + 30.0 * (t - start) / RELEASE
        if t < cocked:
            return angle + 30.0 + 330.0 * smooth((t - start - RELEASE) / (cocked - start - RELEASE))
        angle += 360.0
    return angle


def arm_angle(t):
    """The mallet arm's swing away from the bowl, in degrees: following the cam, except in flight after the drop."""
    for start, _ in STRIKES:
        drop = start + RELEASE * (DWELL_END - PARK) / 30.0
        if drop <= t < drop + FLIGHT:
            lifted = math.degrees(math.asin(LIFT / TAIL))
            return lifted * math.cos(math.pi / 2 * (t - drop) / FLIGHT)
    lift = cam_radius(cam_angle(t)) - R_REST
    return math.degrees(math.asin(max(0.0, lift) / TAIL))


def motion(path_):
    strike_at = [start + RELEASE * (DWELL_END - PARK) / 30.0 + FLIGHT for start, _ in STRIKES]
    times = sorted({round(k * 0.04, 4) for k in range(int(DURATION / 0.04) + 1)} | {round(s + d, 4) for s in strike_at for d in (-FLIGHT, -FLIGHT / 2, 0)})

    sl = slosh(DURATION)
    water = [(t, round(sl[min(len(sl) - 1, int(round(t / 0.0005)))][1] - eased(ROCK_KEYS, t)[0], 3)) for t in times]

    # The handle flexes past the stop at each strike: the head touches the bowl, then springs back clear.
    flex = [(0.0, 0.0)]
    for s in strike_at:
        flex += [(s, 0.0), (s + 0.01, -1.5), (s + 0.025, 0.6), (s + 0.045, -0.25), (s + 0.07, 0.0)]
    flex.append((DURATION, 0.0))

    drops, fall = [(0.0, 0.0)], (NOZZLE_TIP - 2.5) - (Z_WATER + 1)
    t_fall = math.sqrt(2 * fall / G)
    t = 0.15
    while t + t_fall < DURATION - 0.1:
        drops += [(t, 0.0)] + [(t + f * t_fall, -0.5 * G * (f * t_fall) ** 2) for f in (0.25, 0.5, 0.75, 1.0)] + [(t + t_fall, 0.0)]
        t += 0.9
    drops.append((DURATION, 0.0))

    write_motion(
        path_,
        duration=DURATION,
        tracks=[
            turn(["water"], (1, 0, 0), (0, 0, Z_WATER), water, ease="linear"),
            turn(CRADLE + ["water"], (1, 0, 0), (0, 0, Z_PIVOT), ROCK_KEYS),
            turn(["bought:drain-hose"], (1, 0, 0), HOSE_BOTTOM, [(t, round(-HOSE_K * v, 2)) for t, v in ROCK_KEYS]),
            turn(["printed:cam"], (0, 1, 0), CAM_C, [(t, round(cam_angle(t), 2)) for t in times], ease="linear"),
            turn(["bought:mallet"], (0, 1, 0), (ARM_X, 0, CLAMP_Z), [(round(t, 4), v) for t, v in flex], ease="linear"),
            turn(MALLET, (0, 1, 0), PIVOT_ARM, [(t, round(arm_angle(t), 3)) for t in times], ease="linear"),
            slide(["water-drop"], (0, 0, 1), [(round(t, 4), round(v, 1)) for t, v in drops], ease="linear"),
        ],
        captions=[(round(at, 2), text) for at, text in [
            (0, "The bowl hangs level, a little over half full. A drop falls from the nozzle about once a second, and each one plinks. The mallet waits cocked."),
            (STRIKES[0][0], "The cam turns past its edge. A spring flings the mallet in; its handle flexes past the stop, strikes just below the rim and springs back clear, so the bowl rings."),
            (strike_at[0] + 0.3, "The cam turns on and pulls the mallet back for the next strike, then waits."),
            (ROCK_KEYS[1][0], "The motor rocks the bowl slowly. The water stays level as the bowl tilts, so the waterline climbs one wall and falls on the other, and the note wavers."),
            (STRIKES[1][0] - 0.1, "Struck at full tilt. The mallet hits on the line the bowl rocks about, which hardly moves, so its gap is the same at any tilt."),
            (7.4, "Rocking stops. A quick nudge sets the water sloshing at its own pace, a little under twice a second, which bends the note like a slow vibrato."),
            (STRIKES[2][0] - 0.05, "Struck while it sloshes: each strike lands on a different wave."),
            (10.4, "Over minutes, the drops fill the bowl and the note slides down. When the water reaches the top of the standpipe, the siphon drains it back to the bell's edge in under a minute, and the note glides back up."),
        ]],
    )
    peak = max(abs(p) for _, p in sl)
    print(f"water: {Z_WATER - INNER_BOTTOM:.0f} mm deep shown; siphon cycles {Z_LOW - INNER_BOTTOM:.0f} to {Z_HIGH - INNER_BOTTOM:.0f} mm, "
          f"{volume_between(Z_LOW - Z_BOWL, Z_HIGH - Z_BOWL):.2f} L a cycle; slosh peaks at {peak:.1f} degrees")
    print(f"  strikes at {', '.join(f'{s:.2f}' for s in strike_at)} s; drop falls {fall:.0f} mm in {t_fall:.2f} s; hose swings {HOSE_K:.2f} x the rock")


# ---------------------------------------------------------------------------
# Checks
# ---------------------------------------------------------------------------


def check_clearances(printed, bought):
    """Every moving part against every part it doesn't touch on purpose, at rest, at full rock and cocked."""
    shapes = dict(printed + [(f"bought:{n}", s) for n, s, _ in bought])
    touching = {
        frozenset(p)
        for p in [
            ("bought:cradle", "bought:cradle-brackets"), ("bought:cradle", "printed:saddle"), ("printed:saddle", "bought:pad"),
            ("bought:pad", "bought:bowl"), ("bought:bowl", "bought:drain-fitting"), ("printed:saddle", "bought:drain-fitting"),
            ("bought:pad", "bought:drain-fitting"), ("bought:cradle", "bought:drain-fitting"), ("bought:drain-fitting", "bought:standpipe"),
            ("bought:standpipe", "bought:bell"), ("bought:cradle", "bought:flange-couplings"), ("bought:flange-couplings", "bought:axle"),
            ("bought:flange-couplings", "bought:rocker-motor"), ("bought:axle", "bought:flange-bearing"),
            ("printed:mallet-arm", "bought:mallet"), ("printed:mallet-arm", "bought:roller"), ("printed:mallet-arm", "bought:stop-screw"),
            ("printed:mallet-arm", "bought:mallet-pivot"), ("printed:mallet-arm", "bought:rest-switch"), ("printed:mallet-arm", "bought:spring"),
            ("bought:stop-screw", "bought:hanger"), ("bought:spring", "bought:corner-brackets"), ("bought:nozzle", "bought:pump-tubes"), ("bought:roller", "bought:mallet-pivot"), ("printed:cam", "bought:striker-motor"),
            ("bought:drain-fitting", "bought:drain-hose"), ("bought:drain-hose", "bought:reservoir"), ("bought:mallet-pivot", "bought:hanger"),
        ]
    }
    def rocked(shape, deg):
        return Pos(0, 0, Z_PIVOT) * Rot(deg, 0, 0) * Pos(0, 0, -Z_PIVOT) * shape

    def cocked(shape):
        x, y, z = PIVOT_ARM
        return Pos(x, y, z) * Rot(0, math.degrees(math.asin(LIFT / TAIL)), 0) * Pos(-x, -y, -z) * shape

    problems = 0
    for label, move in [
        ("at rest", lambda n, s: s),
        (f"rocked +{ROCK} degrees, mallet cocked", lambda n, s: rocked(s, ROCK) if n in CRADLE else cocked(s) if n in MALLET else s),
        (f"rocked -{ROCK} degrees", lambda n, s: rocked(s, -ROCK) if n in CRADLE else s),
    ]:
        placed = {n: move(n, s) for n, s in shapes.items()}
        names = sorted(placed)
        for i, a in enumerate(names):
            for b in names[i + 1:]:
                if frozenset((a, b)) in touching or (a in CRADLE and b in CRADLE):
                    continue
                if a not in CRADLE + MALLET and b not in CRADLE + MALLET and label != "at rest":
                    continue
                ba, bb = placed[a].bounding_box(), placed[b].bounding_box()
                if ba.min.X > bb.max.X or bb.min.X > ba.max.X or ba.min.Y > bb.max.Y or bb.min.Y > ba.max.Y or ba.min.Z > bb.max.Z or bb.min.Z > ba.max.Z:
                    continue
                common = placed[a] & placed[b]
                vol = sum(s.volume for s in common.solids()) if common else 0.0
                if vol > 1.0:
                    problems += 1
                    print(f"  overlap {label}: {a} and {b}, {vol:.0f} mm3")
    print("clearances:", "all clear" if not problems else f"{problems} overlaps")


# ---------------------------------------------------------------------------
# Build and export
# ---------------------------------------------------------------------------


def lay_flat(shape, rotation):
    s = rotation * shape
    bb = s.bounding_box()
    return Pos(-(bb.min.X + bb.max.X) / 2, -(bb.min.Y + bb.max.Y) / 2, -bb.min.Z) * s


def assemble(with_water):
    assembly = []
    for name, build, _ in PRINTED:
        part = build()
        part.label = f"printed:{name}"
        part.color = Color(0.86, 0.42, 0.28)
        assembly.append(part)
    for name, shape, rgb in bought_parts():
        shape.label = f"bought:{name}"
        shape.color = Color(*rgb)
        assembly.append(shape)
    if with_water:
        for name, shape, rgb in water_shapes():
            shape.label = name
            shape.color = Color(*rgb)
            assembly.append(shape)
    return Compound(children=assembly, label="drip-bowl")


def main():
    (OUT / "stl").mkdir(parents=True, exist_ok=True)
    DRAWINGS.mkdir(parents=True, exist_ok=True)

    print(f"{'part':12} {'volume cm3':>10}  {'print size (mm)':>18}  valid")
    for name, build, rotation in PRINTED:
        part = build()
        flat = lay_flat(part, rotation)
        size = flat.bounding_box().size
        print(f"{name:12} {part.volume / 1000:10.1f}  {size.X:5.0f} x {size.Y:4.0f} x {size.Z:4.0f}  {part.is_valid}")
        export_stl(flat, OUT / "stl" / f"{name}.stl", tolerance=0.02, angular_tolerance=0.2)

    if "--check" in sys.argv:
        check_clearances([(f"printed:{n}", b()) for n, b, _ in PRINTED], bought_parts())

    export_step(assemble(with_water=False), OUT / "drip-bowl.step")
    export_gltf(assemble(with_water=True), OUT / "drip-bowl.glb", unit=Unit.MM, binary=True, linear_deflection=0.08, angular_deflection=0.3)
    compact_glb(OUT / "drip-bowl.glb")
    motion(OUT / "drip-bowl-motion.json")

    with zipfile.ZipFile(OUT / "drip-bowl-stl.zip", "w", zipfile.ZIP_DEFLATED) as z:
        for stl in sorted((OUT / "stl").glob("*.stl")):
            z.write(stl, f"drip-bowl/{stl.name}")
    shutil.copyfile(__file__, OUT / "drip-bowl-cad.py")

    render(DRAWINGS / "drip-bowl-render.svg")
    print("written to", OUT)


def sheet(path_, title, note, views):
    """Line drawings of the model, framed like the other drawing sheets.
    views: (build, eye, look_at, caption) for each figure, stacked top to bottom. Each figure's shapes are
    built fresh just before it is drawn, because a shape put into two compounds drops out of the first."""
    figures, y = [], 110
    for i, (build, eye, look_at, caption) in enumerate(views, start=1):
        visible, _hidden = build().project_to_viewport(eye, viewport_up=(0, 0, 1), look_at=look_at)
        svg = ExportSVG(margin=4, line_weight=0.25)
        svg.add_layer("visible", line_color=(0.067, 0.067, 0.067), line_weight=0.25)
        svg.add_shape(visible, layer="visible")
        raw = path_.with_suffix(f".{i}.svg")
        svg.write(raw)
        inner = raw.read_text()
        raw.unlink()
        view_box = re.search(r'viewBox="([^"]+)"', inner).group(1)
        body = inner[inner.index(">", inner.index("<svg")) + 1 : inner.rindex("</svg>")]
        _, _, vw, vh = (float(v) for v in view_box.split())
        w = min(1120, round(620 * vw / vh))
        h = round(w * vh / vw)
        figures.append(f'<svg x="{600 - w / 2:.0f}" y="{y}" width="{w}" height="{h}" viewBox="{view_box}">{body}</svg>')
        figures.append(f'<text x="600" y="{y + h + 34}" font-size="15" letter-spacing="2" text-anchor="middle" fill="#111">FIG. {i}   {caption}</text>')
        y += h + 80
    height = y
    path_.write_text(
        f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 {height}" width="1200" height="{height}" '
        f'font-family="Georgia, \'Times New Roman\', serif">\n'
        f"<title>{title}</title>\n"
        f'<rect width="1200" height="{height}" fill="#fff"/>\n'
        '<text x="40" y="48" font-size="13" letter-spacing="4" fill="#111">THE MACHINE</text>\n'
        f'<text x="40" y="76" font-size="22" fill="#111">{title}</text>\n'
        f'<text x="1160" y="48" font-size="12" text-anchor="end" fill="#111">{note}</text>\n'
        '<text x="1160" y="66" font-size="12" text-anchor="end" fill="#111">2026-10-19</text>\n'
        '<line x1="40" y1="88" x2="1160" y2="88" stroke="#111" stroke-width="1"/>\n'
        + "\n".join(figures)
        + "\n</svg>\n"
    )


def whole():
    return Compound(children=[b() for _, b, _ in PRINTED] + [s for _, s, _ in bought_parts()])


def mallet_closeup():
    """The mallet, its cam and the hanger, with the bowl's rim and the top bar cut short around them."""
    keep = box(-215, -60, -60, 60, Z_STRIKE - 40, TOP_Z + 45)
    parts = [mallet_arm(), cam()] + [s for _, s, _ in mallet_parts()]
    parts += [bowl_shape() & keep, vslot(-215, -60, -10, 10, TOP_Z, TOP_Z + 40)]
    return Compound(children=parts)


def siphon_cut():
    """The bowl, the siphon and the cradle's bar cut through the middle, seen from the front."""
    back = box(-75, 75, 0, 80, CROSS_Z - 25, Z_HIGH + 20)
    parts = [bowl_shape(), saddle(), vslot(-150, 150, -10, 10, CROSS_Z, CROSS_Z + 20) - cyl_z(0, 0, 8, CROSS_Z - 1, CROSS_Z + 21)]
    parts += [s for _, s, _ in siphon_parts()]
    parts.append(cyl_z(0, 0, 28, Z_BOWL - 2, Z_BOWL) - cyl_z(0, 0, 8.2, Z_BOWL - 3, Z_BOWL + 1))
    return Compound(children=[p & back for p in parts])


def render(path_):
    sheet(
        path_,
        "Drip Bowl: 3D views",
        "Drawn from the CAD model.",
        [
            (whole, (-1100, -1500, 1050), (0, 0, 300), "THE WHOLE ELEMENT, FROM THE FRONT"),
            (mallet_closeup, (-560, -700, 640), (-140, 0, 450), "THE MALLET: CAM, ARM, STOP AND SPRING, AND THE RIM IT STRIKES"),
            (siphon_cut, (0, -700, Z_BOWL + 30), (0, 0, Z_BOWL + 30), "THE BELL SIPHON, CUT THROUGH THE MIDDLE OF THE BOWL'S BOTTOM"),
        ],
    )


if __name__ == "__main__":
    main()
