From bcb20a9a5dd12e02e0fe09b7fe7a499d7f2b1102 Mon Sep 17 00:00:00 2001 From: Sam Hadow Date: Wed, 7 Oct 2026 16:33:26 +0200 Subject: [PATCH] weave planter --- README.md | 8 + flower_pot/weave_planter.scad | 277 ++++++++++++++++++++++++++++++++++ weave_planter.py | 96 ++++++++++++ 3 files changed, 381 insertions(+) create mode 100644 flower_pot/weave_planter.scad create mode 100755 weave_planter.py diff --git a/README.md b/README.md index 3e80129..a9d3d70 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,14 @@ To generate the pot and tray STL files: python flower_pot.py ``` +### weave_planter + +Parameterized round weave planter with a pot and a tray. +To generate the pot and tray STL files: +```bash +python weave_planter.py +``` + ### seedlings_tray Parameterized seedlings tray with pushable plates. diff --git a/flower_pot/weave_planter.scad b/flower_pot/weave_planter.scad new file mode 100644 index 0000000..69bb5ea --- /dev/null +++ b/flower_pot/weave_planter.scad @@ -0,0 +1,277 @@ +// ============================================================================= +// Woven planter + drip tray with bayonet (twist) lock +// Parametric. +// Reverse-engineered from "Planter - 100mm" and "Drip Tray - 100mm" STLs (https://www.printables.com/model/1507840-weave-planter). +// +// Outside: diagonal basket weave (two strip directions crossing over/under, +// 45 deg chamfered strip edges, dark diamond gaps between strips). +// Lock: 4 lugs on the tray rim pass between 4 stops on the underside of +// the planter. Push the planter down onto the tray with the lugs in +// the gaps, then twist ~45 deg so every lug sits over a stop. +// +// Both parts print as modelled (flat side down, no supports): +// - weave strips have 45 deg chamfers, ends of the weave taper 45 deg +// - lugs and stops have 45 deg flanks +// - the only bridge is the 5 mm slot ceiling inside the planter floor +// ============================================================================= + +/* [Render] */ +part = "both"; // [both, planter, tray, assembled] + +/* [Main dimensions] */ +outer_d = 93.6; // max outer diameter, mm (rim bead / tray foot) +planter_h = 79.92; // planter height, mm +tray_h = 26.4; // tray height incl. lock rim, mm + +/* [Walls & floor] */ +wall_t = 3.2; // planter wall thickness at the raised weave +floor_center = 2.45; // planter floor thickness at the centre +floor_edge = 8.0; // planter floor height at the wall (cone slopes ~7 deg) +tray_floor_center = 2.4; // tray floor thickness at the centre +tray_floor_edge = 8.0; // tray floor height at the rim (water runs to the centre) + +/* [Drainage] */ +drain_d = 4.0; // drain hole diameter (keep >= 3.5 mm for PETG) +hole_pitch = 15; // radial spacing between hole rings +holes_per_ring = 8; // holes on every ring (+1 in the centre) +hole_margin = 4; // keep holes this far from the plug edge + +/* [Weave] */ +texture = true; // weave on/off (off = smooth walls) +weave_pitch = 10.2; // strip pitch (centre to centre), mm; snapped so it fits the circumference +relief = 0.9; // weave depth, mm +weave_zphase = 1.4; // vertical start phase of the weave, mm +weave_rot = 8.5; // rotate the weave pattern around the part, degrees + +/* [Bayonet lock] */ +lug_count = 4; // number of lugs on the tray / stops on the planter +lug_w = 24; // angular width of each lug and each stop, degrees +lug_depth = 2.2; // how far the lugs reach inwards, mm +lug_z0 = 1.8; // height of the lug start above the tray shoulder, mm +rim_t = 2.2; // tray rim wall thickness +rim_h = 6.38; // tray rim height above the shoulder +fit_c = 0.25; // radial clearance rim <-> planter wall (raise to 0.3 if tight) +lug_c = 0.3; // radial clearance lug tip <-> planter plug +lock_c = 0.3; // vertical clearance lug <-> stop (smaller = tighter lock) +lug_angle = 0; // tray lug rotation in the assembled view (0 = locked, 45 = unlocked) + +// ----------------------------------------------------------------- derived --- +bead_r = 1.2; +bead_h = 2.4; +neck_h = 1.2; + +R = outer_d / 2; // bead radius +R_t = R - 0.4; // top of the weave strips +Rc = R_t - relief; // base (gap) level of the weave +R_in = R_t - wall_t; // planter inner wall radius + +wall_r = texture ? Rc - 0.3 : R_t; // core wall radius (weave skin is added on top) +neck_r = Rc - 0.1; // neck under the bead / above the foot + +C = 2 * PI * R_t; +N = max(6, round(C / (sqrt(2) * weave_pitch))); // diamonds per row around the part +cellx = C / N; // horizontal distance between neighbouring diamonds +fn_core = 10 * N; // facets of the round parts (rim, bead, lock) + +// bayonet geometry +flank_max = (rim_h - lug_z0 - 0.15) / 2; +lug_d = min(lug_depth, flank_max); // lug reach, 45 deg flanks +lug_tf = rim_h - lug_z0 - 2 * lug_d; // flat tip height +R_rim_o = R_in - fit_c; // tray rim outer radius +R_rim_i = R_rim_o - rim_t; // tray rim inner radius +tip_r = R_rim_i - lug_d; // lug tip radius +plug_r = tip_r - lug_c; // planter floor plug radius +blk_r = R_rim_i - 0.25; // stop outer radius +blk_z = lug_z0 + (R_rim_i - blk_r) - lock_c; // stop vertical land before the 45 deg top +pocket_h = rim_h + 0.3; // slot ceiling inside the planter +z_s = tray_h - rim_h; // tray shoulder height +fe_p = max(floor_edge, pocket_h + 1.2); +planter_skin_z1 = planter_h - bead_h - neck_h; +tray_skin_z0 = bead_h + neck_h; + +assert(lug_tf >= 0.1, "rim_h too small for the lug: raise rim_h or lower lug_z0"); +assert(blk_z + (blk_r - plug_r) < pocket_h - 0.5, "slot too shallow for the stops: raise rim_h"); +assert(z_s > tray_floor_edge + 6, "tray_h too small: raise tray_h or lower tray_floor_edge"); +assert(planter_h > 25, "planter_h too small"); +assert(plug_r > 15, "outer_d too small for the lock geometry"); + +// ------------------------------------------------------------------- weave --- +// Plain diagonal weave: two strip families run at +/-45 deg and pass over / under each other in a checker. (u, v) are lattice coordinates, one unit = one strip pitch; u runs up-right, v runs down-right along the wall. +strip_half = 0.30; // half width of a strip, in pitches +strip_edge = 0.05; // width of the chamfered strip edge, in pitches +ramp_floor = 0.25; // height an under-going strip ramps down to (fraction of relief) +weave_detail = 4; // mesh facets across each strip top: 1 coarse ... 4 very smooth (3 renders faster) + +function tri(z) = abs(z - 2 * floor(z / 2) - 1); +function Tb(f) = max(0, min(1, (strip_half - abs(f - 0.5)) / strip_edge)); // across a strip +function Wv(q) = ramp_floor + (1 - ramp_floor) * + max(0, min(1, (tri(q - 0.5) - strip_half) / (1 - 2 * strip_half))); // along a strip +function weave_h(u, v) = // surface height 0..1 + let (I = floor(u), J = floor(v), + a = min(Tb(u - I), Wv(v + I)), + b = min(Tb(v - J), Wv(u + J + 1))) + max(a, b); + +// The surface mesh is built on the lattice lines where the weave changes slope (strip edges and ramp ends). Every edge of every strip is therefore a straight mesh edge, and the flat tops, ramps and gaps need only a handful of facets. Extra lines across the flat strip tops and the gaps keep them round (one big facet would chord across the curved wall) and give smoother shading. weave_detail sets how many facets span each strip top: 1 = coarse, 3 = smooth, 4 = very smooth (default). +function gap_lines(k0, k1) = [for (k = [k0 : k1]) 0.5 + strip_half + (1 - 2 * strip_half) * k / weave_detail]; +low_g = [for (f = gap_lines(1, weave_detail - 1)) if (f >= 1 - 1e-9) f - 1]; // gap, next cell +high_g = [for (f = gap_lines(1, weave_detail - 1)) if (f < 1 - 1e-9) f]; // gap, this cell +plat = [for (k = [0 : weave_detail]) + 0.5 - strip_half + strip_edge + (2 * strip_half - 2 * strip_edge) * k / weave_detail]; +Fl = concat(low_g, [0.5 - strip_half], plat, [0.5 + strip_half], high_g); // mesh lines per pitch +nl = len(Fl); +function mline(a) = floor(a / nl) + Fl[((a % nl) + nl) % nl]; // lattice coordinate of mesh line a +function g_y(a, dl) = (mline(a) - mline(a + dl)) * cellx / 2 + weave_zphase; // height on the wall +function g_h(a, dl) = weave_h(mline(a), mline(a + dl)); +// P = [lines around, rows, first row, last row, y min, y max] +function g_vi(a, dl, P) = (((a % P[0]) + P[0]) % P[0]) * P[1] + (dl - P[2]); +function g_in(a, dl, P) = dl >= P[2] && dl <= P[3] && g_y(a, dl) >= P[4] && g_y(a, dl) <= P[5]; +function g_q(a, dl, P) = g_in(a, dl, P) && g_in(a + 1, dl - 1, P) && g_in(a + 1, dl, P) && g_in(a, dl + 1, P); +function g_pt(a, dl, z0, inner) = + let (u = mline(a), v = mline(a + dl), th = 180 * (u + v) / N, + r = inner ? Rc - 0.8 : Rc + relief * weave_h(u, v)) + [r * cos(th), r * sin(th), z0 + (u - v) * cellx / 2 + weave_zphase]; +function g_faces(a, dl, P, off) = + let (p00 = g_vi(a, dl, P), p10 = g_vi(a + 1, dl - 1, P), + p11 = g_vi(a + 1, dl, P), p01 = g_vi(a, dl + 1, P), + i00 = p00 + off, i10 = p10 + off, i11 = p11 + off, i01 = p01 + off, + d1 = abs(g_h(a, dl) - g_h(a + 1, dl)), + d2 = abs(g_h(a + 1, dl - 1) - g_h(a, dl + 1))) + concat( + // outer surface: split the quad along the diagonal that bends the least + d1 <= d2 ? [[p00, p10, p11], [p00, p11, p01]] : [[p00, p10, p01], [p10, p11, p01]], + // inner wall (hidden inside the core) + [[i00, i01, i11], [i00, i11, i10]], + // side walls where the patch ends (these lie outside the part and get trimmed away) + g_q(a, dl - 1, P) ? [] : [[p10, p00, i00], [p10, i00, i10]], + g_q(a + 1, dl - 1, P) ? [] : [[p11, p10, i10], [p11, i10, i11]], + g_q(a, dl + 1, P) ? [] : [[p01, p11, i11], [p01, i11, i01]], + g_q(a - 1, dl + 1, P) ? [] : [[p00, p01, i01], [p00, i01, i00]]); + +// Ring-shaped skin whose outer surface is the weave between z0 and z1, with the +// 45 deg run-out at both ends (trimmed by the envelope below). +module weave_skin(z0, z1) { + L = z1 - z0; + M = nl * N; + ylo = -0.6 * cellx; + yhi = L + 0.6 * cellx; + dlo = floor((weave_zphase - yhi) * 2 * nl / cellx) - 3; + dhi = ceil((weave_zphase - ylo) * 2 * nl / cellx) + 3; + Dn = dhi - dlo + 1; + P = [M, Dn, dlo, dhi, ylo, yhi]; + off = M * Dn; + pts = concat([for (a = [0 : M - 1], dl = [dlo : dhi]) g_pt(a, dl, z0, false)], + [for (a = [0 : M - 1], dl = [dlo : dhi]) g_pt(a, dl, z0, true)]); + fcs = [for (a = [0 : M - 1], dl = [dlo : dhi]) if (g_q(a, dl, P)) each g_faces(a, dl, P, off)]; + Rbig = R_t + 0.6; + k = Rbig - Rc; // 45 deg run-out + assert(L > 2 * k + 1, "weave zone too short"); + intersection() { + rotate([0, 0, weave_rot]) polyhedron(points = pts, faces = fcs, convexity = 10); + translate([0, 0, z0]) rotate_extrude($fn = fn_core) + polygon([[Rc - 1, 0], [Rc, 0], [Rbig, k], [Rbig, L - k], [Rc, L], [Rc - 1, L]]); + } +} + +// ----------------------------------------------------------------- planter --- +module planter_profile() { + difference() { + union() { + polygon([[0, 0], [wall_r, 0], [wall_r, planter_skin_z1 - 0.2], + [neck_r, planter_skin_z1 - 0.2], [neck_r, planter_h], + [R_in, planter_h], [R_in, fe_p], [0, floor_center]]); + // rim bead: round on top, 45 deg underneath (printable) + cx = R - bead_r; + cz = planter_h - bead_r; + tx = cx + bead_r * cos(45); + tz = cz - bead_r * sin(45); + zP = tz - (tx - neck_r); + hull() { + translate([cx, cz]) circle(r = bead_r, $fn = 48); + polygon([[R_in, planter_h], [R_in, zP], [neck_r, zP]]); + } + } + // annular slot on the underside that receives the tray rim + polygon([[plug_r, -1], [R_in, -1], [R_in, pocket_h], [plug_r, pocket_h]]); + } +} + +// stops on the plug: the tray lugs sit on top of these once twisted +module lock_stops() { + zt = blk_z + (blk_r - plug_r); + for (k = [0 : lug_count - 1]) + rotate([0, 0, k * 360 / lug_count - lug_w / 2]) + rotate_extrude(angle = lug_w, $fn = fn_core) + polygon([[plug_r - 0.6, 0], [blk_r, 0], [blk_r, blk_z], + [plug_r, zt], [plug_r - 0.6, zt]]); +} + +module drain_holes() { + n_rings = floor((plug_r - hole_margin - drain_d / 2) / hole_pitch); + translate([0, 0, -1]) cylinder(d = drain_d, h = fe_p + 2, $fn = 32); + for (i = [1 : n_rings], k = [0 : holes_per_ring - 1]) + rotate([0, 0, k * 360 / holes_per_ring]) + translate([i * hole_pitch, 0, -1]) + cylinder(d = drain_d, h = fe_p + 2, $fn = 32); +} + +module planter() { + union() { + difference() { + union() { + rotate_extrude($fn = fn_core) planter_profile(); + lock_stops(); + } + drain_holes(); + } + if (texture) weave_skin(0, planter_skin_z1); + } +} + +// -------------------------------------------------------------------- tray --- +module tray_profile() { + union() { + polygon([[0, 0], [neck_r, 0], [neck_r, tray_skin_z0 + 0.2], + [wall_r, tray_skin_z0 + 0.2], [wall_r, z_s], + [R_rim_o, z_s], [R_rim_o, z_s + rim_h], [R_rim_i, z_s + rim_h], + [R_rim_i, tray_floor_edge], [0, tray_floor_center]]); + // rounded foot bead + r = 0.7; + hull() { + translate([R - r, r]) circle(r = r, $fn = 32); + translate([R - r, bead_h - r]) circle(r = r, $fn = 32); + square([R - r, bead_h]); + } + } +} + +// bayonet lugs on the inside of the rim: 45 deg flanks, flat tip +module lock_lugs() { + z_a = z_s + lug_z0; + for (k = [0 : lug_count - 1]) + rotate([0, 0, k * 360 / lug_count + lug_angle - lug_w / 2]) + rotate_extrude(angle = lug_w, $fn = fn_core) + polygon([[R_rim_i + 0.5, z_a], [R_rim_i, z_a], + [tip_r, z_a + lug_d], [tip_r, z_a + lug_d + lug_tf], + [R_rim_i, z_a + 2 * lug_d + lug_tf], + [R_rim_i + 0.5, z_a + 2 * lug_d + lug_tf]]); +} + +module tray() { + union() { + rotate_extrude($fn = fn_core) tray_profile(); + lock_lugs(); + if (texture) weave_skin(tray_skin_z0, z_s); + } +} + +// ------------------------------------------------------------------ output --- +if (part == "planter") planter(); +else if (part == "tray") tray(); +else if (part == "assembled") { + tray(); + translate([0, 0, z_s]) planter(); +} else { + planter(); + translate([outer_d + 10, 0, 0]) tray(); +} diff --git a/weave_planter.py b/weave_planter.py new file mode 100755 index 0000000..49f3ec3 --- /dev/null +++ b/weave_planter.py @@ -0,0 +1,96 @@ +#!/bin/python3 +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +from utils import ask_float + + +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, + outer_d: float, + planter_h: float, +) -> None: + cmd = [ + "openscad", + "-D", + f"outer_d={outer_d}", + "-D", + f"planter_h={planter_h}", + "-D", + 'part="both"', + "-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: + if shutil.which("openscad") is None: + raise SystemExit( + "OpenSCAD was not found in PATH. " + "Install OpenSCAD or make sure the 'openscad' command is available." + ) + + scad_file = SCAD_DIR / "weave_planter.scad" + + if not scad_file.exists(): + raise SystemExit(f"Missing SCAD file: {scad_file}") + + print("Weave planter generator") + print("The SCAD model has additional geometric constraints, so very small dimensions may fail OpenSCAD's assertions.\n") + + outer_d = ask_float("Outer diameter (mm): ") + planter_h = ask_float("Planter height (mm): ") + + OUT_DIR.mkdir(parents=True, exist_ok=True) + + diameter_str = f"{outer_d:g}" + height_str = f"{planter_h:g}" + + out_file = OUT_DIR / f"weave_planter_{diameter_str}x{height_str}mm.stl" + + render_scad( + scad_file, + out_file, + outer_d, + planter_h, + ) + + print("\nDone.") + + +if __name__ == "__main__": + main()