The Icosahedron Hub

July 1, 2026

The part that holds the video sphere together is a single boolean operation: a 60 mm printed ball minus thirteen cylinders.

module hub() {
    difference() {
        sphere(r = sphere_r, $fn = resolution);
        for (v = vertices) radial_hole(v);
        if (include_south_pole_hole) radial_hole(south_pole_dir);
    }
}

Twelve of the holes point at the vertices of a regular icosahedron and receive the press-fit stems that carry the screens. The thirteenth points straight down and receives the long stem that mounts the whole thing on a pedestal. The file that produces every one of these parts (hub, screen stems, mounting stem, pedestal) is about 140 lines of OpenSCAD, and the part I want to talk about is a comment.

The white 3D-printed icosahedron hub with thin stems press-fit into its radial holes, standing on a wooden workbench

Twelve directions from three rectangles

An icosahedron’s twelve vertices are the cyclic permutations of (0, ±1, ±φ), where φ is the golden ratio. Picture three mutually perpendicular golden rectangles:

phi = (1 + sqrt(5)) / 2;

vertices = [
    [ 0,  1,  phi], [ 0,  1, -phi], [ 0, -1,  phi], [ 0, -1, -phi],
    [ 1,  phi,  0], [ 1, -phi,  0], [-1,  phi,  0], [-1, -phi,  0],
    [ phi,  0,  1], [ phi,  0, -1], [-phi,  0,  1], [-phi,  0, -1]
];

The count of twelve comes from the project spec, which ranks candidate screen counts by how evenly you can distribute them on a sphere: 12 (icosahedron) and 20 (dodecahedron) are the two counts with perfectly regular distributions, and everything in between needs a Fibonacci-sphere approximation.

The icosahedron has a second property that pays off later, on the software side: none of its normalized vertices has a |y| component above φ/√(1+φ²) ≈ 0.851. In practice that means no screen sits near a pole, so every virtual camera in the render rig can use world-Y as its up vector without special-case handling. The camera math gets a whole post (The Camera Rig), but the reason it’s simple is decided right here, in the choice of solid.

Pointing a cylinder anywhere

OpenSCAD’s cylinder() only grows along Z, so each hole is a rotation of a Z-aligned cylinder onto its vertex direction. The axis of rotation is the cross product of Z with the target direction; the angle falls out of the dot product:

module radial_hole(direction) {
    dir  = normalize(direction);
    axis = cross([0, 0, 1], dir);
    ang  = acos(dir[2]);
    rotate(a = ang, v = axis)
        translate([0, 0, sphere_r - hole_depth])
            cylinder(h = hole_depth + 0.5, r = hole_r, $fn = 48);
}

The cylinder starts at sphere_r - hole_depth and overshoots the surface by half a millimeter so the boolean cuts cleanly. That’s the only trigonometry in the file.

The thirteenth hole

The south-pole hole is the one that almost didn’t fit. The mounting hole points along −Y, and the icosahedron has two vertices at [±1, −φ, 0] that are only 31.72° away from it. Three holes converging that tightly means their inner ends approach each other inside the sphere, and if they’re deep enough they merge. The hub would print with internal voids where solid plastic should be.

The constraint, worked out and left as a comment above the parameters it couples:

// sphere_diameter and hole_depth are coupled by the south-pole clearance
// constraint: the south-pole hole (−Y) is only 31.72° away from the
// icosahedron vertices at [±1, −phi, 0], so the inner ends of those
// hole cylinders intersect inside the sphere unless
//     sphere_r − hole_depth ≥ hole_r / sin(31.72°/2) ≈ 18.86 mm.

Run the numbers on the first version (a 50 mm sphere with 12 mm-deep holes) and the hole floors sit at radius 25 − 12 = 13 mm, well inside the 18.86 mm minimum. The holes collided. The version in the repo today is a 60 mm sphere with 10 mm holes: floors at 20 mm, which clears the constraint with about 0.6 mm of wall left between the closest pair of hole walls. The fuller story of what changed across hardware versions is in The Hardware Revisions.

Stems, chamfers, and press fits

Everything joins by friction. The stems are 10.0 mm cylinders; the holes are 10.3 mm, and stem_clearance = 0.3 is a named parameter with a comment admitting it’s printer-dependent. Each stem gets a 0.6 mm chamfer at the insertion end so it starts into the hole instead of shaving it.

The screen stems expose 100 mm. The long mounting stem exposes 220 mm (230 mm total with its socket depth, sized to fit a 256 mm print bed with margin) and is chamfered on both ends, because both ends are press-fit: one into the hub, one into the pedestal.

A pedestal with no supports

The pedestal that holds the long stem upright is a rotate_extrude of a quarter-ellipse: a 150 mm flat foot that tapers up to a narrow socket neck. The curve is chosen so the tangent is horizontal at the foot edge and vertical at the neck. Every printed layer is contained by the layer below it, so the part prints without supports by construction rather than by slicer heroics. More on that in The Printed Parts.

First full-form prototype: the white pedestal and long stem holding the hub, with three empty black screen enclosures mounted on stems

The inequality is the engineering

A sphere minus thirteen cylinders is a first-week OpenSCAD exercise; the real work is the clearance inequality, and the decision to write it down at the exact place where someone (probably me) will later try to change sphere_diameter or hole_depth without remembering they’re coupled. Write the constraint where the parameters live.

If you want the parts: the source is icosahedron-sphere.scad, with a part variable at the top ("sphere" | "stem" | "long_stem" | "base"). Set it, render, export the STL.


Part 1 of the video-sphere series. Next: The Screen Enclosure.