Konubinix' opinionated web of thoughts

Cascade Overflow Gutter

Fleeting

Flushing the toilet with drinkable water feels wasteful, and a handful of other uses around the house don’t need it potable either. Rainwater handles those, stored in 5L cans I cycle through as they empty. Refilling them is the painpoint: each can takes minutes under the spout, and I have to be there at the right moment to swap the next one in before water spills. Five cans, five fills to stand watch over.

The goal is to fill all my 5L cans in a single pour: water enters at the top, fills the first can, overflows into the second, and so on down the line.

A first attempt — cascade overflow funnel, written in OpenSCAD — drowned in complexity. Three issues killed it:

  1. Threading is harder to print than I had hoped, and stacking a threaded piece on top of another forces me to design gasket seats and the like.
  2. The funnel itself is hard to print (I still don’t know why) and takes too much space.
  3. OpenSCAD is hard to read — features end up buried inside nested unions and differences, and every revisit costs time before I find what to change.

Let’s try a simpler option: a slanted gutter pierced by a hole. Under the hole, a short downspout fits snugly inside the can’s neck. Water enters at the top of the gutter, runs down, and falls through the hole into the can. Once the can is full, the water overflows past the hole and continues down the gutter onto the next can’s gutter — a bit of PTFE around the hole should be enough to keep the flow skimming over instead of dribbling through.

Two constraints shape the geometry:

  • The slant must be shallow enough to keep many cans fitting side by side — a steep gutter eats vertical space — but steep enough that water reaches the hole rather than spilling off the side.
  • The lower end of each gutter (its tongue) must be narrower than its higher end, so that it rests naturally inside the next gutter’s high end.

With OpenSCAD out of the picture, build123d and CadQuery were the contenders. CadQuery is the one that clicked: its Python API builds parts the way one would draw them — sketch, extrude, fillet — with named intermediate features that survive growth.

The gutter body

The cross-section is a square U rather than a curved one — flat bed and vertical walls print cleanly, where curves would force overhangs. The funnel wall needs ~6mm to print well, and the gutter wall is matched to it so the funnel starts falling right where the gutter wall ends — no ledge for water to pool on.

import math

GUTTER_LENGTH = 175
GUTTER_SLOPE = 0.10
FUNNEL_WALL_WIDTH = 6
GUTTER_WALL = FUNNEL_WALL_WIDTH
GUTTER_FLOOR_DEPTH = 2
HOLE_OFFSET_FROM_LOW = GUTTER_LENGTH / 2
GUTTER_OUTER_WIDTH_HIGH = 60
GUTTER_OUTER_WIDTH_LOW = 44
GUTTER_TROUGH_DEPTH = 12
TAPER_LENGTH = 40
TONGUE_TIP_LENGTH = 15

gutter_thickness = GUTTER_FLOOR_DEPTH + GUTTER_TROUGH_DEPTH
slope_rad = math.atan(GUTTER_SLOPE)
slope_deg = math.degrees(slope_rad)

Under the hood, CadQuery’s multisection sweep splines smoothly between cross-sections, bowing the sides; a ruled loft keeps the joins straight, so the constant sections stay constant and the taper between them stays planar.

def u_section(width, thickness):
    outer = width / 2
    inner = (width - GUTTER_WALL) / 2
    return [
        (-outer, 0),
        (-outer, thickness),
        (-inner, thickness),
        (-inner, GUTTER_FLOOR_DEPTH),
        ( inner, GUTTER_FLOOR_DEPTH),
        ( inner, thickness),
        ( outer, thickness),
        ( outer, 0),
    ]

def make_gutter(trough_depth):
    thickness = GUTTER_FLOOR_DEPTH + trough_depth
    return (
        cq.Workplane("YZ")
        .polyline(u_section(GUTTER_OUTER_WIDTH_LOW, thickness)).close()
        .workplane(offset=TONGUE_TIP_LENGTH)
        .polyline(u_section(GUTTER_OUTER_WIDTH_LOW, thickness)).close()
        .workplane(offset=TAPER_LENGTH)
        .polyline(u_section(GUTTER_OUTER_WIDTH_HIGH, thickness)).close()
        .workplane(offset=GUTTER_LENGTH - TAPER_LENGTH - TONGUE_TIP_LENGTH)
        .polyline(u_section(GUTTER_OUTER_WIDTH_HIGH, thickness)).close()
        .loft(ruled=True)
    )

gutter = make_gutter(GUTTER_TROUGH_DEPTH)

