Modular Snap Together Bins
FleetingLooking for free, open-source modular 3D printable bins that snap together side-by-side, like MultiBin from MultiBuild but free. Gridfinity requires a baseplate which is not suitable here.
Inspired by Jujumo’s interlocking and stackable containers V3, we designed a parametric system in build123d with Claude.
The parameters are calibrated to fit well with a 0.8mm nozzle on a bambulab a1 mini.
Vase mode printing
The bins are designed for vase mode (aka spiral/contour mode). In vase mode, the slicer prints a single continuous spiral — no retractions, no seams. The model must therefore be a solid block: the slicer itself creates the thin wall by tracing the outer contour. This means we extrude a solid shape and let the slicer hollow it out, rather than modeling walls explicitly.
This constrains the geometry: everything must be a single continuous shell with no internal features, overhangs that would need support, or disconnected volumes.
Snap-fit interlocking
The bins interlock by sliding together vertically. Primary walls (front, left) carry bumps at grid-unit centers (0.5) and boundaries (0.0, 1.0, …), while secondary walls (back, right) carry 2 bumps per grid unit (at 0.25 and 0.75). When two bins are placed side by side, each primary bump slides between a pair on the facing wall.
Once interleaved, the bumps physically prevent horizontal separation: each primary bump sits between a pair of secondary bumps, so the bins cannot be pulled apart without the solids passing through each other. The bins can only be assembled or disassembled by sliding vertically.
Interleaving constraint: the sub-cell width is GRID_UNIT / 2 (the spacing
between adjacent bumps on the secondary wall). The bump tip width fills this
gap: tip_w = sub - fw - 2 * CLEARANCE.
Vertical stacking
The bins stack vertically by nesting the bottom of one bin into the top of
another. The bottom STACK_HEIGHT mm of each bin is a nesting lip — inset by
NOZZLE_SIZE + STACK_CLEARANCE per side so it fits inside the inner cavity of
the bin below. In vase mode the wall thickness equals the nozzle diameter
(0.8 mm), so the inner cavity is outer - 2 * NOZZLE_SIZE per axis. The lip is
inset by NOZZLE_SIZE + STACK_CLEARANCE per side, leaving STACK_CLEARANCE
(0.15 mm) of play for an easy fit. The dovetail bumps start above the lip so
they don’t interfere with stacking.
Inner volume trade-off: in vase mode the wall thickness is fixed at
NOZZLE_SIZE, so the smaller outer of the lip also shrinks the inner cavity by
NOZZLE_SIZE + STACK_CLEARANCE per side at the bottom. The lip is built as a
loft from the smaller footprint at Z=0 to the full body size at Z==STACK_HEIGHT=,
giving a smooth taper that slices cleanly in vase mode (no abrupt step).
Parameters
The design is fully parametric. The sub-cell width sub = GRID_UNIT / 2
defines the bump spacing. Finger ratio controls the base width of each finger.
The tip width is derived as tip_w = sub - fw - 2 * CLEARANCE so the
dovetail exactly fills the gap between adjacent bumps on the facing wall, with
CLEARANCE per side.
GRID_UNIT = 10.0 # mm per grid unit
FINGER_RATIO = 0.3 # fraction of sub-cell used for finger base width
FINGER_DEPTH = 5.0 # mm, how far bumps protrude
CLEARANCE = 0.12 # mm, per-side clearance between interleaving bumps
NOZZLE_SIZE = 0.8 # mm, nozzle diameter (wall thickness in vase mode)
STACK_HEIGHT = 3.0 # mm, height of the nesting lip at the bottom
STACK_CLEARANCE = 0.15 # mm, extra clearance for stacking fit
Grid-aligned fingers
Secondary walls get 2 fingers per GRID_UNIT (at sub-cell positions 0.25 and
0.75). Primary walls get 1 finger at the center (0.5) plus 1 at each grid-unit
boundary (0.0) — the boundary bumps fill the gap between secondary pairs of
adjacent grid units in multi-unit bins; bumps at the wall edges are omitted by
the corner constraint. Each finger width is sub * FINGER_RATIO, leaving flat
margins on each side. This grid alignment guarantees that bins of any size
snap together: a 1×1 bin’s fingers align with the corresponding region of a 3×2
bin’s wall, because every finger sits at the same absolute grid position.
Corner constraint: bumps on one edge must never protrude past the adjacent
wall surface. At each corner, two perpendicular walls meet and both carry bumps
that protrude outward. A bump whose tip-width footprint extends past the
neighboring wall surface would collide with that wall’s bumps. Therefore, for
front/back wall bumps at position x, we require x - tip_w/2 > -ow/2= and
x + tip_w/2 < ow/2=; likewise for left/right wall bumps along y. Any bump
violating this is simply omitted.
Dovetail profile
The bump is a trapezoidal dovetail profile. The base sits at the wall (width
finger_width), overlapping 0.1 mm into the box for a clean boolean union; the tip
protrudes outward and is wider (tip_w = sub - fw - 2 * CLEARANCE). The taper
fills the gap between adjacent bumps on the facing wall with uniform clearance
at every depth.
To avoid the elephant foot effect (first layer squished wider by bed adhesion), the bumps do not touch the build plate. Each bump has a 45° ramp at the bottom: at Z=0 the profile is flush with the wall (no protrusion), and it linearly reaches full protrusion at Z==FINGER_DEPTH=. This slope is gentle enough to print without support. The ramp is built by lofting from a thin wall-flush strip at Z=0 to the full dovetail profile at Z==FINGER_DEPTH=, then extruding the full profile from there to the top.
The profile is parameterized by direction (-x, +x, -y, +y) so the same function works for all four walls.
def _make_dovetail(base_w, tip_w, depth, height, direction):
slope_height = depth # 45° ramp: flush at Z=0, full protrusion at Z=slope_height
half_base = base_w / 2
half_tip = tip_w / 2
overlap = 0.1 # overlap into wall for clean boolean union
if direction == "-y":
full_pts = [(-half_base, overlap), (-half_tip, -depth), (half_tip, -depth), (half_base, overlap)]
base_pts = [(-half_base, overlap), (-half_base, 0), (half_base, 0), (half_base, overlap)]
elif direction == "+y":
full_pts = [(-half_base, -overlap), (-half_tip, depth), (half_tip, depth), (half_base, -overlap)]
base_pts = [(-half_base, -overlap), (-half_base, 0), (half_base, 0), (half_base, -overlap)]
elif direction == "-x":
full_pts = [(overlap, -half_base), (-depth, -half_tip), (-depth, half_tip), (overlap, half_base)]
base_pts = [(overlap, -half_base), (0, -half_base), (0, half_base), (overlap, half_base)]
elif direction == "+x":
full_pts = [(-overlap, -half_base), (depth, -half_tip), (depth, half_tip), (-overlap, half_base)]
base_pts = [(-overlap, -half_base), (0, -half_base), (0, half_base), (-overlap, half_base)]
# Main body: full profile from slope_height to height
with BuildSketch(Plane.XY.offset(slope_height)) as full_sketch:
with BuildLine():
Polyline(*full_pts, close=True)
make_face()
main_body = extrude(full_sketch.sketch, height - slope_height)
# Ramp: loft from wall-flush base at Z=0 to full profile at Z=slope_height
with BuildPart() as ramp_part:
with BuildSketch(Plane.XY) as base_sketch:
with BuildLine():
Polyline(*base_pts, close=True)
make_face()
with BuildSketch(Plane.XY.offset(slope_height)) as top_sketch:
with BuildLine():
Polyline(*full_pts, close=True)
make_face()
loft()
return main_body + ramp_part.part
Assembling the bin
The bin starts as a solid box sized GRID_UNIT * n in each dimension (remember:
vase mode hollows it). Bumps protrude FINGER_DEPTH beyond each wall; when two
bins are placed adjacent with a gap of FINGER_DEPTH, bumps from each side
fully interleave. The center-to-center pitch is GRID_UNIT + FINGER_DEPTH.
All four walls get dovetail bumps (union only, no subtraction). Primary walls
(front -y, left -x) place bumps at positions 0.0 and 0.5 per grid unit, and
secondary walls (back +y, right +x) place 2 bumps at positions 0.25 and
0.75. Each primary bump lands in the gap between a pair on the facing wall.
def make_bin(width_units=1, depth_units=1, height=50.0):
outer_width = width_units * GRID_UNIT
outer_depth = depth_units * GRID_UNIT
subcell = GRID_UNIT / 2 # sub-cell size (2 bumps per unit on secondary walls)
finger_width = subcell * FINGER_RATIO
finger_depth = FINGER_DEPTH
tip_width = subcell - finger_width - 2 * CLEARANCE # fills gap between facing-wall bumps
stack_inset = NOZZLE_SIZE + STACK_CLEARANCE # per-side inset for nesting lip
# Bottom nesting lip: loft from smaller footprint at Z=0 to full size at Z=STACK_HEIGHT
with BuildPart() as lip_part:
with BuildSketch(Plane.XY):
Rectangle(outer_width - 2 * stack_inset, outer_depth - 2 * stack_inset)
with BuildSketch(Plane.XY.offset(STACK_HEIGHT)):
Rectangle(outer_width, outer_depth)
loft()
# Main body at full size, starting above the lip
body = Pos(0, 0, STACK_HEIGHT) * Box(outer_width, outer_depth, height - STACK_HEIGHT,
align=(Align.CENTER, Align.CENTER, Align.MIN))
bin_solid = lip_part.part + body
# Chamfer the vertical corner edges
vertical_edges = bin_solid.edges().filter_by(Axis.Z)
bin_solid = chamfer(vertical_edges, length=0.5)
primary_fracs = [0.0, 0.5] # center + unit boundary (edge ones filtered by corner constraint)
secondary_fracs = [0.25, 0.75] # 2 bumps per grid unit
def _add_bumps(fracs, units, wall_pos, direction, along_wall):
nonlocal bin_solid
half = along_wall / 2
for u in range(units):
for frac in fracs:
center = -half + GRID_UNIT * (u + frac)
if center - tip_width / 2 < -half or center + tip_width / 2 > half:
continue # corner constraint
bump = _make_dovetail(finger_width, tip_width, finger_depth, height - STACK_HEIGHT, direction)
bin_solid = bin_solid + wall_pos(center) * bump
# Front wall (primary, -y) — bumps start above the nesting lip
_add_bumps(primary_fracs, width_units, lambda x: Pos(x, -outer_depth / 2, STACK_HEIGHT), "-y", outer_width)
# Back wall (secondary, +y)
_add_bumps(secondary_fracs, width_units, lambda x: Pos(x, outer_depth / 2, STACK_HEIGHT), "+y", outer_width)
# Left wall (primary, -x)
_add_bumps(primary_fracs, depth_units, lambda y: Pos(-outer_width / 2, y, STACK_HEIGHT), "-x", outer_depth)
# Right wall (secondary, +x)
_add_bumps(secondary_fracs, depth_units, lambda y: Pos(outer_width / 2, y, STACK_HEIGHT), "+x", outer_depth)
if isinstance(bin_solid, _b123d.topology.shape_core.ShapeList):
return max(bin_solid, key=lambda s: s.volume)
return bin_solid
Generating the STL