From 67f0d26978c824ebbee60d13784e5e876189c9db Mon Sep 17 00:00:00 2001 From: Sam Hadow Date: Tue, 22 Sep 2026 17:37:48 +0200 Subject: [PATCH] new design: parameterized box with clip in lid --- README.md | 19 +++- box/parametric_box.scad | 201 ++++++++++++++++++++++++++++++++++++++++ parametric_box.py | 80 ++++++++++++++++ 3 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 box/parametric_box.scad create mode 100644 parametric_box.py diff --git a/README.md b/README.md index 152632d..074397a 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ -# plants_scad +# scripts_scad -OpenSCAD scripts for plant related 3D designs. +OpenSCAD scripts for 3D designs. -## flower_pot +## plant related + +### flower_pot Parameterized round flower pot and tray. To generate the pot and tray STL files: @@ -10,10 +12,19 @@ To generate the pot and tray STL files: python flower_pot.py ``` -## seedlings_tray +### seedlings_tray Parameterized seedlings tray with pushable plates. To generate the tray and plates STL files: ```bash python seedling_tray.py ``` + +## other + +### parametric_box +Parameterized rectangular box with a clip in lid. +To generate the box and lid STL file: +```bash +python parametric_box.py +``` diff --git a/box/parametric_box.scad b/box/parametric_box.scad new file mode 100644 index 0000000..dd2fce2 --- /dev/null +++ b/box/parametric_box.scad @@ -0,0 +1,201 @@ +// ===================================================================== +// Parametric box with clip-in lid +// ===================================================================== +// PRINT NOTES +// * Box: print open side UP. Lid: print flat (either face down). +// * No supports needed - every overhang is <= 45 degrees. +// * The snap beams flex in the XY plane, i.e. along the layer lines, +// so they are strong (no bending across weak layer bonds). +// * Suggested: 0.4 mm nozzle, 0.2 mm layers, 3+ perimeters, PLA/PETG. +// Walls and beams are multiples of 0.4 mm -> full-width extrusions. +// * Lid too tight? raise `clearance` (0.1 steps). +// * Lid rattles? raise `snap_preload` (up to ~= clearance). +// * Lid pops off too easily? raise `snap_depth` (0.1 steps) or `flex_t`. +// ===================================================================== + +/* [Box size - outer dimensions in mm] */ +length = 50; // X +width = 50; // Y +height = 30; // Z + +/* [Walls] */ +wall = 2.4; // side wall thickness +floor_t = 1.6; // floor thickness +corner_r = 3; // rounded vertical edges (0 = sharp) +foot_chamfer = 0.4; // bottom edge chamfer, compensates "elephant foot" (0 = off) + +/* [Lid] */ +lid_t = 3; // lid thickness +ledge = 0.8; // width of the shelf the lid rests on +clearance = 0.2; // gap between lid and box + +/* [Snap fit] */ +snap_depth = 0.5; // how far the ridge sticks out +snap_flat = 0.6; // flat tip of the ridge +snap_preload = 0.15; // 0 = loose, for snug fit +clip_count = 1; // clips per long edge +clip_len = 30; // length of each flexible beam +flex_t = 1.6; // beam thickness (thinner = softer clip) +slot_w = 1.2; // slot width = room for the beam to flex + +/* [Options] */ +pry_notch = true; // small notch to get a fingernail under the lid +notch_r = 5; +part = "print"; // [print, box, lid, assembly] +cutaway = false; + + +$fn = 48; + +// --------------------------------------------------------------------- +// Derived values +// --------------------------------------------------------------------- +R = corner_r; +rim_wall = wall - ledge; // wall thickness in the lid rebate +rr = max(R - rim_wall, 0); // rebate corner radius +rl = length - 2*rim_wall; // rebate size +rw = width - 2*rim_wall; +lid_l = rl - 2*clearance; // lid size +lid_w = rw - 2*clearance; +lid_r = max(rr - clearance, 0); + +// The clips go on the two long edges of the lid. +long_x = length >= width; +edge_l = long_x ? lid_l : lid_w; // length of the clip edges +edge_w = long_x ? lid_w : lid_l; // lid size across the clip edges + +hb = snap_depth + snap_flat/2; // half-height of ridge at its base +avail = edge_l - 2*lid_r - 4; // straight edge usable for clips +seg = avail / clip_count; +clip_l = min(clip_len, seg - 4); +ridge_l = clip_l - slot_w; +clip_x = [for (i = [0 : clip_count-1]) -avail/2 + seg*(i + 0.5)]; + +assert(corner_r >= 0 && corner_r <= min(length, width)/2 - 0.1, + "corner_r is too large for this box"); +assert(height >= floor_t + lid_t + 8, + "height is too small for floor + lid"); +assert(rim_wall - snap_depth >= 0.8, + "Rim wall too thin at the groove: reduce ledge/snap_depth or increase wall"); +assert(lid_t/2 - snap_preload - hb >= 0.2, + "Lid too thin for the snap ridge: increase lid_t or reduce snap_depth/snap_flat"); +assert(edge_w/2 - flex_t - slot_w >= 3, + "Box too narrow for the flex slots: reduce flex_t/slot_w"); +assert(clip_count >= 1 && clip_l >= 8, + "Box too short for this many clips: reduce clip_count"); + +// --------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------- +module rrect(l, w, r) { // centred rounded rectangle (2D) + if (r > 0.01) offset(r = r) square([l - 2*r, w - 2*r], center = true); + else square([l, w], center = true); +} + +// --------------------------------------------------------------------- +// BOX +// --------------------------------------------------------------------- +module outer_shell() { + if (foot_chamfer > 0) + hull() { + linear_extrude(0.01) + rrect(length - 2*foot_chamfer, width - 2*foot_chamfer, + max(R - foot_chamfer, 0)); + translate([0, 0, foot_chamfer]) + linear_extrude(height - foot_chamfer) rrect(length, width, R); + } + else + linear_extrude(height) rrect(length, width, R); +} + +// Snap groove running around the rebate wall. +// Trapezoid profile with 45 degree flanks -> prints without supports. +module snap_groove() { + zc = height - lid_t/2 - snap_preload; + ht = snap_flat/2; + hull() { + translate([0, 0, zc - ht]) linear_extrude(2*ht) + rrect(rl + 2*snap_depth, rw + 2*snap_depth, rr + snap_depth); + translate([0, 0, zc - hb]) linear_extrude(2*hb) + rrect(rl, rw, rr); + } +} + +module pry_notch() { + p = long_x ? [length/2, 0] : [0, width/2]; // on a short wall + translate([p[0], p[1], height - lid_t - 1]) + cylinder(r = notch_r, h = lid_t + 2); +} + +module box() { + difference() { + outer_shell(); + // main cavity + translate([0, 0, floor_t]) linear_extrude(height) + rrect(length - 2*wall, width - 2*wall, max(R - wall, 0)); + // rebate for the lid (leaves a ledge of width `ledge`) + translate([0, 0, height - lid_t]) linear_extrude(lid_t + 1) + rrect(rl, rw, rr); + snap_groove(); + if (pry_notch) pry_notch(); + } +} + +// --------------------------------------------------------------------- +// LID +// --------------------------------------------------------------------- +// Ridge along +Y edge, centred on x = 0, z = 0. 45 degree flanks. +module ridge(len) { + e = 0.05; + ht = snap_flat/2; + rotate([90, 0, 90]) translate([0, 0, -len/2]) linear_extrude(len) + polygon([[-e, -hb - e], [snap_depth, -ht], + [snap_depth, ht], [-e, hb + e]]); +} + +// Rounded slot +module slot(len) { + hull() for (x = [-1, 1]) + translate([x*(len - slot_w)/2, 0, 0]) cylinder(d = slot_w, h = lid_t + 2); +} + +module lid_core(L, W) { + difference() { + union() { + linear_extrude(lid_t) rrect(L, W, lid_r); + for (s = [-1, 1]) scale([1, s, 1]) + for (cx = clip_x) + translate([cx, W/2, lid_t/2]) ridge(ridge_l); + } + for (s = [-1, 1]) scale([1, s, 1]) + for (cx = clip_x) + translate([cx, W/2 - flex_t - slot_w/2, -1]) slot(clip_l); + } +} + +module lid() { + rotate([0, 0, long_x ? 0 : 90]) lid_core(edge_l, edge_w); +} + +// --------------------------------------------------------------------- +// OUTPUT +// --------------------------------------------------------------------- +module assembly() { + big = 10 * max(length, width, height); + difference() { + union() { + box(); + color("orange") translate([0, 0, height - lid_t]) lid(); + } + if (cutaway) translate([-big/2, 0, -1]) cube([big, big, big]); + } +} + +if (part == "box") box(); +else if (part == "lid") lid(); +else if (part == "assembly") assembly(); +else { // "print": both parts side by side + lid_ext_y = long_x ? lid_w : lid_l; + box(); + translate([0, (width + lid_ext_y)/2 + 10, 0]) lid(); +} diff --git a/parametric_box.py b/parametric_box.py new file mode 100644 index 0000000..dae5a01 --- /dev/null +++ b/parametric_box.py @@ -0,0 +1,80 @@ +#!/bin/python3 +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +from utils import ask_float + + +BASE_DIR = Path(__file__).resolve().parent +SCAD_DIR = BASE_DIR / "box" +OUT_DIR = BASE_DIR / "stl" + + +def render_scad( + scad_file: Path, + out_file: Path, + length: float, + width: float, + height: float, +) -> None: + cmd = ( + f'openscad ' + f'-D length={length} ' + f'-D width={width} ' + f'-D height={height} ' + f'-D part="print" ' + f'-o "{out_file}" ' + f'"{scad_file}"' + ) + + result = subprocess.run( + ["bash", "-lc", 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 not SCAD_DIR.exists(): + raise SystemExit(f"Missing SCAD directory: {SCAD_DIR}") + + print("Dimensions less than 20mm may result in unusable STL files.\n") + + length = ask_float("Box depth / length (X) in mm: ") + width = ask_float("Box width (Y) in mm: ") + height = ask_float("Box height (Z) in mm: ") + + OUT_DIR.mkdir(parents=True, exist_ok=True) + + box_scad = SCAD_DIR / "parametric_box.scad" + + if not box_scad.exists(): + raise SystemExit(f"Missing file: {box_scad}") + + render_scad( + box_scad, + OUT_DIR / "box.stl", + length, + width, + height, + ) + + print("\nDone.") + + +if __name__ == "__main__": + main()