Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
5efd0557aThis file is pinned to the selected release. Download the original to open it in your design tool.
"""
Ultra Light Gridfinity Bin generator — manifold3d port of
UltraLightGridfinityBins.scad by HuMa_Meng (CC BY-NC-SA 4.0).
This is a faithful 1:1 mapping of the SCAD source to manifold3d primitives.
The key correspondence:
SCAD → manifold3d
----------------------------------------------------------------
cube([w,d,h]) → Manifold.cube([w,d,h])
cylinder(h, r_bot, r_top) → Manifold.cylinder(h, rb, rt, n)
translate([x,y,z]) X → X.translate([x,y,z])
mirror([1,0,0]) X → X.mirror([1,0,0])
union() { A B C } → A + B + C
difference() { A B C } → A - B - C
hull() { A B C D } → Manifold.batch_hull([A,B,C,D])
Geometry is identical to OpenSCAD's, modulo cylinder discretization (set by
``CIRCULAR_SEGMENTS``). CSG runs in C++ via manifold3d ⇒ a full bin generates
in single-digit milliseconds.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
import manifold3d as m
import numpy as np
# ---------------------------------------------------------------------------
# Parameters
# ---------------------------------------------------------------------------
LabelPosition = Literal["Full", "Left", "Center", "Right"]
@dataclass
class GridfinityParams:
grids_x: float = 1.0
grids_y: float = 2.0
grids_z: float = 3.0
half_grid_right: bool = True
half_grid_top: bool = True
half_grid_base: bool = False
wall_thickness: float = 1.0
ultra_light_base: bool = True
# Thickness (mm) of the foot's bottom plate — the solid layer between the
# build plate and the ultra-light cavity. 0 = use ``wall_thickness`` (stock
# SCAD geometry). Larger values = sturdier bottom + shallower bin interior;
# the cavity above is clipped accordingly. Has no effect when
# ultra_light_base is off.
ultra_light_floor_thickness: float = 0.0
ultra_light_labels: bool = True
# Multiplier on the auto-computed rib count under ultra-light label tabs.
# 1.0 = original SCAD behaviour (one rib every 13 mm). 2.0 = twice as many.
label_support_density: float = 1.0
magnets: bool = False
magnet_diameter: float = 6.15
magnet_depth: float = 2.2
dividers: bool = False
dividers_x: int = 0
dividers_y: int = 1
labels: bool = False
label_for_each_section: bool = True
label_position: LabelPosition = "Full"
label_width: float = 30.0
label_depth: float = 13.0
scoops: bool = False
scoop_radius: float = 15.0
# Brim "mouse ears" — flat discs added at the four outer corners of the
# bin to improve first-layer bed adhesion on open / unheated printers.
# Not part of the upstream SCAD; lives entirely outside the bin's bbox
# except for a small overlap at the corner foot for fusion.
brim_ears: bool = False
brim_ear_diameter: float = 10.0
brim_ear_height: float = 0.4
# ---------------------------------------------------------------------------
# Constants from the SCAD source
# ---------------------------------------------------------------------------
OFFSET_XY = 0.25
BASIC_UNIT_XY = 42.0
BASIC_UNIT_Z = 7.0
def _unit_xy(p: "GridfinityParams") -> float:
"""Effective basic unit XY size.
SCAD: ``Basic_Unit_XY = Half_Grid_Base ? 0.5 * 42.0 : 42.0;``
With ``half_grid_base`` on, the count ``gx_/gy_`` is doubled and the unit
halved → external size of the bin stays the same, but the base footprint
is split into 4× more (smaller) feet.
"""
return 0.5 * BASIC_UNIT_XY if p.half_grid_base else BASIC_UNIT_XY
BASIC_RADIUS_1 = 4.0
BASIC_RADIUS_2 = 8.0
TOP_CLEARANCE_OFFSET = 0.6
STACKING_LIP_WIDTH = 2.6
LABEL_HEIGHT = 1.0
# Discretization to mirror OpenSCAD's $fa / $fs adaptive scheme. Each cylinder
# gets segments = max(5, min(ceil(360/FA), ceil(2π·r / FS))). These are the
# values from the SCAD source's "[Hidden] $fa = 8; $fs = 0.25;" — so our
# triangulation should match OpenSCAD's bin output 1:1.
FA = 8.0
FS = 0.25
# Floor on segments: a circle never has fewer than this many segments
MIN_SEGMENTS = 5
# Used for the few circles that don't have an obvious radius (e.g. when we
# don't dispatch to _cyl). Kept as a fallback.
CIRCULAR_SEGMENTS = 32
import math as _math
def _segments_for_radius(r: float) -> int:
"""OpenSCAD-equivalent fragment count for a cylinder of given radius."""
if r <= 1e-6:
return MIN_SEGMENTS
by_angle = _math.ceil(360.0 / FA)
by_length = _math.ceil(2.0 * _math.pi * r / FS)
return max(MIN_SEGMENTS, min(by_angle, by_length))
Manifold = m.Manifold
# ---------------------------------------------------------------------------
# SCAD-style primitive helpers (named identically to the OpenSCAD operators)
# ---------------------------------------------------------------------------
def _cyl(x: float, y: float, z: float, h: float, r_bot: float, r_top: float,
segments: int = 0) -> Manifold:
"""SCAD's `translate([x,y,z]) cylinder(h, r_bot, r_top)`.
Segment count follows OpenSCAD's $fa/$fs scheme based on the larger of the
two radii — same as OpenSCAD itself. Pass `segments=N` to override.
"""
r_bot = max(r_bot, 1e-4)
r_top = max(r_top, 1e-4)
h = max(h, 1e-6)
if segments <= 0:
segments = _segments_for_radius(max(r_bot, r_top))
return Manifold.cylinder(h, r_bot, r_top, circular_segments=segments).translate([x, y, z])
def _box(x0: float, y0: float, z0: float, w: float, d: float, h: float) -> Manifold:
"""SCAD's `translate([x0,y0,z0]) cube([w,d,h])`."""
return Manifold.cube([w, d, h], center=False).translate([x0, y0, z0])
def _hull4(c0: Manifold, c1: Manifold, c2: Manifold, c3: Manifold) -> Manifold:
"""The omnipresent `hull() { 4 cylinders at corners }` pattern."""
return Manifold.batch_hull([c0, c1, c2, c3])
# Tiny over-size for subtraction shapes to avoid coplanar-surface CSG
# robustness issues (manifold3d, like CGAL, leaves zero-thickness shells when
# two surfaces coincide exactly). ε is far below mesh tolerance / printer
# precision, so the resulting STL is geometrically equivalent.
EPS_VOID = 1e-3
def _hull4_void(corners: list, z0: float, h: float, rb: float, rt: float,
segments: int = 0) -> Manifold:
"""Like `_hull4(four cylinders at corners)`, but the resulting solid is
grown by EPS_VOID radially and in Z so it cleanly carves through any
coincident outer surface. Use this *only* for voids in difference()."""
cyls = [_cyl(cx, cy, z0 - EPS_VOID, h + 2 * EPS_VOID,
rb + EPS_VOID, rt + EPS_VOID, segments)
for cx, cy in corners]
return Manifold.batch_hull(cyls)
def _union_all(parts: list) -> Manifold:
parts = [p for p in parts if p is not None and not p.is_empty()]
if not parts:
return Manifold()
res = parts[0]
for p in parts[1:]:
res = res + p
return res
def _diff_all(base: Manifold, voids: list) -> Manifold:
voids = [v for v in voids if v is not None and not v.is_empty()]
if not voids:
return base
res = base
for v in voids:
res = res - v
return res
# ---------------------------------------------------------------------------
# Bin pieces — each function name and structure mirrors the SCAD module of
# the same purpose so it stays auditable side-by-side with the .scad file.
# ---------------------------------------------------------------------------
def _make_magnet_holes(p: GridfinityParams, sx: float, sy: float) -> Manifold:
r = p.magnet_diameter / 2.0
h = p.magnet_depth
cyls = [
_cyl(sx + BASIC_RADIUS_2, sy + BASIC_RADIUS_2, 0, h, r, r),
_cyl(sx + _unit_xy(p) - BASIC_RADIUS_2, sy + BASIC_RADIUS_2, 0, h, r, r),
_cyl(sx + BASIC_RADIUS_2, sy + _unit_xy(p) - BASIC_RADIUS_2, 0, h, r, r),
_cyl(sx + _unit_xy(p) - BASIC_RADIUS_2, sy + _unit_xy(p) - BASIC_RADIUS_2, 0, h, r, r),
]
return _union_all(cyls)
def _make_bin_base_single(p: GridfinityParams, sx: float, sy: float,
wx: float, wy: float) -> Manifold:
"""One Gridfinity foot unit (full / half / quarter sized)."""
radius_0 = 1.05 - OFFSET_XY
radius_1 = 1.85 - OFFSET_XY
radius_2 = BASIC_RADIUS_1 - OFFSET_XY
h0 = 0.80
h1 = 1.80
h2 = 2.15
wall = p.wall_thickness
Wx = wx * _unit_xy(p)
Wy = wy * _unit_xy(p)
R = BASIC_RADIUS_1
corners = [
(sx + R, sy + R),
(sx + Wx - R, sy + R),
(sx + R, sy + Wy - R),
(sx + Wx - R, sy + Wy - R),
]
outer = _box(sx, sy, 0.0, Wx, Wy, h0 + h1 + h2 + wall)
voids: list[Manifold] = []
if p.ultra_light_base:
# Layer 1: bottom transition
cyls = [_cyl(cx, cy, wall,
h0 - 0.5858 * wall,
radius_0 - 0.4142 * wall,
radius_1 - wall)
for cx, cy in corners]
voids.append(_hull4(*cyls))
# Layer 2: vertical mid wall
cyls = [_cyl(cx, cy, h0 + 0.4142 * wall, h1,
radius_1 - wall, radius_1 - wall)
for cx, cy in corners]
voids.append(_hull4(*cyls))
# Layer 3: outward flare
cyls = [_cyl(cx, cy, h0 + h1 + 0.4142 * wall, h2,
radius_1 - wall, radius_2 - wall)
for cx, cy in corners]
voids.append(_hull4(*cyls))
# Layer 4: small inset under the lip
cyls = [_cyl(cx, cy, h0 + h1 + h2, 0.4142 * wall,
radius_2 - 1.4142 * wall, radius_2 - wall)
for cx, cy in corners]
voids.append(_hull4(*cyls))
# Layer 5: top chamfer (overlaps Layer 4 in z by design)
cyls = [_cyl(cx, cy, h0 + h1 + h2, 1.4142 * wall,
radius_2 - 1.4142 * wall, radius_2)
for cx, cy in corners]
voids.append(_hull4(*cyls))
if (not p.half_grid_base) and p.magnets and wx == 1.0 and wy == 1.0:
voids.append(_make_magnet_holes(p, sx, sy))
# The foot's bottom plate normally has thickness = wall_thickness (the
# cavity layers start at z = wall). When ``ultra_light_floor_thickness`` is
# set, raise the cavity floor by clipping any void below the requested z —
# equivalent to thickening the bottom plate to that value. Going thinner
# than wall_thickness is not supported (would require sculpting beneath
# Layer 1's chamfer).
if p.ultra_light_base and p.ultra_light_floor_thickness > wall:
box_top = h0 + h1 + h2 + wall
bottom_t = min(p.ultra_light_floor_thickness, box_top)
clip_block = _box(sx, sy, 0, Wx, Wy, bottom_t)
voids = [v - clip_block for v in voids]
result = _diff_all(outer, voids)
# Magnet ring reinforcement (4 cylinders around magnet holes), so the
# walls around magnets stay at least wall_thickness even after the
# ultra-light hollow chops the foot.
if (not p.half_grid_base) and p.magnets and wx == 1.0 and wy == 1.0:
ring_ro = (p.magnet_diameter / 2.0) + p.wall_thickness
ring_h = p.magnet_depth + p.wall_thickness
d = BASIC_RADIUS_2 - R
ring_centers = [
(sx + R + d, sy + R + d),
(sx + Wx - R - d, sy + R + d),
(sx + R + d, sy + Wy - R - d),
(sx + Wx - R - d, sy + Wy - R - d),
]
rings = _union_all([_cyl(cx, cy, 0, ring_h, ring_ro, ring_ro)
for cx, cy in ring_centers])
rings = rings - _make_magnet_holes(p, sx, sy)
result = result + rings
return result
def _make_bin_base(p: GridfinityParams, gx_: float, gy_: float) -> Manifold:
"""Tile foot units to cover the requested grid footprint."""
parts: list[Manifold] = []
fx = int(gx_)
fy = int(gy_)
half_x = (gx_ - fx) > 0
half_y = (gy_ - fy) > 0
for ix in range(fx):
for iy in range(fy):
parts.append(_make_bin_base_single(p, ix * _unit_xy(p), iy * _unit_xy(p), 1.0, 1.0))
if half_x:
for iy in range(fy):
parts.append(_make_bin_base_single(p, fx * _unit_xy(p), iy * _unit_xy(p), 0.5, 1.0))
if half_y:
for ix in range(fx):
parts.append(_make_bin_base_single(p, ix * _unit_xy(p), fy * _unit_xy(p), 1.0, 0.5))
if half_x and half_y:
parts.append(_make_bin_base_single(p, fx * _unit_xy(p), fy * _unit_xy(p), 0.5, 0.5))
return _union_all(parts)
def _make_bin_body(p: GridfinityParams, gx_: float, gy_: float) -> Manifold:
"""Outer rounded-rect tube of the bin."""
radius_0 = BASIC_RADIUS_1 - OFFSET_XY
h0 = 4.75
h1 = 7.0 - h0
h2 = (p.grids_z - 1) * BASIC_UNIT_Z
Wx = gx_ * _unit_xy(p)
Wy = gy_ * _unit_xy(p)
R = BASIC_RADIUS_1
corners = [
(R, R),
(Wx - R, R),
(R, Wy - R),
(Wx - R, Wy - R),
]
outer_cyls = [_cyl(cx, cy, h0, h1 + h2, radius_0, radius_0) for cx, cy in corners]
outer = _hull4(*outer_cyls)
inner_r = radius_0 - p.wall_thickness
inner_cyls = [_cyl(cx, cy, h0, h1 + h2, inner_r, inner_r) for cx, cy in corners]
inner = _hull4(*inner_cyls)
return outer - inner
def _make_bin_stacklip(p: GridfinityParams, gx_: float, gy_: float) -> Manifold:
"""Stacking lip on top of the bin (interlock profile)."""
radius_0 = 1.15
radius_1 = 1.85
radius_2 = BASIC_RADIUS_1 - OFFSET_XY
h0 = STACKING_LIP_WIDTH - p.wall_thickness
h1 = 0.60
h2 = 0.70
h3 = 1.80
h4 = 1.90
h5 = 0.75
h_start = (p.grids_z * BASIC_UNIT_Z) - (h0 + h1)
Wx = gx_ * _unit_xy(p)
Wy = gy_ * _unit_xy(p)
R = BASIC_RADIUS_1
corners = [
(R, R),
(Wx - R, R),
(R, Wy - R),
(Wx - R, Wy - R),
]
def _ring(z0: float, h: float, rb: float, rt: float) -> Manifold:
return _hull4(*[_cyl(cx, cy, z0, h, rb, rt) for cx, cy in corners])
outer = _ring(h_start, h0 + h1 + h2 + h3 + h4, radius_2, radius_2)
# Use _hull4_void for subtractions — needed because some voids have radii
# that match radius_2 (outer) → coplanar surfaces would leave thin shells.
voids = [
_hull4_void(corners, h_start, h0, radius_2 - p.wall_thickness, radius_0),
_hull4_void(corners, h_start + h0, h1, radius_0, radius_0),
_hull4_void(corners, h_start + h0 + h1, h2, radius_0, radius_1),
_hull4_void(corners, h_start + h0 + h1 + h2, h3, radius_1, radius_1),
_hull4_void(corners, h_start + h0 + h1 + h2 + h3, h4, radius_1, radius_2),
_hull4_void(corners, h_start + h0 + h1 + h2 + h3 + h4 - h5, h5, radius_2, radius_2),
]
return _diff_all(outer, voids)
def _make_bin_dividers(p: GridfinityParams, gx_: float, gy_: float) -> Manifold:
"""Vertical dividers inside the bin."""
if not p.dividers or (p.dividers_x <= 0 and p.dividers_y <= 0):
return Manifold()
radius_0 = 1.10
Wx = gx_ * _unit_xy(p)
Wy = gy_ * _unit_xy(p)
wall = p.wall_thickness
rsx = (Wx - 2 * OFFSET_XY - 2 * wall - 2 * radius_0
- p.dividers_x * (wall + 2 * radius_0)) / (p.dividers_x + 1)
rsy = (Wy - 2 * OFFSET_XY - 2 * wall - 2 * radius_0
- p.dividers_y * (wall + 2 * radius_0)) / (p.dividers_y + 1)
sxy = OFFSET_XY + wall + radius_0
R = BASIC_RADIUS_1
outer_r = BASIC_RADIUS_1 - OFFSET_XY
z_h = (p.grids_z * BASIC_UNIT_Z) - TOP_CLEARANCE_OFFSET - p.wall_thickness
outer_cyls = [
_cyl(R, R, p.wall_thickness, z_h, outer_r, outer_r),
_cyl(Wx - R, R, p.wall_thickness, z_h, outer_r, outer_r),
_cyl(R, Wy - R, p.wall_thickness, z_h, outer_r, outer_r),
_cyl(Wx - R, Wy - R, p.wall_thickness, z_h, outer_r, outer_r),
]
outer = _hull4(*outer_cyls)
full_h = p.grids_z * BASIC_UNIT_Z
voids: list[Manifold] = []
for dx in range(p.dividers_x + 1):
for dy in range(p.dividers_y + 1):
ox = dx * (rsx + 2 * radius_0 + wall)
oy = dy * (rsy + 2 * radius_0 + wall)
cell_corners = [
(sxy + ox, sxy + oy),
(sxy + ox + rsx, sxy + oy),
(sxy + ox, sxy + oy + rsy),
(sxy + ox + rsx, sxy + oy + rsy),
]
cell_cyls = [_cyl(cx, cy, 0.0, full_h, radius_0, radius_0)
for cx, cy in cell_corners]
voids.append(_hull4(*cell_cyls))
return _diff_all(outer, voids)
def _make_bin_label(p: GridfinityParams, gx_: float, gy_: float,
start_x: float, start_y: float, width: float, depth: float) -> Manifold:
"""One label tap (slab + sloped triangular underside).
Built as the SCAD `hull()` of two thin slabs — convex hull is exactly the
quadrilateral wedge we want.
"""
z_top = (p.grids_z * BASIC_UNIT_Z) - TOP_CLEARANCE_OFFSET
# 1 mm top slab
slab = _box(start_x, start_y - depth, z_top - LABEL_HEIGHT, width, depth, LABEL_HEIGHT)
# Sloped wedge (hull of two thin rectangles, exactly mirroring SCAD)
rect_a = _box(start_x, start_y - depth, z_top - LABEL_HEIGHT, width, depth, 0.0001)
rect_b = _box(start_x, start_y, z_top - LABEL_HEIGHT - depth, width, 0.0001, 0.0001)
triangle = Manifold.batch_hull([rect_a, rect_b])
if p.ultra_light_labels:
import math
# SCAD's default "one rib every ~13 mm". Density multiplier shrinks
# the spacing → more ribs per label tab.
density = max(p.label_support_density, 0.1)
rips_max_bridge = 13.0 / density
wall = p.wall_thickness
if p.label_position == "Full" and p.dividers:
rips = math.ceil(
(gx_ * _unit_xy(p) + p.dividers_x * wall - 2 * OFFSET_XY)
/ (rips_max_bridge * (p.dividers_x + 1))
) * (p.dividers_x + 1) - p.dividers_x
else:
rips = math.ceil(width / rips_max_bridge) + 1
# Cap so ribs don't overlap (need at least 0.5 mm of gap between them).
rips_max = max(2, int((width - 0.5) / max(wall, 0.1)))
rips = max(2, min(rips, rips_max))
rips_distance = (width - rips * wall) / (rips - 1)
rip_voids = []
for r in range(rips - 1):
rx = start_x + wall + r * (rips_distance + wall)
rip_voids.append(_box(rx, start_y - depth, z_top - LABEL_HEIGHT - depth,
rips_distance, depth, depth))
triangle = _diff_all(triangle, rip_voids)
return slab + triangle
def _make_bin_labels(p: GridfinityParams, gx_: float, gy_: float) -> Manifold:
if not p.labels:
return Manifold()
dx_n = p.dividers_x if (p.dividers and p.label_for_each_section) else 0
dy_n = p.dividers_y if (p.dividers and p.label_for_each_section) else 0
Wx = gx_ * _unit_xy(p)
Wy = gy_ * _unit_xy(p)
wall = p.wall_thickness
parts: list[Manifold] = []
if p.label_position == "Full":
for dy in range(dy_n + 1):
label_w = Wx - 2 * OFFSET_XY
label_d = (p.label_depth + STACKING_LIP_WIDTH) if dy == 0 else p.label_depth
sy = (Wy * (dy_n + 1 - dy)
- OFFSET_XY * (dy_n + 1 - 2.0 * dy)
+ wall * dy) / (dy_n + 1)
parts.append(_make_bin_label(p, gx_, gy_, OFFSET_XY, sy, label_w, label_d))
elif p.label_position == "Left":
for dx in range(dx_n + 1):
for dy in range(dy_n + 1):
lw = (p.label_width + STACKING_LIP_WIDTH) if dx == 0 else p.label_width
ld = (p.label_depth + STACKING_LIP_WIDTH) if dy == 0 else p.label_depth
sx = OFFSET_XY + ((Wx - 2 * OFFSET_XY - wall) / (dx_n + 1)) * dx
sy = (Wy - OFFSET_XY) - ((Wy - 2 * OFFSET_XY - wall) / (dy_n + 1)) * dy
parts.append(_make_bin_label(p, gx_, gy_, sx, sy, lw, ld))
elif p.label_position == "Center":
for dx in range(dx_n + 1):
for dy in range(dy_n + 1):
lw = p.label_width
ld = (p.label_depth + STACKING_LIP_WIDTH) if dy == 0 else p.label_depth
cell_w = (Wx - 2 * OFFSET_XY - wall) / (dx_n + 1)
sx = OFFSET_XY + cell_w * dx + cell_w / 2.0 - lw / 2.0
sy = (Wy - OFFSET_XY) - ((Wy - 2 * OFFSET_XY - wall) / (dy_n + 1)) * dy
parts.append(_make_bin_label(p, gx_, gy_, sx, sy, lw, ld))
elif p.label_position == "Right":
for dx in range(dx_n + 1):
for dy in range(dy_n + 1):
lw = (p.label_width + STACKING_LIP_WIDTH) if dx == dx_n else p.label_width
ld = (p.label_depth + STACKING_LIP_WIDTH) if dy == 0 else p.label_depth
cell_w = (Wx - 2 * OFFSET_XY - wall) / (dx_n + 1)
sx = OFFSET_XY + cell_w * dx + (cell_w + wall) - lw
sy = (Wy - OFFSET_XY) - ((Wy - 2 * OFFSET_XY - wall) / (dy_n + 1)) * dy
parts.append(_make_bin_label(p, gx_, gy_, sx, sy, lw, ld))
return _union_all(parts)
def _make_scoop_one(p: GridfinityParams, gx_: float, gy_: float,
start_y: float, spacing: float) -> Manifold:
Wx = gx_ * _unit_xy(p)
wall = p.wall_thickness
h0 = 0.6
h1 = 0 if p.ultra_light_base else 4.75
Sr = p.scoop_radius
outer = _box(OFFSET_XY, start_y, wall,
Wx - 2 * OFFSET_XY, Sr + spacing,
(p.grids_z * BASIC_UNIT_Z) - wall - h0)
cyl_y = start_y + Sr + spacing
cyl_z = wall + Sr + h1
cyl_len = Wx - 2 * OFFSET_XY
# Cylinder along +X: build in standard +Z orientation, rotate 90° around Y.
scoop_cyl = (Manifold.cylinder(cyl_len, Sr, Sr, circular_segments=_segments_for_radius(Sr))
.rotate([0, 90, 0])
.translate([OFFSET_XY, cyl_y, cyl_z]))
above = _box(OFFSET_XY, start_y + spacing, wall + Sr + h1,
Wx - 2 * OFFSET_XY, 2 * Sr, p.grids_z * BASIC_UNIT_Z)
return _diff_all(outer, [scoop_cyl, above])
def _make_bin_scoops(p: GridfinityParams, gx_: float, gy_: float) -> Manifold:
if not p.scoops or p.scoop_radius <= 0:
return Manifold()
dy_n = p.dividers_y if p.dividers else 0
Wy = gy_ * _unit_xy(p)
wall = p.wall_thickness
parts: list[Manifold] = []
for dy in range(dy_n + 1):
spacing = STACKING_LIP_WIDTH if dy == 0 else wall
sy = OFFSET_XY + ((Wy - 2 * OFFSET_XY - wall) / (dy_n + 1)) * dy
parts.append(_make_scoop_one(p, gx_, gy_, sy, spacing))
return _union_all(parts)
def _make_bin_clean_single(p: GridfinityParams, sx: float, sy: float,
wx: float, wy: float) -> Manifold:
radius_0 = 1.05 - OFFSET_XY
radius_1 = 1.85 - OFFSET_XY
radius_2 = BASIC_RADIUS_1 - OFFSET_XY
h0 = 0.80
h1 = 1.80
h2 = 2.15
Wx = wx * _unit_xy(p)
Wy = wy * _unit_xy(p)
R = BASIC_RADIUS_1
corners = [
(sx + R, sy + R),
(sx + Wx - R, sy + R),
(sx + R, sy + Wy - R),
(sx + Wx - R, sy + Wy - R),
]
outer = _box(sx, sy, 0.0, Wx, Wy, h0 + h1 + h2)
voids = [
_hull4(*[_cyl(cx, cy, 0.0, h0, radius_0, radius_1) for cx, cy in corners]),
_hull4(*[_cyl(cx, cy, h0, h1, radius_1, radius_1) for cx, cy in corners]),
_hull4(*[_cyl(cx, cy, h0 + h1, h2, radius_1, radius_2) for cx, cy in corners]),
]
res = _diff_all(outer, voids)
if (not p.half_grid_base) and p.magnets and wx == 1.0 and wy == 1.0:
res = res + _make_magnet_holes(p, sx, sy)
return res
def _make_bin_clean(p: GridfinityParams, gx_: float, gy_: float) -> Manifold:
"""Subtraction volume that gives the bin its rounded outer profile.
SCAD subtracts this from the union of base+body+lip+... at the end.
"""
parts: list[Manifold] = []
fx = int(gx_)
fy = int(gy_)
half_x = (gx_ - fx) > 0
half_y = (gy_ - fy) > 0
for ix in range(fx):
for iy in range(fy):
parts.append(_make_bin_clean_single(p, ix * _unit_xy(p), iy * _unit_xy(p), 1.0, 1.0))
if half_x:
for iy in range(fy):
parts.append(_make_bin_clean_single(p, fx * _unit_xy(p), iy * _unit_xy(p), 0.5, 1.0))
if half_y:
for ix in range(fx):
parts.append(_make_bin_clean_single(p, ix * _unit_xy(p), fy * _unit_xy(p), 1.0, 0.5))
if half_x and half_y:
parts.append(_make_bin_clean_single(p, fx * _unit_xy(p), fy * _unit_xy(p), 0.5, 0.5))
base_clean = _union_all(parts)
radius_0 = BASIC_RADIUS_1 - OFFSET_XY
h0 = 4.40
Wx = gx_ * _unit_xy(p)
Wy = gy_ * _unit_xy(p)
R = BASIC_RADIUS_1
corners = [
(R, R),
(Wx - R, R),
(R, Wy - R),
(Wx - R, Wy - R),
]
h_total = p.grids_z * BASIC_UNIT_Z + h0
inner_cyls = [_cyl(cx, cy, 0.0, h_total, radius_0, radius_0) for cx, cy in corners]
inner = _hull4(*inner_cyls)
outer_box = _box(0, 0, 0, Wx, Wy, h_total)
around = outer_box - inner
below = _box(0, 0, -12.0, Wx, Wy, 12.0)
return _union_all([base_clean, around, below])
# ---------------------------------------------------------------------------
# Top level
# ---------------------------------------------------------------------------
def _make_brim_ears(p: GridfinityParams, Wx: float, Wy: float) -> Manifold:
"""Flat discs at the four outer corners (after centering) to improve
first-layer adhesion on open printers — same idea as a slicer's "mouse
ears" / brim ears.
Disc centers are tucked diagonally INSIDE the bbox by 3/4 of the corner
radius (= BASIC_RADIUS_1 * 3/4 = 3 mm) so the disc significantly overlaps
the rounded foot — sitting the center exactly on the bbox corner leaves
only a hairline contact (the foot is rounded with R=BASIC_RADIUS_1, so
its closest point to the bbox corner is ~1.55 mm in along the diagonal).
With this offset, an Ø10 disc still sticks out past the bbox by ~2.25 mm
on each X/Y side while keeping a large overlap with the foot.
Must be unioned *after* the `_make_bin_clean` subtract, otherwise the
corner-clearance cut would slice the inner half away.
"""
if (not p.brim_ears) or p.brim_ear_diameter <= 0 or p.brim_ear_height <= 0:
return Manifold()
r = p.brim_ear_diameter / 2.0
h = p.brim_ear_height
inset = BASIC_RADIUS_1 * 0.75
hx = Wx / 2.0 - inset
hy = Wy / 2.0 - inset
corners = [(-hx, -hy), (hx, -hy), (-hx, hy), (hx, hy)]
return _union_all([_cyl(cx, cy, 0.0, h, r, r) for cx, cy in corners])
def _mirror_xy(part: Manifold, mirror_x: bool, mirror_y: bool,
Wx: float, Wy: float) -> Manifold:
if not mirror_x and not mirror_y:
return part
if mirror_x and not mirror_y:
return part.mirror([1, 0, 0]).translate([Wx, 0, 0])
if not mirror_x and mirror_y:
return part.mirror([0, 1, 0]).translate([0, Wy, 0])
return part.mirror([1, 0, 0]).mirror([0, 1, 0]).translate([Wx, Wy, 0])
def build_bin(p: GridfinityParams) -> Manifold:
gx_ = 2.0 * p.grids_x if p.half_grid_base else p.grids_x
gy_ = 2.0 * p.grids_y if p.half_grid_base else p.grids_y
Wx = gx_ * _unit_xy(p)
Wy = gy_ * _unit_xy(p)
base = _make_bin_base(p, gx_, gy_)
base = _mirror_xy(base, not p.half_grid_right, not p.half_grid_top, Wx, Wy)
body = _make_bin_body(p, gx_, gy_)
lip = _make_bin_stacklip(p, gx_, gy_)
pieces = [base, body, lip]
if p.dividers and (p.dividers_x > 0 or p.dividers_y > 0):
pieces.append(_make_bin_dividers(p, gx_, gy_))
if p.labels:
pieces.append(_make_bin_labels(p, gx_, gy_))
if p.scoops:
pieces.append(_make_bin_scoops(p, gx_, gy_))
union_part = _union_all(pieces)
clean = _make_bin_clean(p, gx_, gy_)
clean = _mirror_xy(clean, not p.half_grid_right, not p.half_grid_top, Wx, Wy)
result = union_part - clean
# SCAD centers the footprint on the origin
result = result.translate([-Wx / 2.0, -Wy / 2.0, 0])
# Brim "mouse ears" are added *after* the clean subtract — otherwise the
# corner-clearance cut would slice their inner halves away.
ears = _make_brim_ears(p, Wx, Wy)
if not ears.is_empty():
result = result + ears
return result
def to_stl_bytes(part: Manifold) -> bytes:
"""Return the binary-STL bytes for a manifold."""
mesh = part.to_mesh()
verts = np.asarray(mesh.vert_properties)[:, :3].astype(np.float32)
tris = np.asarray(mesh.tri_verts, dtype=np.uint32)
# Build STL triangles + recompute face normals
v0 = verts[tris[:, 0]]
v1 = verts[tris[:, 1]]
v2 = verts[tris[:, 2]]
n = np.cross(v1 - v0, v2 - v0)
norms = np.linalg.norm(n, axis=1, keepdims=True)
norms[norms == 0] = 1.0
n = (n / norms).astype(np.float32)
n_tri = len(tris)
out = bytearray()
out += b"\x00" * 80 # header
out += int(n_tri).to_bytes(4, "little") # uint32 triangle count
# Triangle records: 12B normal + 36B verts + 2B attribute
record = np.zeros((n_tri, 50), dtype=np.uint8)
# Pack as struct of 12 floats + 2 padding bytes per triangle
tri_buf = np.zeros((n_tri, 12), dtype=np.float32)
tri_buf[:, 0:3] = n
tri_buf[:, 3:6] = v0
tri_buf[:, 6:9] = v1
tri_buf[:, 9:12] = v2
record[:, :48] = tri_buf.view(np.uint8).reshape(n_tri, 48)
out += record.tobytes()
return bytes(out)
def export(p: GridfinityParams, path: str) -> None:
part = build_bin(p)
with open(path, "wb") as f:
f.write(to_stl_bytes(part))
if __name__ == "__main__":
import sys, time
p = GridfinityParams()
out = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gridfinity_default.stl"
t = time.perf_counter()
export(p, out)
print(f"Wrote {out} in {(time.perf_counter()-t)*1000:.0f}ms")