The trough floor is the bed where the funnel will descend from, so we tag it. Under the hood, CadQuery’s tags don’t survive rotation, so the bed’s plane is pre-computed from the slope and re-attached after the gutter is tilted around its low end.

bed_plane = cq.Plane(
    origin=(-GUTTER_FLOOR_DEPTH * math.sin(slope_rad), 0, GUTTER_FLOOR_DEPTH * math.cos(slope_rad)),
    xDir=(math.cos(slope_rad), 0, math.sin(slope_rad)),
    normal=(-math.sin(slope_rad), 0, math.cos(slope_rad)),
)
gutter = gutter.rotate((0, 0, 0), (0, 1, 0), -slope_deg)
gutter = cq.Workplane(bed_plane).add(gutter.val()).tag("bed")

Let’s see what the bare gutter looks like.

Adding a funnel and a downspout

Two cans, two neck diameters — the Netto/Ardea narrower, the MIEUXA wider. Rather than parameterising the geometry on the can, we print only the Netto version and use an adapter to fit the wider MIEUXA neck. The Netto value drives the gutter; both feed the adapter.

NECK_INNER_D = 35

NECK_INNER_D = 34.3

The downspout seat has to hold despite printer tolerances and can-to-can variation. A uniform-diameter cylinder fails — too snug it won’t go in, too loose it rattles. A slight cone, looser at the bottom, slides in and wedges itself as it seats.

Where the funnel slants, the shell becomes nearly overhanging at its corners. The wall there must span the per-layer bead offset, or pinholes leak between layers.

DOWNSPOUT_DROP = 12
DOWNSPOUT_WALL = 3.2
DOWNSPOUT_INSERT_RADIUS = NECK_INNER_D / 2
DOWNSPOUT_CLEARANCE = 0.4
FUNNEL_DEPTH = 10

The funnel’s top spans the full trough width, so water can’t slip past the hole until the can fills and the level rises back into the trough.

outer_hole_w = GUTTER_OUTER_WIDTH_HIGH
inner_hole_w = outer_hole_w - FUNNEL_WALL_WIDTH

bed_p = gutter.workplaneFromTagged("bed").plane
hole_center = bed_p.toWorldCoords((HOLE_OFFSET_FROM_LOW, 0, 0))

outer_loft = (
    cq.Workplane().add(
        gutter.workplaneFromTagged("bed")
        .center(HOLE_OFFSET_FROM_LOW, 0)
        .rect(outer_hole_w, outer_hole_w)
        .val()
    ).toPending()
    .workplane(offset=-FUNNEL_DEPTH)
    .center(hole_center.x, 0)
    .circle(DOWNSPOUT_INSERT_RADIUS + DOWNSPOUT_CLEARANCE)
    .workplane(offset=-DOWNSPOUT_DROP)
    .circle(DOWNSPOUT_INSERT_RADIUS - DOWNSPOUT_CLEARANCE)
    .loft(ruled=True)
)

inner_loft = (
    cq.Workplane().add(
        gutter.workplaneFromTagged("bed")
        .center(HOLE_OFFSET_FROM_LOW, 0)
        .rect(inner_hole_w, inner_hole_w)
        .val()
    ).toPending()
    .workplane(offset=-FUNNEL_DEPTH)
    .center(hole_center.x, 0)
    .circle(DOWNSPOUT_INSERT_RADIUS - DOWNSPOUT_WALL + DOWNSPOUT_CLEARANCE)
    .workplane(offset=-DOWNSPOUT_DROP)
    .circle(DOWNSPOUT_INSERT_RADIUS - DOWNSPOUT_WALL - DOWNSPOUT_CLEARANCE)
    .loft(ruled=True)
)

gutter = gutter.union(outer_loft).cut(inner_loft)

Let’s see what the assembled gutter looks like.

How to seal the funnel

Here are the things I tried so far

  • PTFE tape Works well, but tends to peel off after several uses
  • Sealing compound

Works well, I fear it may peel off after several uses like PTFE. Still need some time to conclude.

  • Silicone grease

TBD

Printing in two pieces

Printed in one piece, the funnel and downspout would need supports. We don’t want supports, so we slice the gutter along its underside: the gutter prints flat trough-up, the funnel flipped downspout-up, and glue bonds them back.

cutter = cq.Workplane("XY").transformed(rotate=(0, -slope_deg, 0)).rect(1000, 1000).extrude(1000)
gutter_top = gutter.intersect(cutter)
funnel_piece = gutter.cut(cutter)

Let’s see how each half comes out.

Adding an extension

The print bed isn’t long enough to print a gutter that reaches the next one in a single piece. The extension adds the missing length and clips onto the tongue with a snap-fit lip — no fastener needed.

