Cascade Overflow Gutter
FleetingFlushing 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:
- 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.
- The funnel itself is hard to print (I still don’t know why) and takes too much space.
- 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):
outer = width / 2
inner = (width - GUTTER_WALL) / 2
return [
(-outer, 0),
(-outer, gutter_thickness),
(-inner, gutter_thickness),
(-inner, GUTTER_FLOOR_DEPTH),
( inner, GUTTER_FLOOR_DEPTH),
( inner, gutter_thickness),
( outer, gutter_thickness),
( outer, 0),
]
gutter = (
cq.Workplane("YZ")
.polyline(u_section(GUTTER_OUTER_WIDTH_LOW)).close()
.workplane(offset=TONGUE_TIP_LENGTH)
.polyline(u_section(GUTTER_OUTER_WIDTH_LOW)).close()
.workplane(offset=TAPER_LENGTH)
.polyline(u_section(GUTTER_OUTER_WIDTH_HIGH)).close()
.workplane(offset=GUTTER_LENGTH - TAPER_LENGTH - TONGUE_TIP_LENGTH)
.polyline(u_section(GUTTER_OUTER_WIDTH_HIGH)).close()
.loft(ruled=True)
)
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.
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 = 50
outer_w = GUTTER_OUTER_WIDTH_LOW + 2 * EXTENSION_CLEARANCE + 2 * EXTENSION_WALL
lip_bottom = gutter_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),
]
extension = (
cq.Workplane("YZ")
.polyline(extension_section).close()
.extrude(EXTENSION_LENGTH)
)
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 but is shaved off after a handful of open/close cycles. Silicone grease should hold much longer — it redistributes at each insertion instead of being scraped off — and is the next thing to try. 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.
Visualising them together
Exploded and assembled views of the three parts together.
The result
Every part prints clean on a 0.4 mm nozzle with Bambu Studio’s Extra Draft preset — no tuning needed.