DEV Community

Black
Black

Posted on

The moon always shows the same face, so my 3D printed moon phases do too (Python SDF)

Moon phase garland render

Moon phase wall hangings are everywhere, and almost all of them are flat cut-outs: a circle, a half circle, a crescent. The thing that bugged me is that the real moon always shows us the same face. The craters don't move between phases; only the shadow does.

So I made a printable garland where all seven moons share one surface, and each phase just changes where the light falls. Here is the approach, in Python with manifold3d and a small signed-distance-field mesher.

1. One face for every moon

The face is a height function on the unit disc: a dome, minus a few shallow maria (the dark "seas"), plus a scatter of craters. The random generator has a fixed seed, so every phase gets exactly the same craters in the same places:

rng = np.random.default_rng(11)
# many small craters, few large ones
CR = [(rng.uniform(-1, 1), rng.uniform(-1, 1), 0.025 + 0.11*rng.uniform(0, 1)**3)
      for _ in range(55)]
MARIA = [(-0.25, 0.35, 0.30), (0.2, 0.42, 0.22), (0.38, 0.05, 0.2),
         (-0.42, -0.05, 0.24), (0.05, -0.15, 0.15)]
Enter fullscreen mode Exit fullscreen mode

Cubing the random radius (uniform**3) is a cheap way to get the realistic size spread: lots of tiny craters and only a handful of big ones.

2. A crater is a bowl plus a rim

Each crater is a shallow bowl with a raised ring around it. A clipped paraboloid gives the bowl, a gaussian around distance 1 gives the rim:

d = np.sqrt((u - x)**2 + (v - y)**2) / rc
s += rc * (-0.45*np.clip(1 - d*d, 0, 1) + 0.22*np.exp(-((d - 1)/0.22)**2))
Enter fullscreen mode Exit fullscreen mode

Because the depth scales with rc, small craters stay small and big ones get proportionally deep, which is what makes it read as a moon and not a golf ball.

3. Light as height

On a print there is no light, so I turn "lit" into "raised". The lit side is the full dome with all its detail; the night side drops down to a low, almost flat plate:

side = -u if waning else u
lit = np.clip((side - k*np.sqrt(np.clip(1 - v*v, 0, 1)))*R/0.8 + 0.5, 0, 1)
h = BASE + (NIGHT + (DOME - NIGHT)*lit)*dome + R*s*(0.08 + 0.92*lit)
Enter fullscreen mode Exit fullscreen mode

k places the terminator: k = 0 is a straight line (quarter moon), positive k bends it into a crescent, negative k into a gibbous. The sqrt(1 - v*v) factor makes the terminator an ellipse, the same curve you see on the real moon. The /0.8 makes the transition about 0.8 mm wide, so the shadow edge is soft instead of a hard cliff.

The craters are still faintly there on the night side (8% strength). In a raking light you can just make them out, like earthshine.

4. From height field to mesh

The height function becomes an SDF (below the surface, above the bed, inside the disc), and a grid level-set turns it into a watertight mesh at 0.35 mm resolution:

def f(X, Y, Z):
    ...
    return np.minimum(np.minimum(h - Z, Z), R - np.hypot(X, Y))

m = grid_level_set(f, [-R-1, -R-1, -0.5, R+1, R+1, BASE+DOME+3], 0.35)
Enter fullscreen mode Exit fullscreen mode

Two cord holes go through each moon sideways, so the set strings onto a cord and hangs straight.

Result

Seven moons: waxing crescent, first quarter, waxing gibbous, a larger 90 mm full moon, then the waning three. Flat backs, about 13 mm thick, no supports. Hung in a row it reads as one moon moving through the month, which is the whole point. It also takes a two-colour filament swap really well (dark base, white from the dome up).

If you want to print it: Moon Phase Garland on Cults3D.

Other designs and early access: patreon.com/123black

Top comments (0)