EXTENSION_WALL = 3
EXTENSION_FLOOR = 2
EXTENSION_LIP_HEIGHT = 1
EXTENSION_BRIDGE_HEIGHT = 1
EXTENSION_CLEARANCE = 0.2
EXTENSION_LENGTH = 30

def make_extension(thickness):
    outer_w    = GUTTER_OUTER_WIDTH_LOW + 2 * EXTENSION_CLEARANCE + 2 * EXTENSION_WALL
    lip_bottom = thickness + EXTENSION_CLEARANCE
    lip_top    = lip_bottom + EXTENSION_LIP_HEIGHT
    bridge_top = lip_top + EXTENSION_BRIDGE_HEIGHT
    lip_inner  = (GUTTER_OUTER_WIDTH_LOW - GUTTER_WALL) / 2 + EXTENSION_CLEARANCE
    lip_outer  = GUTTER_OUTER_WIDTH_LOW / 2 + EXTENSION_CLEARANCE
    extension_section = [
        (-outer_w/2,  -EXTENSION_FLOOR),
        (-outer_w/2,   bridge_top),
        (-lip_inner,   bridge_top),
        (-lip_inner,   lip_bottom),
        (-lip_outer,   lip_bottom),
        (-lip_outer,   0),
        ( lip_outer,   0),
        ( lip_outer,   lip_bottom),
        ( lip_inner,   lip_bottom),
        ( lip_inner,   bridge_top),
        ( outer_w/2,   bridge_top),
        ( outer_w/2,  -EXTENSION_FLOOR),
    ]
    return (
        cq.Workplane("YZ")
        .polyline(extension_section).close()
        .extrude(EXTENSION_LENGTH)
    )

extension = make_extension(gutter_thickness)

Let’s see what comes out.

Netto to Mieuxa adapter

The adapter receives the Netto downspout at the top and plugs into the MIEUXA neck at the bottom. Same snug-fit logic as the downspout-into-can: cone goes into cylinder. The MIEUXA neck is already a cylinder, so the adapter’s bottom is a cone; the downspout from above is already a cone, so the adapter’s top is a cylinder. A conical frustum joins the two — no step on either surface, prints without supports.

NECK_INNER_D = 34.3
NETTO_NECK_INNER_D = NECK_INNER_D

NECK_INNER_D = 35
MIEUXA_NECK_INNER_D = NECK_INNER_D

DOWNSPOUT_DROP = 12
DOWNSPOUT_WALL = 3.2
DOWNSPOUT_INSERT_RADIUS = NECK_INNER_D / 2
DOWNSPOUT_CLEARANCE = 0.4
FUNNEL_DEPTH = 10

ADAPTER_FEMALE_LENGTH = 10
ADAPTER_JUNCTION_LENGTH = 3
ADAPTER_MALE_LENGTH = 10

The body uses sleeve_tapered() from the CadQuery pipe and sleeve shared block.

adapter = sleeve_tapered(
    cq.Workplane("XY"),
    female=(ADAPTER_FEMALE_LENGTH, NETTO_NECK_INNER_D, NETTO_NECK_INNER_D + DOWNSPOUT_WALL * 2),
    junction_length=ADAPTER_JUNCTION_LENGTH,
    male=(ADAPTER_MALE_LENGTH, MIEUXA_NECK_INNER_D - DOWNSPOUT_WALL * 2, MIEUXA_NECK_INNER_D),
    tolerance=(0, 1),
)

Once seated in the can’s neck the adapter can be glued in permanently. The can is HDPE — a low-surface-energy plastic that ordinary glues refuse — but a flexible polymer mastic (MS-Polymer, PU, or hybrid SMP) bonds it cleanly and stays watertight. Wood glue, acrylic, and neoprene don’t. Tube labels often skip the chemistry — both the Sika metal-bonding and the Bostik cladding tubes I had on hand needed a look at the technical data sheet to confirm the family. Scuff the contact zone with P120, wipe with isopropanol, lay a thin bead, and give it 24h to cure.

Netto to Netto adapter

Same as the Netto-to-Mieuxa adapter but with the male plug sized for a Netto neck. The canisters are the same height so no extra length is needed.

Netto cap

A full can travels back to the toilet, then sits there between uses. Without a cap it sloshes on the way and gathers dust at the rim. The same wedging cone the downspout uses plugs the neck cleanly; a flat grip on top closes it.

CAP_PLUG_LENGTH = 12
CAP_GRIP_DIAMETER = NECK_INNER_D + 5
CAP_GRIP_THICKNESS = 3

