Voronoi bone scaffold

The example is a bone-like scaffold of 20 × 20 that uses the edges of a Voronoi diagram as struts, dense near the outside like cortical bone and open inside like the spongy core. With 2000 cells, some 6000 struts are united at once. Smaller scaffolds are then pressed in a linear elasticity solve in NGSolve, and their stiffness is compared with the scaling law of bending-dominated cellular solids. Lengths are in mm.

A bone-like Voronoi scaffold, dense at the rim and open inside

[1]:
import math
import random
import time
import numpy as np
from scipy.spatial import Voronoi
import matplotlib.pyplot as plt
import ngsolve as ngs
from ngsolve_webgpu.jupyter import Draw
import planex as px

The scaffold

The seeds are thinned towards the center. Mirrored copies across the four sides make the diagram’s cells end exactly on the square, so the cells along the sides share a frame of struts on the square’s edges. Every edge of the diagram inside the square becomes a strut, and the union is cut to the square, whose edges are named.

[3]:
size = 20.0


def seeds(n, graded=True, seed=1):
    rng = random.Random(seed)
    points = []
    while len(points) < n:
        x, y = rng.uniform(0, size), rng.uniform(0, size)
        inner = min(x, y, size - x, size - y) / (size / 2)
        if not graded or rng.random() < 1.2 - inner:
            points.append((x, y))
    return np.array(points)


def scaffold(p, thickness, sides=True):
    mirrored = np.vstack([p, p * [-1, 1], p * [1, -1], [2 * size, 0] + p * [-1, 1], [0, 2 * size] + p * [1, -1]])
    diagram = Voronoi(mirrored)
    struts = []
    for i, j in diagram.ridge_vertices:
        if i < 0 or j < 0:
            continue
        a, b = diagram.vertices[i], diagram.vertices[j]
        if not all(-1e-9 <= c <= size + 1e-9 for c in (*a, *b)):
            continue
        if not sides and (max(abs(a[0]), abs(b[0])) < 1e-9 or min(a[0], b[0]) > size - 1e-9):
            continue
        struts.append(px.Line(a, b).Offset(thickness / 2))
    box = px.QuickSketch().Line(size, name="bottom").Turn(90).Line(size, name="right").Turn(90)
    box = box.Line(size, name="top").Turn(90).Line(size, name="left").Face()
    return struts, box

2000 cells

The struts are 0.12 thick. Every cell becomes a hole, except the few that are smaller than the thickness of a strut (Figure 1).

[4]:
struts, box = scaffold(seeds(2000), 0.12)
start = time.perf_counter()
bone = px.Unify(struts) * box
print(len(struts), "struts united and cut to the square in", round(time.perf_counter() - start, 3), "s")
print(len(bone.faces), "face,", sum(len(f.inner) for f in bone.faces), "holes from 2000 cells,", len(bone.edges),
      "edges")
print("relative density", round(bone.mass / size**2, 3))
6001 struts united and cut to the square in 0.09 s
1 face, 1986 holes from 2000 cells, 10541 edges
relative density 0.461
2000 cells, the struts 0.12 thick
Figure 1: A graded Voronoi scaffold

Pressing

The test uses scaffolds with 300 cells of even size and open sides, with bone tissue in plane stress, the bottom held and the top pushed down by 1 %. The reaction at the top gives the scaffold’s apparent stiffness E. A structure whose struts bend instead of stretch loses stiffness with the cube of its relative density ρ, E ≈ C E ρ³ with C of order one, while a structure that only stretched would get E ρ. Bone tissue has a Young’s modulus E of 18 000 N/mm² and a Poisson’s ratio nu of 0.3.

[6]:
E = 18000.0
nu = 0.3
cells = seeds(300, graded=False)


def strain(w):
    return ngs.Sym(ngs.Grad(w))


def stress(w):
    e = strain(w)
    return E / (1 - nu**2) * ((1 - nu) * e + nu * ngs.Trace(e) * ngs.Id(2))


def press(thickness):
    struts, box = scaffold(cells, thickness, sides=False)
    sample = px.Unify(struts) * box
    mesh = ngs.Mesh(px.OCCGeometry(sample).GenerateMesh(maxh=0.6))
    mesh.Curve(3)
    fes = ngs.VectorH1(mesh, order=2, dirichletx="bottom", dirichlety="bottom|top")
    u, v = fes.TnT()
    a = ngs.BilinearForm(ngs.InnerProduct(stress(u), strain(v)) * ngs.dx).Assemble()
    disp = ngs.GridFunction(fes)
    disp.Set(ngs.CF((0, -0.01 * size)), definedon=mesh.Boundaries("top"))
    with ngs.TaskManager():
        disp.vec.data += a.mat.Inverse(fes.FreeDofs(), inverse="sparsecholesky") * (-a.mat * disp.vec)
    reaction = disp.vec.CreateVector()
    reaction.data = a.mat * disp.vec
    force = {}
    for side in ("top", "bottom"):
        w = ngs.GridFunction(fes)
        w.Set(ngs.CF((0, 1)), definedon=mesh.Boundaries(side))
        force[side] = ngs.InnerProduct(w.vec, reaction)
    density = sample.mass / size**2
    modulus = -force["top"] / size / 0.01
    print("strut", thickness, "rho", round(density, 3), "E*/E", round(modulus / E, 4),
          "C", round(modulus / (E * density**3), 2), "top + bottom reaction", f"{force['top'] + force['bottom']:.1e}")
    return density, modulus, mesh, disp

Check: for struts from 0.1 to 0.3 thick, C stays of order one, and the reactions at the top and the bottom balance:

[7]:
results = {t: press(t) for t in (0.1, 0.15, 0.2, 0.3)}
rho = np.array([r[0] for r in results.values()])
stiffness = np.array([r[1] / E for r in results.values()])
strut 0.1 rho 0.161 E*/E 0.0116 C 2.79 top + bottom reaction -2.5e-11
strut 0.15 rho 0.236 E*/E 0.0284 C 2.18 top + bottom reaction -1.1e-10
strut 0.2 rho 0.307 E*/E 0.0519 C 1.8 top + bottom reaction 7.9e-11
strut 0.3 rho 0.438 E*/E 0.1142 C 1.36 top + bottom reaction 7.1e-11

The stiffness against the density, on logarithmic axes, with the two laws:

The scaffold with struts of 0.2, pressed: its displacement, with the deformation magnified 10 times:

[9]:
density, modulus, mesh, disp = results[0.2]
Draw(disp, mesh, deformation=10 * disp)
3D view
▶ Click to interact