Rendered headless by the example itself — click to zoom.
blender --background --python examples/soccer-ball-goldberg/soccer_ball_goldberg.py --
A runnable example that builds a soccer ball as a Goldberg polyhedron: a bmesh icosphere truncated at exactly 1/3 of every edge, yielding the truncated icosahedron — 12 pentagons, 20 hexagons — with every face ordered by walking the source mesh's own link topology (fan walk per vertex, loop order per triangle). Nothing is hand-listed, following mesh-editing-and-bmesh and the always-free-bmesh rule.
Sibling, not twin: bmesh-gear witnesses parametric extrusion ownership (counts from construction parameters, watertightness). This witnesses polyhedral topology invariants — the truncated icosahedron is an Archimedean solid, so its counts, vertex degree, edge uniformity, face planarity, and circumsphere are all closed forms — plus per-face-class material binding driven by face vertex count, never by enumeration order.
What it witnesses: truncation at 1/3 makes every new edge the same length (a/3), which is what puts all 60 vertices on one sphere. The check asserts, with tolerances printed on success:
- Counts and characteristic. V=60, E=90, F=32, Euler
V−E+F==2— the sphere topology. Catches a leaked or dropped face/edge/vertex (probe: one face deleted → exit 3, topology(60, 90, 31)). - Face census. Exactly 12 five-sided and 20 six-sided faces (exit 5).
- Uniform degree 3. Every vertex touches exactly three edges, and every edge borders exactly two faces — the Goldberg dual property, watertight (exit 6/7).
- Edge uniformity. Max deviation from the mean length, measured 6.471e-06 against tol 3.0e-05 (exit 8). Catches a vertex dragged off the lattice (probe: one vertex shifted 0.05 → exit 8, deviation 4.726e-02, three orders of magnitude over tolerance).
- Face planarity. Worst vertex-to-plane distance 1.888e-06 against the same tolerance, measured against an independent Newell normal — not Blender's polygon normal (exit 9).
- Circumsphere. Centroid on the origin (
0.000e+00, tol 1e-6) and every vertex equidistant from it: max deviation 8.956e-06 (exit 10/11). - Panel binding by class. Exactly two materials; every 5-gon carries slot 1 (black), every 6-gon slot 0 (white). The builder assigns by
len(poly.vertices), and the check re-derives the expectation the same way — so an enumeration-order shortcut fails (probe: last-12-faces-black → exit 13, 24 misbound faces; inverted classes → exit 13, 32 misbound). Enumeration order coinciding with class order is a construction artifact, not a contract — the check exists for the cases where it doesn't.
Tolerance basis: mesh coordinates are float32 and create_icosphere's own trig lands the invariants at a deterministic ~9e-6 noise floor (measured values are byte-identical on 4.5.11 and 5.1.2). The 3e-5 gates sit ~3x above that floor; any genuine contract break is orders of magnitude larger.
Version witness: check output is byte-identical on Blender 4.5.11 LTS and 5.1.2 — same counts, same deviations. One authoring hazard surfaced and is noted in the code: Object.to_mesh_clear() takes no argument on current Blender (passing the mesh raises TypeError) — see depsgraph-and-evaluated-data.
The render is the proof: the faceted Goldberg cage is smoothed by an unapplied Subsurf modifier (panel materials carry through Catmull-Clark per face class), and the ball is grounded by its depsgraph-evaluated lowest vertex — center-at-circumradius floats the ball, because the smoothed surface sinks toward the face inradii. Invert the panel binding and the still inverts with it: white pentagons on a black ball, wrong on sight.
Run
# Cheap correctness check (no render) — the CI check:
blender --background --python soccer_ball_goldberg.py --
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python soccer_ball_goldberg.py -- --output ball.png
blender --background --python soccer_ball_goldberg.py -- --output ball.png --engine cycles
It exits non-zero on failure (topology, census, degree, edge uniformity, planarity, circumsphere, or panel binding). The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.
Source
"""A soccer ball built as a Goldberg polyhedron with bmesh — a runnable example. A truncated icosahedron (12 pentagons, 20 hexagons) derived by cutting every edge of a bmesh icosphere at exactly 1/3, with the pentagon/hexagon faces ordered by walking the source mesh's own link topology — nothing is hand-listed. Distinct from bmesh-gear (which witnesses parametric extrusion ownership): this witnesses *polyhedral topology invariants* — the closed-form counts, Euler characteristic, uniform degree, uniform edge length, face planarity, and a common circumsphere — plus per-face-class material binding driven by face vertex count, never by enumeration order. By default it runs only the correctness check (no render) — the CI smoke check. Pass --output to also render a still: blender --background --python soccer_ball_goldberg.py -- # check only blender --background --python soccer_ball_goldberg.py -- --output b.png # + render """ import bpy, bmesh, sys, os, math, argparse # truncation parameter: cutting each icosahedron edge at 1/3 makes every new # edge the same length (a/3), which is what makes the result an Archimedean # solid with a circumsphere at all TRUNC_T = 1.0 / 3.0 BALL_RADIUS = 1.15 # normalized circumradius after truncation # Tolerances: mesh coordinates are float32, and create_icosphere's own trig # lands the Goldberg invariants at a deterministic noise floor (~9e-6 here, # byte-identical on 4.5.11 and 5.1.2). The 3e-5 gates sit ~3x above that # floor; a genuine contract break (wrong cut, shifted vertex, bad binding) # produces errors of 1e-2 or larger, so the margin costs no sensitivity. TOL_LEN = 3.0e-5 # edge-length uniformity TOL_PLANAR = 3.0e-5 # face planarity (max vertex-to-plane distance) TOL_RADIUS = 3.0e-5 # circumsphere uniformity TOL_CENTER = 1.0e-6 # centroid at origin # closed forms for a truncated icosahedron (Goldberg polyhedron GP(1,1)) EXPECT_V, EXPECT_E, EXPECT_F = 60, 90, 32 EXPECT_PENTS, EXPECT_HEXES = 12, 20 EXPECT_DEGREE = 3 # every vertex touches exactly three faces def _fan_edges(bv): """Link edges of an icosphere vertex ordered circularly around it. BMVert.link_edges is unordered, so walk the manifold fan: each step crosses the link face that shares the current edge, to the face's other edge touching bv. A non-manifold vertex breaks the walk — which is itself a signal the source topology is not the icosphere we require. """ first = list(bv.link_edges)[0] ordered = [first] prev_face, current = None, first while True: # first step: either link face starts the walk; after that, cross to # the face that is not the one we came from shared = ([current.link_faces[0]] if prev_face is None else [f for f in current.link_faces if f is not prev_face]) if len(shared) != 1: raise RuntimeError("fan walk hit a non-manifold edge") face = shared[0] cand = [e for e in face.edges if e is not current and bv in e.verts] if len(cand) != 1: raise RuntimeError("fan walk found no continuation edge") nxt = cand[0] if nxt is first: return ordered ordered.append(nxt) prev_face, current = face, nxt def build_ball(): """Truncate a bmesh icosphere at 1/3 per edge into the Goldberg ball. The icosphere is the topology source: cut points are computed per edge, pentagons are ordered by the fan walk around each source vertex, and hexagons by each source face's loop order. Face winding is normalized once at the end with recalc_face_normals on the closed solid. """ bpy.ops.wm.read_factory_settings(use_empty=True) src = bmesh.new() ball = bmesh.new() try: bmesh.ops.create_icosphere(src, subdivisions=1, radius=1.0) src.verts.ensure_lookup_table() src.edges.ensure_lookup_table() # two cut points per source edge, keyed so the "near" point for # either endpoint is retrievable without edge-orientation guessing cut = {} for e in src.edges: a, b = e.verts i, j = sorted((a.index, b.index)) pa = a.co + (b.co - a.co) * TRUNC_T # near a pb = a.co + (b.co - a.co) * (2.0 * TRUNC_T) # near b cut[(i, j)] = (pa, pb) if a.index == i else (pb, pa) # each cut point is shared by exactly one pentagon and one hexagon, # so verts are created once and reused — duplicating them per face # would leave 180 loose vertices instead of the 60-vertex closed solid bvert = {} def near(i, j): """Ball vertex on edge (i,j) at the cut nearest source vertex i.""" key = (min(i, j), max(i, j), i) if key not in bvert: pa, pb = cut[(key[0], key[1])] bvert[key] = ball.verts.new(pa if i < j else pb) return bvert[key] # 12 pentagons: one per source vertex, from the fan-walked link edges for v in src.verts: ring = [near(v.index, e.other_vert(v).index) for e in _fan_edges(v)] ball.faces.new(ring) # 20 hexagons: one per source triangle, in loop order for f in src.faces: i0, i1, i2 = (v.index for v in f.verts) ring = [ near(i0, i1), near(i1, i0), near(i1, i2), near(i2, i1), near(i2, i0), near(i0, i2), ] ball.faces.new(ring) bmesh.ops.recalc_face_normals(ball, faces=ball.faces) # normalize scale: the solid is centered at the origin by symmetry, # so scale the mean circumradius to the target ball radius ball.verts.ensure_lookup_table() r_mean = sum(v.co.length for v in ball.verts) / len(ball.verts) bmesh.ops.scale(ball, vec=(BALL_RADIUS / r_mean,) * 3, verts=ball.verts) me = bpy.data.meshes.new("SoccerBall") ball.to_mesh(me) finally: src.free() ball.free() # the ownership contract from always-free-bmesh obj = bpy.data.objects.new("SoccerBall", me) bpy.context.collection.objects.link(obj) # the two panel materials exist in both modes — the binding is part of # the contract the check reads, not a render-only decoration white = bpy.data.materials.new("PanelWhite") black = bpy.data.materials.new("PanelBlack") me.materials.append(white) # slot 0: hexagons me.materials.append(black) # slot 1: pentagons # bound by face CLASS (vertex count), never by enumeration order: a # builder that assigns "first 12 faces black" passes only by luck of # bmesh face ordering, and the check below must catch it for poly in me.polygons: poly.material_index = 1 if len(poly.vertices) == 5 else 0 return obj def _newell(points): """Independent Newell normal for a polygon ring (no Blender normal used).""" nx = ny = nz = 0.0 for i, p in enumerate(points): q = points[(i + 1) % len(points)] nx += (p.y - q.y) * (p.z + q.z) ny += (p.z - q.z) * (p.x + q.x) nz += (p.x - q.x) * (p.y + q.y) n = math.sqrt(nx * nx + ny * ny + nz * nz) return (nx / n, ny / n, nz / n) def check(obj): me = obj.data got = (len(me.vertices), len(me.edges), len(me.polygons)) if got != (EXPECT_V, EXPECT_E, EXPECT_F): print(f"ERROR: topology {got} != expected " f"{(EXPECT_V, EXPECT_E, EXPECT_F)}", file=sys.stderr) return 3 euler = got[0] - got[1] + got[2] if euler != 2: print(f"ERROR: Euler characteristic {euler} != 2 — not a sphere topology", file=sys.stderr) return 4 census = {} for p in me.polygons: census[len(p.vertices)] = census.get(len(p.vertices), 0) + 1 if census != {5: EXPECT_PENTS, 6: EXPECT_HEXES}: print(f"ERROR: face census {census} != {{5: {EXPECT_PENTS}, 6: {EXPECT_HEXES}}}", file=sys.stderr) return 5 bm = bmesh.new() try: bm.from_mesh(me) degrees = [len(v.link_edges) for v in bm.verts] deg_min, deg_max = min(degrees), max(degrees) if deg_min != EXPECT_DEGREE or deg_max != EXPECT_DEGREE: print(f"ERROR: vertex degree spans {deg_min}..{deg_max}, " f"expected uniform {EXPECT_DEGREE}", file=sys.stderr) return 6 bad_edges = sum(1 for e in bm.edges if len(e.link_faces) != 2) if bad_edges: print(f"ERROR: {bad_edges} edge(s) do not border exactly 2 faces", file=sys.stderr) return 7 finally: bm.free() lengths = [(me.vertices[e.vertices[0]].co - me.vertices[e.vertices[1]].co).length for e in me.edges] l_mean = sum(lengths) / len(lengths) l_dev = max(abs(l - l_mean) for l in lengths) if l_dev > TOL_LEN: print(f"ERROR: edge lengths deviate {l_dev:.3e} from mean {l_mean:.6f} " f"(tol {TOL_LEN:.1e}) — not a uniform truncation", file=sys.stderr) return 8 worst_planar = 0.0 for p in me.polygons: pts = [me.vertices[i].co for i in p.vertices] n = _newell(pts) cx = sum(v.x for v in pts) / len(pts) cy = sum(v.y for v in pts) / len(pts) cz = sum(v.z for v in pts) / len(pts) d = max(abs((v.x - cx) * n[0] + (v.y - cy) * n[1] + (v.z - cz) * n[2]) for v in pts) worst_planar = max(worst_planar, d) if worst_planar > TOL_PLANAR: print(f"ERROR: face planarity worst {worst_planar:.3e} (tol {TOL_PLANAR:.1e})", file=sys.stderr) return 9 cx = sum(v.co.x for v in me.vertices) / EXPECT_V cy = sum(v.co.y for v in me.vertices) / EXPECT_V cz = sum(v.co.z for v in me.vertices) / EXPECT_V center_off = math.sqrt(cx * cx + cy * cy + cz * cz) if center_off > TOL_CENTER: print(f"ERROR: centroid off origin by {center_off:.3e} (tol {TOL_CENTER:.1e})", file=sys.stderr) return 10 radii = [math.sqrt((v.co.x - cx) ** 2 + (v.co.y - cy) ** 2 + (v.co.z - cz) ** 2) for v in me.vertices] r_mean = sum(radii) / len(radii) r_dev = max(abs(r - r_mean) for r in radii) if r_dev > TOL_RADIUS: print(f"ERROR: circumradius deviates {r_dev:.3e} from {r_mean:.6f} " f"(tol {TOL_RADIUS:.1e}) — vertices not on one sphere", file=sys.stderr) return 11 if len(me.materials) != 2: print(f"ERROR: expected 2 panel materials, found {len(me.materials)}", file=sys.stderr) return 12 misbound = [p.index for p in me.polygons if p.material_index != (1 if len(p.vertices) == 5 else 0)] if misbound: print(f"ERROR: {len(misbound)} face(s) carry the wrong panel material for " f"their class (first: {misbound[0]}) — binding must follow vertex " f"count, not enumeration order", file=sys.stderr) return 13 print(f"V={got[0]} E={got[1]} F={got[2]} euler=2 census=12x5+20x6 " f"degree={deg_min}..{deg_max} watertight=True") print(f"edge_len mean={l_mean:.6f} max_dev={l_dev:.3e} (tol {TOL_LEN:.1e}) | " f"planarity max={worst_planar:.3e} (tol {TOL_PLANAR:.1e})") print(f"circumradius mean={r_mean:.6f} max_dev={r_dev:.3e} (tol {TOL_RADIUS:.1e}) | " f"centroid_off={center_off:.3e} | panels bound by vertex count (12 black " f"pentagons, 20 white hexagons)") return 0 def eevee_engine_id(): return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' def _panel_materials(obj): """Leather finishes for the two panel classes (render path only).""" white, black = obj.data.materials white.use_nodes = True wb = white.node_tree.nodes["Principled BSDF"] wb.inputs["Base Color"].default_value = (0.82, 0.82, 0.84, 1.0) wb.inputs["Roughness"].default_value = 0.52 black.use_nodes = True bb = black.node_tree.nodes["Principled BSDF"] bb.inputs["Base Color"].default_value = (0.018, 0.02, 0.024, 1.0) bb.inputs["Roughness"].default_value = 0.48 def render_still(obj, path, engine): scene = bpy.context.scene me = obj.data _panel_materials(obj) # the check reads the base mesh; the still adds an UNAPPLIED Subsurf so # the faceted Goldberg cage reads as an inflated ball. Panel materials # carry through Catmull-Clark per face class, so a misbound panel would # still show in the image. for poly in me.polygons: poly.use_smooth = True sub = obj.modifiers.new("Inflate", 'SUBSURF') sub.subdivision_type = 'CATMULL_CLARK' sub.levels = 2 sub.render_levels = 2 # a pentagon sits on the icosphere +Z pole; tip it toward the camera and # spin for an asymmetric, match-worn panel layout obj.rotation_euler = (math.radians(58.0), math.radians(8.0), math.radians(31.0)) # rest the ball on the floor by its EVALUATED lowest point, not the # cage circumradius: the subsurf surface sinks toward the face inradii, # so center-at-RADIUS floats the ball visibly above the floor obj.location = (0.0, 0.0, BALL_RADIUS) bpy.context.view_layer.update() dg = bpy.context.evaluated_depsgraph_get() ev = obj.evaluated_get(dg) ev_me = ev.to_mesh() try: min_z = min((ev.matrix_world @ v.co).z for v in ev_me.vertices) finally: ev.to_mesh_clear() # no argument: clears this object's evaluated mesh obj.location.z -= min_z - 0.002 # 2 mm contact: grounded, not intersecting floor_me = bpy.data.meshes.new("Floor") bm = bmesh.new() try: bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0) bm.to_mesh(floor_me) finally: bm.free() fmat = bpy.data.materials.new("Studio") fmat.use_nodes = True fb = fmat.node_tree.nodes["Principled BSDF"] fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) fb.inputs["Roughness"].default_value = 0.7 floor_me.materials.append(fmat) floor = bpy.data.objects.new("Floor", floor_me) scene.collection.objects.link(floor) wall = bpy.data.objects.new("Wall", floor_me.copy()) wall.location = (0.0, 9.0, 0.0) wall.rotation_euler = (math.radians(90), 0.0, 0.0) scene.collection.objects.link(wall) world = bpy.data.worlds.new("World") world.use_nodes = True world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 1.0) scene.world = world def light(name, loc, energy, size, col, rot): ld = bpy.data.lights.new(name, 'AREA') ld.energy = energy; ld.size = size; ld.color = col ob = bpy.data.objects.new(name, ld) ob.location = loc ob.rotation_euler = tuple(math.radians(a) for a in rot) scene.collection.objects.link(ob) # default-stage rig per docs/VISUAL-STYLE.md; the white leather caps the # key energy so the pentagons' white neighbors never clip light("Key", (-4.0, -5.0, 6.0), 500.0, 5.0, (1.0, 0.96, 0.9), (48, 0, -35)) light("Fill", (5.0, -3.5, 2.5), 110.0, 9.0, (0.75, 0.85, 1.0), (65, 0, 50)) light("Rim", (3.0, 4.5, 5.0), 300.0, 4.0, (0.6, 0.78, 1.0), (-55, 0, 155)) light("Wedge", (2.5, 5.5, 4.0), 380.0, 6.0, (1.0, 0.76, 0.5), (-68, 0, 190)) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 55.0 cam = bpy.data.objects.new("Cam", cam_data) cam.location = (0.0, -7.2, 3.8) cam.rotation_euler = (math.radians(68), 0.0, 0.0) scene.collection.objects.link(cam) scene.camera = cam scene.render.engine = 'CYCLES' if engine == 'cycles' else eevee_engine_id() if engine == 'cycles': scene.cycles.samples = 32 else: try: scene.eevee.taa_render_samples = 64 except AttributeError: pass scene.render.resolution_x = 1280 scene.render.resolution_y = 720 scene.render.image_settings.file_format = 'PNG' scene.render.filepath = path # AgX would wash the white leather toward grey and lift the stage # (docs/VISUAL-STYLE.md); Standard is the house transform scene.view_settings.view_transform = 'Standard' bpy.ops.render.render(write_still=True) return os.path.exists(path) and os.path.getsize(path) > 0 def main(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] p = argparse.ArgumentParser() p.add_argument("--output", default=None, help="optional: render a still PNG here") p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"), help="render engine for --output (cycles for GPU-less hosts)") args = p.parse_args(argv) obj = build_ball() code = check(obj) if code: return code if args.output: if not render_still(obj, os.path.abspath(args.output), args.engine): print("ERROR: render produced no file", file=sys.stderr) return 6 print(f"rendered still {args.output}") print("soccer-ball-goldberg OK") return 0 if __name__ == "__main__": try: sys.exit(main()) except Exception as e: import traceback; traceback.print_exc(); print(f"FATAL: {e}", file=sys.stderr); sys.exit(1)