Two flat prints, two small pieces of math that make them look right: a Celtic knot trivet with a true over-under weave, and a set of tree-ring coasters where every slice grows from its own random seed. Both are generated in Python with manifold3d and print with no supports.

1. Celtic knot trivet: getting over-under-over from one sine

Celtic knot trivet

The band follows a (3, 8) torus-knot path flattened onto the plane:

P, Q = 3, 8; R0, A = 50.0, 35.0 t = np.linspace(0, 2*np.pi, 1200, endpoint=False) r = R0 + A*np.cos(Q*t); a = P*t xy = np.c_[r*np.cos(a), r*np.sin(a)] s = np.sin(2*Q*t) # height sign 

The trick is sin(2Qt). Each strand meets another strand twice per lobe, and sin(2Qt) changes sign exactly between consecutive crossings. If height is BASE + RISE * s, a strand goes over, then under, then over, all the way around. That is the rule that makes a Celtic knot read as woven instead of a flat tangle.

A height field alone still looks muddy at the crossings, because the low strand runs straight through the high one. So the model is built in two halves:

  • the "over" segments (s > 0) at full height
  • the "under" segments, with a widened copy of the over strand subtracted from them near each crossing
cutter = band(xy, np.full(N, 3.0), s > 0.55, W + 1.1).translate([0, 0, FLOOR]) m = (under - cutter) + over 

The cutter starts 1.6 mm above the bed, not at zero. That leaves a thin floor under every crossing, so the band stays one connected piece and does not fall apart into separate arcs. A small groove along the top of the band and two rim rings finish it.

Celtic Knot Trivet on Payhip

2. Tree ring coasters: rings from a cumulative sum

Tree ring coasters

Real growth rings are not evenly spaced: wet years are wide, dry years are narrow. That is one line of numpy:

years = np.cumsum(rng.uniform(1.4, 3.4, 40)) # ring radii 

For every grid point, measure the distance from an off-centre pith, add a little angular wobble, then find which two rings it sits between with searchsorted:

q = rp + wobble k = np.searchsorted(years, q).clip(1, len(years) - 1) d = np.minimum(abs(q - years[k]), abs(q - years[k-1])) h = T - 0.85*np.exp(-(d / 0.5)**2) # dark late-wood line 

This is vectorized over the whole grid, so a 96 mm slice at 0.3 mm resolution meshes in seconds. Drying cracks are V-shaped cuts that are widest at the bark and fade toward the centre, and the bark rim is a few summed sines at prime frequencies so it never visibly repeats.

One thing that cost me a render: the groove profile needs a width (sigma) of at least 0.5 mm at a 0.3 mm grid step. Narrower than that and the lines alias into dotted noise on the mesh.

Each of the four coasters uses a different seed, so no two match, and they can print in three colours with filament swaps at fixed heights.

Tree Ring Coasters on Payhip

Takeaways

  • Look for the one function that encodes the rule (sin(2Qt) for the weave, cumsum for the years) before adding detail.
  • When cutting one part with another, leave a floor or bridge so the result stays one piece. Check with decompose().
  • Numpy SDFs on a grid are far faster than per-point Python functions.

Both designs are licensed so you can print them and sell the prints; the files themselves stay mine. More of my designs: Cults3D · patreon.com/123black