The plug stays hollow — a solid cone wastes plastic — and the grip disk seals it from above, so water never reaches the inside.

cap = cone(
    cq.Workplane("XY"),
    height=CAP_PLUG_LENGTH,
    inner_top=NECK_INNER_D - 2 * DOWNSPOUT_WALL,
    inner_bottom=NECK_INNER_D - 2 * DOWNSPOUT_WALL,
    outer_top=NECK_INNER_D + DOWNSPOUT_CLEARANCE,
    outer_bottom=NECK_INNER_D - DOWNSPOUT_CLEARANCE,
)
cap = (
    cap.faces(">Z").workplane()
    .circle(CAP_GRIP_DIAMETER / 2)
    .extrude(CAP_GRIP_THICKNESS)
)

The cone wedges in tightly but water still seeps along the print’s layer stripes when the can travels full. PTFE tape wrapped around the plug seals those grooves well, and the few extra wraps fatten the Netto-sized cone enough to seat in the wider MIEUXA neck too — a single cap covers both cans. The tape is shaved off after a handful of open/close cycles though. Silicone grease should hold much longer — it redistributes at each insertion instead of being scraped off — and is the next thing to try; without the tape’s thickness, though, the MIEUXA would then need its own dimensioned cap. Multi-purpose lithium grease is not a substitute: it washes out in water and can attack the plastic.

Let’s see what the cap looks like.

A start gutter for the inflow

Once the cascade is running, every gutter sees the same gentle film trickling in from above. The first gutter is different — it takes the full pour from the source, faster than the funnel can drain. Water rushes past the upstream end before the trough has filled, then splashes over the side walls as it backfills.

A transverse wall closes the high end so water cannot run off the back. A deeper trough lifts the side walls so the splash stays contained. The rest of the geometry is the regular one.

START_TROUGH_DEPTH = 25
start_thickness = GUTTER_FLOOR_DEPTH + START_TROUGH_DEPTH

The closing wall is a slab perpendicular to the floor, spanning the full outer width and rising to the trough rim.

def closing_wall(at_x, thickness):
    return (
        cq.Workplane("YZ")
        .workplane(offset=at_x)
        .rect(GUTTER_OUTER_WIDTH_HIGH, thickness, centered=(True, False))
        .extrude(GUTTER_WALL)
    )

For the start gutter, the wall sits just inside the high end.

gutter = make_gutter(START_TROUGH_DEPTH)
gutter = gutter.union(closing_wall(GUTTER_LENGTH - GUTTER_WALL, start_thickness))

Everything below the bed is unchanged, so the same funnel piece glues underneath. Only the top piece is new — let’s see what comes out.

The extension that bridges to the next gutter needs the same adjustment: same tongue width, but its lip slot has to rise to clear the taller walls.

extension = make_extension(start_thickness)

An end gutter for the last can

The other end of the cascade has its own pour problem. Once the last can fills, water keeps overflowing along the trough — but there is no next gutter to receive it. The overflow spills off the low end onto the floor.

A wall stops the runoff right past the funnel hole: nothing downstream would receive it, so the body has no reason to extend further. Taller side walls contain the splash, same reason as the start gutter, and the tongue’s taper goes too — the body stays full-width across its shortened length.

END_TROUGH_DEPTH = 25
end_thickness = GUTTER_FLOOR_DEPTH + END_TROUGH_DEPTH

The body extrudes the wide cross-section straight up to the high end — no loft, no taper. The closing wall fills the body’s low end, flush with the funnel hole.

end_body_low = HOLE_OFFSET_FROM_LOW - (GUTTER_OUTER_WIDTH_HIGH - FUNNEL_WALL_WIDTH) / 2 - GUTTER_WALL
gutter = (
    cq.Workplane("YZ")
    .workplane(offset=end_body_low)
    .polyline(u_section(GUTTER_OUTER_WIDTH_HIGH, end_thickness)).close()
    .extrude(GUTTER_LENGTH - end_body_low)
)
gutter = gutter.union(closing_wall(end_body_low, end_thickness))

Everything below the bed is unchanged, so the same funnel piece glues underneath. Let’s see what the new top piece comes out as.

Visualising the cascade

The full chain from top to bottom — start gutter, regular gutter, end gutter, with extensions bridging the pairs and a MIEUXA adapter under each funnel. One step past the end, a removed-and-capped can position shows the cap seated on its adapter.

The result

Every part prints clean on a 0.4 mm nozzle with Bambu Studio’s Extra Draft preset — no tuning needed.