From c8df6abdefb98eae136099c015b385e8377700ae Mon Sep 17 00:00:00 2001 From: Sam Hadow Date: Thu, 8 Oct 2026 18:27:08 +0200 Subject: [PATCH] spiral pattern self watering pot --- README.md | 8 ++ flower_pot/spiral_pot.scad | 225 +++++++++++++++++++++++++++++++++++++ spiral_pot.py | 101 +++++++++++++++++ 3 files changed, 334 insertions(+) create mode 100644 flower_pot/spiral_pot.scad create mode 100755 spiral_pot.py diff --git a/README.md b/README.md index 99dbbd0..98757c4 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,14 @@ To generate the pot and tray STL files: python weave_planter.py ``` +### spiral_pot + +Parameterized round self watering spiral pot. Includes an inner pot and an outer pot with a reservoir and a spiral pattern. (It takes a long time to render). +To generate the STL files: +```bash +python spiral_pot.py +``` + ### seedlings_tray Parameterized seedlings tray with pushable plates. diff --git a/flower_pot/spiral_pot.scad b/flower_pot/spiral_pot.scad new file mode 100644 index 0000000..c3d2e35 --- /dev/null +++ b/flower_pot/spiral_pot.scad @@ -0,0 +1,225 @@ +// ==================================================================== +// Two-part self-watering flower pot, designed for PETG +// ==================================================================== +// OUTER pot : watertight reservoir cup. Spiral ridges on the outside, smooth tapered inside, with a conical seat at the rim. +// INNER pot : planter with drainage holes. Its conical underside rests on the seat, which leaves a water reservoir underneath. It lifts straight out (tapered fit, no snap that can jam). +// ==================================================================== + +/* [What to render] */ +part = "print"; // [print:Both parts laid out for printing, outer:Outer pot only, inner:Inner pot only, assembly:Assembled preview, section:Assembled cut-away preview] + +/* [Main dimensions (mm)] */ +// Outer diameter at the top, measured over the ridge crests +pot_diameter = 100; +// Height of the outer pot (rim included) +pot_height = 100; +// Bottom diameter / top diameter (1 = straight sides, lower = more taper) +bottom_ratio = 0.8; + +/* [Reservoir, walls and fit] */ +// Water space between the outer floor and the underside of the inner pot +reservoir_depth = 25; +// Wall thickness (6 x 0.4 mm lines) +wall = 2.4; +// Floor thickness of both parts +base_thickness = 2.4; +// Gap between the inner pot and the outer pot (raise for a looser fit) +clearance = 0.4; +// Height of the collar that stands above the outer rim (also your finger grip) +collar_height = 1; +// Horizontal width of the conical seat at the top of the outer pot +seat = 1.4; +// Steepness of the seat, measured from vertical (smaller = steeper = easier to print; max 50) +seat_angle = 40; + +/* [Spiral pattern] */ +// Number of ridges around the pot +ridge_count = 12; +// Peak-to-valley depth of the ridges +ridge_depth = 2.4; +// Total twist from bottom to top in degrees (negative = other direction) +twist = 180; + +/* [Drainage holes in the inner pot] */ +hole_diameter = 5; +// Centre-to-centre distance between holes +hole_spacing = 12; +// Keep-out distance between holes and the wall +hole_margin = 3; + +/* [Hidden] */ +$fn = 1024; +bottom_chamfer = 1.2; // height of the elephant-foot chamfer on the outer pot +bottom_chamfer_run = 0.8; // its horizontal width (less than the height = under 45 degrees) +inner_chamfer = 0.6; // horizontal width of the 1 mm high elephant-foot chamfer on the inner pot + +// --------------------------------------------------------------------- +// Derived geometry +// --------------------------------------------------------------------- +Rt = pot_diameter / 2; // crest radius at the top +Rb = Rt * bottom_ratio; // crest radius at the bottom +s_top = Rt / Rb; // scale applied by the twisted extrusion +Rv = Rt * (1 - ridge_depth / Rb); // valley radius at the top +Cb = Rb - ridge_depth - wall; // cavity radius at z = 0 +Ct = Rv - wall; // cavity radius at z = pot_height +zb = base_thickness + reservoir_depth; // z of the inner pot's underside + +m = (Ct - Cb) / pot_height; // slope of the cavity radius (taper) +k = tan(seat_angle); // slope of the seat cone (radius per mm of height) +seat_dz = seat / k; // vertical height of the seat +z_seat = pot_height - seat_dz; // where the seat starts + +function lerp_z(a, b, z) = a + (b - a) * z / pot_height; +function cav(z) = lerp_z(Cb, Ct, z); // outer pot: inner (cavity) radius +function rb(z) = cav(z) - clearance; // inner pot: outer radius +function env(z) = lerp_z(Rb, Rt, z); // crest radius + +r_seat_top = cav(z_seat) + seat; // seat radius at the rim height where the inner pot's wall meets the seat cone +z1 = z_seat - clearance / (k - m); + +// --------------------------------------------------------------------- +// Sanity checks +// --------------------------------------------------------------------- +rim_flat = Rv - r_seat_top; +overhang = atan((ridge_depth / 2) * ridge_count * (abs(twist) * PI / 180 / pot_height) * s_top + + (Rt - Rb) / pot_height); + +assert(bottom_ratio > 0.4 && bottom_ratio <= 1, "bottom_ratio must be between 0.4 and 1"); +assert(pot_height > reservoir_depth + 2 * base_thickness + 30, + "pot_height is too small for this reservoir_depth"); +assert(seat_angle > atan(m) + 5 && seat_angle <= 50, + "seat_angle must be between about 10 and 50 degrees"); +assert(rim_flat >= 0.8, + "Rim too thin: increase wall, or reduce seat / ridge_depth"); +assert(z1 > zb + 2, + "Seat is too low for this reservoir_depth: reduce seat or increase pot_height"); +assert(Ct > hole_diameter, "pot_diameter is too small for these settings"); + +echo(str("Outer pot, steepest outward overhang: about ", round(overhang), " deg (PETG prints well up to ~40-45)")); +echo(str("Inner pot, steepest overhang: ", seat_angle, " deg (seat cone); no flat underside")); +if (overhang > 45) + echo("WARNING: ridges too steep. Reduce twist, ridge_depth or ridge_count."); +echo(str("Flat rim width: ", rim_flat, " mm")); +echo(str("Reservoir volume: about ", round(PI * pow(cav(zb / 2), 2) * reservoir_depth / 1000), " ml")); + +// --------------------------------------------------------------------- +// OUTER pot +// --------------------------------------------------------------------- +module wavy_profile() { + n = ridge_count * 16; + polygon([for (i = [0 : n - 1]) + let (a = 360 * i / n, + r = Rb - ridge_depth / 2 + (ridge_depth / 2) * cos(ridge_count * a)) + [r * cos(a), r * sin(a)]]); +} + +module spiral_body() { + linear_extrude(height = pot_height, twist = twist, scale = s_top, + slices = max(ceil(pot_height / 2), ceil(abs(twist) / 2)), + convexity = 10) + wavy_profile(); +} + +// Chamfers the bottom edge only; follows the crest line elsewhere. +module envelope() { + rotate_extrude() + polygon([[0, 0], + [env(bottom_chamfer) - bottom_chamfer_run + 0.05, 0], + [env(bottom_chamfer) + 0.05, bottom_chamfer], + [Rt + 0.05, pot_height], + [0, pot_height]]); +} + +module outer_cavity() { + rotate_extrude() + polygon([[0, base_thickness], + [cav(base_thickness), base_thickness], + [cav(z_seat), z_seat], + [r_seat_top + k, pot_height + 1], // conical seat, continued past the rim + [0, pot_height + 1]]); +} + +module outer_part() { + difference() { + intersection() { + spiral_body(); + envelope(); + } + outer_cavity(); + } +} + +// --------------------------------------------------------------------- +// INNER pot +// --------------------------------------------------------------------- +// The outside widens along the seat cone from the wall (z1) up to the rim, then rises as a vertical collar. Nothing hangs out horizontally. +module inner_profile() { + polygon([[0, zb], + [rb(zb + 1) - inner_chamfer, zb], // bottom chamfer, under 45 degrees + [rb(zb + 1), zb + 1], + [rb(z1), z1], + [r_seat_top, pot_height], // rests on the outer pot's seat + [r_seat_top, pot_height + collar_height], + [0, pot_height + collar_height]]); +} + +module inner_cavity() { + z0 = zb + base_thickness; + rotate_extrude() + polygon([[0, z0], + [rb(z0) - wall, z0], + [rb(pot_height) - wall, pot_height], + [rb(pot_height) - wall, pot_height + collar_height + 1], + [0, pot_height + collar_height + 1]]); +} + +module drain_holes() { + fr = rb(zb) - wall - hole_margin; + rings = floor((fr - hole_diameter / 2) / hole_spacing); + translate([0, 0, zb - 1]) + linear_extrude(height = base_thickness + 2) + union() { + circle(d = hole_diameter, $fn = 24); + if (rings >= 1) + for (k = [1 : rings]) { + r = k * hole_spacing; + n = floor(2 * PI * r / hole_spacing); + for (i = [0 : n - 1]) + rotate(i * 360 / n + (k % 2) * 180 / n) + translate([r, 0]) + circle(d = hole_diameter, $fn = 24); + } + } +} + +module inner_part() { + difference() { + rotate_extrude() inner_profile(); + inner_cavity(); + drain_holes(); + } +} + +// --------------------------------------------------------------------- +// Output +// --------------------------------------------------------------------- +module assembled() { + color("sienna") outer_part(); + color("seagreen") inner_part(); +} + +if (part == "outer") + outer_part(); +else if (part == "inner") + translate([0, 0, -zb]) inner_part(); // sits on the bed +else if (part == "assembly") + assembled(); +else if (part == "section") + difference() { + assembled(); + translate([-400, -400, -10]) cube([800, 400, pot_height + 100]); + } +else { // "print" + outer_part(); + translate([pot_diameter + 10, 0, -zb]) inner_part(); +} diff --git a/spiral_pot.py b/spiral_pot.py new file mode 100755 index 0000000..788cd2a --- /dev/null +++ b/spiral_pot.py @@ -0,0 +1,101 @@ +#!/bin/python3 +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +from utils import ask_float, check_openscad + + +BASE_DIR = Path(__file__).resolve().parent +SCAD_DIR = BASE_DIR / "flower_pot" +OUT_DIR = BASE_DIR / "stl" + + +def render_scad( + scad_file: Path, + out_file: Path, + pot_height: float, + pot_diameter: float, + reservoir_depth: float, +) -> None: + cmd = [ + "openscad", + "-D", + f"pot_height={pot_height}", + "-D", + f"pot_diameter={pot_diameter}", + "-D", + f"reservoir_depth={reservoir_depth}", + "-D", + 'part="print"', + "-o", + str(out_file), + str(scad_file), + ] + + print("\nRendering:") + print(" ".join(f'"{arg}"' if " " in arg else arg for arg in cmd)) + + result = subprocess.run( + cmd, + cwd=BASE_DIR, + text=True, + capture_output=True, + ) + + if result.returncode != 0: + print(f"\nFailed to render {scad_file.name}") + + if result.stdout: + print(result.stdout) + + if result.stderr: + print(result.stderr, file=sys.stderr) + + raise SystemExit(result.returncode) + + print(f"Generated {out_file}") + + +def main() -> None: + check_openscad() + + scad_file = SCAD_DIR / "spiral_pot.scad" + + if not scad_file.exists(): + raise SystemExit(f"Missing SCAD file: {scad_file}") + + print("Self-watering spiral flower pot generator") + print("The model has built-in geometry constraints, so values that are too small may cause OpenSCAD assertions to fail.\n") + + pot_height = ask_float("Pot height (mm): ") + pot_diameter = ask_float("Pot diameter (mm): ") + reservoir_depth = ask_float("Reservoir depth (mm): ") + + OUT_DIR.mkdir(parents=True, exist_ok=True) + + # Keep the selected dimensions in the filename. + diameter_str = f"{pot_diameter:g}" + height_str = f"{pot_height:g}" + reservoir_str = f"{reservoir_depth:g}" + + out_file = ( + OUT_DIR + / f"spiral_pot_{diameter_str}x{height_str}mm_{reservoir_str}mm_reservoir.stl" + ) + + render_scad( + scad_file, + out_file, + pot_height, + pot_diameter, + reservoir_depth, + ) + + print("\nDone.") + + +if __name__ == "__main__": + main()