PL. 139 · LATTICES / PROJECTION / FOUR-POLYTOPE
Schlegel Diagram
Victor Schlegel, 1883 · the six figures: Ludwig Schläfli, 1850–1852
OPEN THE LIVE PLATE ▸DEFINITION
{3,3,3} {4,3,3} {3,3,4} {3,4,3} {3,3,5} {5,3,3}
cell centre n̂, h = n̂·x on that cell, eye at v = (h + ε)n̂
x ↦ s·(x − (n̂·x)n̂), s = ε/(h + ε − n̂·x)
one diagram while ε < h(sec γ − 1), γ = angle between adjacent cell centres
NOTES
Euclid closes the Elements by proving there are five regular solids and no more. In four dimensions there are six, and the sixth is the surprise. Ludwig Schläfli had the whole classification by 1852, in a manuscript so long that no journal would take it whole: two fragments appeared in translation in the 1850s and the rest of it waited until 1901, six years after he died. A figure in four dimensions cannot be drawn, so what Victor Schlegel drew in 1883 is a shadow of one, and he picked the most informative shadow there is. Put the eye just outside a single cell, near enough that the cell it looks through is the only one it can see, and project the entire figure onto the flat three-dimensional space that cell lies in. Everything lands inside that one cell. Every vertex, every edge and every cell survives with its incidences intact and nothing crosses anything it should not — provided the eye stays inside a limit, which is a real number, printed in the equation above, and different for each of the six. Past it the diagram folds over itself and stops being a diagram. This plate stays inside it by construction rather than by clipping: the dial is a fraction of whichever limit applies, so every position of it is a genuine Schlegel diagram of whichever figure is showing. How much room that leaves varies enormously. The 5-cell and the 8-cell have no limit at all and the eye can retreat as far as it likes; the 16-cell and the 24-cell can draw their far cell at a third of true size; the 120-cell at a tenth; and the 600-cell at under a fiftieth, which is why it reads as a huge tetrahedron with a dense knot inside it rather than as something evenly spread. That is not a shortcoming of the drawing, it is the figure being as tightly packed as it is. Five of the six answer to a solid you could hold: the 5-cell to the tetrahedron, the 8-cell to the cube, the 16-cell to the octahedron, the 120-cell to the dodecahedron, the 600-cell to the icosahedron. The 24-cell answers to nothing. It is the only regular figure in four dimensions or more with no counterpart in the dimension below, it is its own dual, its 24 vertices are exactly the roots of the lattice D4, and copies of it tile four-space the way cubes tile three. Nothing here is a copied coordinate table. The 5-cell is five equidistant points found in five-space and carried into four; the 8-cell is the sixteen sign patterns; the 16-cell is what falls out of reading that figure as a set of cell centres instead; the 24-cell is the D4 roots themselves; the 600-cell is the binary icosahedral group, closed under quaternion multiplication out of two rotations of an ordinary icosahedron and landing on exactly 120 elements without being told to; and the 120-cell is the set of centres of the 600 tetrahedra of that one. Edges are then found rather than listed, as the pairs at minimum separation, and the counts that come out are the counts Schläfli published. Plate 122 is the other four-dimensional object in this atlas and leaves four dimensions the other way, by a stereographic projection that is conformal and turns circles into circles. This projection is neither of those things. What it keeps instead is the whole incidence structure, unbroken, folded into a single cell.
PROVENANCE
- Origin
- V. Schlegel, "Theorie der homogen zusammengesetzten Raumgebilde", Nova Acta der Kaiserlich Leopoldinisch-Carolinischen Deutschen Akademie der Naturforscher 44 (Halle, 1883). Schlegel had physical models built from the same diagrams and described them in "Ueber Projectionsmodelle der regelmässigen vier-dimensionalen Körper" (Waren, 1886)
- The classification
- L. Schläfli, Theorie der vielfachen Kontinuität, written 1850–1852 and published posthumously in 1901 in the Denkschriften der Schweizerischen naturforschenden Gesellschaft 38. That the list of regular four-dimensional figures closes at six is his result and predates every rediscovery of it. Stringham found them again independently in 1880 and printed the first pictures: W. I. Stringham, "Regular Figures in n-Dimensional Space", American Journal of Mathematics 3(1), 1880, 1–14, doi:10.2307/2369441 — verified on Crossref against title, author, year and venue
- No source link
- Schlegel 1883 has no DOI, which is expected of an 1883 Leopoldina volume, and Crossref holds no record of it — searched and confirmed. Crossref does hold a Birkhäuser book record for Theorie der vielfachen Kontinuität dated 1901, but the 1901 publication was in the Denkschriften and the record carries no container title that would let this atlas tell the original from a later Birkhäuser edition. A citation that is nearly right is worse here than none, so no identifier is printed on the plate. The one identifier this atlas could verify outright, Stringham 1880, names the rediscovery rather than the plate, and sits in the provenance above rather than in the source field
- Standing
- Public domain — a classification from the 1850s and a projection from 1883, both long out of any copyright and neither patentable
- Constructed, not transcribed
- None of the six vertex sets is a coordinate list copied from anywhere; each is built from its own mathematics at run time. The 5-cell comes from the five standard basis vectors of five-space, which are equidistant by inspection, carried into the four-space orthogonal to (1,1,1,1,1) by Gram-Schmidt. The 8-cell is the sixteen points with every coordinate plus or minus one, and the 16-cell is its dual, which the same block produces because each of the two is the cell-centre set of the other. The 24-cell is the D4 root system, every vector with two coordinates plus or minus one and two zero; its own cell centres are the 16-cell and 8-cell directions taken together, which is the sense in which it is self-dual. The 600-cell is the binary icosahedral group of unit quaternions, closed under multiplication from just two generators — the quaternions of a 120 degree turn about a face axis and a 72 degree turn about a vertex axis of the icosahedron whose vertices are the cyclic permutations of (0, ±1, ±φ) — and the closure terminates at exactly 120 elements without being told to. The 120-cell is the set of centres of the 600 tetrahedral cells of that figure, which are found as the four-cliques of its own edge graph. Edges everywhere are the pairs at minimum separation, computed, never listed
- Checked, not just plotted
- Vertex and edge counts from those constructions, against the published f-vectors: 5 and 10 against 5 and 10; 16 and 32 against 16 and 32; 8 and 24 against 8 and 24; 24 and 96 against 24 and 96; 120 and 720 against 120 and 720; 600 and 1200 against 600 and 1200. Six out of six on both counts. Faces and cells were checked where cliques are the right instrument: the three figures with simplex cells give 10 triangles and 5 tetrahedra, 32 and 16, and 1200 and 600, matching their published f-vectors exactly, and the 24-cell gives its 96 triangular faces and correctly no tetrahedron at all. The 8-cell and the 120-cell contain no triangle whatever, which is the check that their edge sets are the right ones, since a wrong minimum separation would manufacture some. Every edge of every figure has the same length to 2.2e-16, and every vertex the same circumradius to 3.3e-16. The published numbers are tabulated in H. S. M. Coxeter, Regular Polytopes, 3rd edition, Dover, 1973, Table I(ii)
- The limit is derived, not clamped
- The eye has to sit beyond the chosen cell and beyond no other, or the shadow ceases to be a Schlegel diagram. Writing that condition out gives ε < h(sec γ − 1), where h is the distance from the centre of the figure to the cell and γ is the angle between two adjacent cell centres. The plate computes γ from the cell centres it constructed and gets 104.477512, 90, 60, 60, 15.522488 and 36.000000 degrees; 180 degrees minus the published dihedral angles of the six figures, in the same order, is 104.477512, 90, 60, 60, 15.522488 and 36.000000. Agreement to six decimals between two quantities computed from different things is the independent confirmation that these directions really are cell centres. h comes out 0.25, 0.5, 0.5, 0.7071068, 0.9256148 and 0.9256148 at unit circumradius, the last two agreeing because polar duals sharing a circumsphere share an insphere, and 0.9256148 checks a second way against the cell geometry alone: a regular tetrahedron of edge 1/φ, and equally a regular dodecahedron of edge 1/(φ²√2), has circumradius 0.3784670, and those two numbers square to 1. Two of the six have no finite limit at all: for the 5-cell and the 8-cell the eye never sees a second cell however far back it goes, and the diagram degenerates only in the limit of a parallel projection. The dial is therefore a fraction of whichever limit applies rather than a distance, and it stops short of 1 at both ends of its travel, so no reachable value renders a folded diagram. Nothing is clipped or clamped to achieve that: there is a guard in the code holding the far-cell scale inside 0.001 to 0.999, and it was checked to be unreachable rather than assumed to be — over the declared range the scale runs 0.0037 to 0.92, so the guard never fires and no part of the travel is spent against a stop
- Why the eye dial does nothing on the 5-cell
- The 5-cell has exactly one vertex that is not on the cell being looked through, and that vertex lies on the axis of the projection, so it lands on the centre of the cell for every viewpoint whatever. The eye dial therefore moves nothing when the 5-cell is selected. That is a fact about the simplex rather than a dead control: with two vertices off the cell it would move both. It is recorded here because a slider that does nothing is normally a defect in this atlas, and this one is not
- Constants
- poly indexes the six figures in the order Schläfli enumerated them and is drawn from CHOICES rather than jittered, since it is an index into a closed list rather than a quantity. eye is the viewpoint as a fraction of the strict limit above, and it moves the size everything inside the outer cell is drawn at. tilt is the elevation the finished three-dimensional diagram is viewed from — a camera, not a figure, which is why the Houdini port waives it. Nothing is locked. The 120-cell is the expensive one and it was measured rather than assumed: its whole construction plus a paint is 5.08 ms on a grid card and the steady paint after that is 2.84 ms, both inside the tier this plate is scheduled in
HOUDINI · VEX
The same published mathematics as a Detail Wrangle body. Paste it into a Wrangle with Run Over set to Detail; every constant is the published value plus a tweak channel, so Create Spare Parameters gives a slider that starts where the paper does.
// FORMA — PL. 139 · SCHLEGEL DIAGRAM — Victor Schlegel, 1883 · the six figures: Ludwig Schläfli, 1850–1852
// {3,3,3} {4,3,3} {3,3,4} {3,4,3} {3,3,5} {5,3,3}
// cell centre n̂, h = n̂·x on that cell, eye at v = (h + ε)n̂
// x ↦ s·(x − (n̂·x)n̂), s = ε/(h + ε − n̂·x)
// one diagram while ε < h(sec γ − 1), γ = angle between adjacent cell centres
// Paste into a Detail Wrangle (Run Over: Detail), no inputs needed.
// Written from the published mathematics, not adapted from any code.
// Constants arrive at their published values. Press the node's Create
// Spare Parameters button and every tweak becomes a slider — starting
// at 0, the published figure, and moving in the constant's own units.
// https://forma-gen.com/#plate=schlegel
float p_poly = 4 + chf('poly_tweak'); // figure — 1:5 2:8 3:16 4:24 5:600 6:120 cells · live 1 .. 6
float p_eye = 0.62 + chf('eye_tweak'); // eye, as a fraction of the Schlegel limit · live 0.2 .. 0.92
// The plate's own colour: FORMA's LATTICES accent as a cosine ramp,
// brightest near t = 0 and t = 1, near-black around t = 0.5.
vector forma_ramp(float t){
return set(
0.46 + 0.5 * cos(6.28318530718 * (t + 0)),
0.4185 + 0.4549 * cos(6.28318530718 * (t + 0.05)),
0.1389 + 0.151 * cos(6.28318530718 * (t + 0.1)));
}
// waived: tilt, azim — the elevation and azimuth the finished three-dimensional diagram is viewed from, and Houdini has a camera of its own
//
// A Schlegel diagram of one of the six regular four-dimensional figures: put
// the eye just outside a single cell, near enough that the cell it looks
// through is the only one it can see, and project the whole figure onto the
// flat three-space that cell lies in. Everything lands inside that one cell,
// with every vertex, edge and cell still in the right relation to every other.
//
// Nothing here is a coordinate table. The 5-cell is five equidistant points
// found in five dimensions and carried into four; the 8-cell is the sixteen
// sign patterns; the 16-cell is the eight axis directions; the 24-cell is the
// D4 root system; the 600-cell is the binary icosahedral group of unit
// quaternions, closed under multiplication out of two rotations of an ordinary
// icosahedron; and the 120-cell is the set of centres of the 600 tetrahedral
// cells of that one, which are the four-cliques of its own edge graph. Edges
// are the pairs at minimum separation, computed rather than listed. The counts
// that come out are 5/10, 16/32, 8/24, 24/96, 120/720 and 600/1200 vertices
// and edges, which are the published f-vectors of the six.
//
// Two things about the port. The plate turns the finished diagram slowly and
// views it from a fixed elevation; both are camera, so neither is emitted here
// — what this makes is the raw three-dimensional diagram, with the plate's
// screen vertical mapped onto Houdini's y, the z-up convention every 3-D curve
// in this corpus follows (catenoid.vex, lissajous3d.vex, hopf.vex). And the
// plate's alpha bands are a depth cue against ITS camera, so they are dropped
// too; what the colour carries instead is a fact about the figure rather than
// about the view — how deep into the diagram a vertex sits, from 0.82 of the
// ramp on the cell being looked through to 1.12 at the far end, inside the
// bright lobe at both ends. The cell being looked through is white, which is
// the contrast mark the plate gives it.
//
// Nothing is random. Two cooks give identical geometry.
float forma_dot4(vector4 a; vector4 b){
return a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w;
}
vector4 forma_unit4(vector4 v){
float r = sqrt(forma_dot4(v, v));
return v / r;
}
float forma_comp(vector4 v; int k){
if (k == 0) return v.x;
if (k == 1) return v.y;
if (k == 2) return v.z;
return v.w;
}
// one coordinate axis, signed — the building block of the 16-cell, the 24-cell
// and the Gram-Schmidt seeds, written without component indexing
vector4 forma_axis(int k; float s){
return set(k == 0 ? s : 0.0, k == 1 ? s : 0.0,
k == 2 ? s : 0.0, k == 3 ? s : 0.0);
}
vector4 forma_qmul(vector4 a; vector4 b){
return set(a.x * b.x - a.y * b.y - a.z * b.z - a.w * b.w,
a.x * b.y + a.y * b.x + a.z * b.w - a.w * b.z,
a.x * b.z - a.y * b.w + a.z * b.x + a.w * b.y,
a.x * b.w + a.y * b.z - a.z * b.y + a.w * b.x);
}
// Five equidistant points in four-space. The five standard basis vectors of
// five-space are equidistant by inspection and span the hyperplane summing to
// one; centring them and re-expressing them in an orthonormal basis of the
// four-space orthogonal to (1,1,1,1,1) lands them in four dimensions with
// every pairwise distance still equal. No vector5 type exists, so the
// five-component work is done on flat float arrays.
function vector4[] forma_simplex5(){
float raw[];
resize(raw, 25);
for (int i = 0; i < 5; i++)
for (int k = 0; k < 5; k++)
raw[i * 5 + k] = (i == k) ? 0.8 : -0.2;
float bas[];
resize(bas, 20);
for (int i = 0; i < 4; i++){
float u[];
resize(u, 5);
for (int k = 0; k < 5; k++) u[k] = raw[i * 5 + k];
for (int b = 0; b < i; b++){
float d = 0.0;
for (int k = 0; k < 5; k++) d += u[k] * bas[b * 5 + k];
for (int k = 0; k < 5; k++) u[k] -= d * bas[b * 5 + k];
}
float r = 0.0;
for (int k = 0; k < 5; k++) r += u[k] * u[k];
r = sqrt(r);
for (int k = 0; k < 5; k++) bas[i * 5 + k] = u[k] / r;
}
vector4 out[];
for (int i = 0; i < 5; i++){
float c[];
resize(c, 4);
for (int b = 0; b < 4; b++){
float d = 0.0;
for (int k = 0; k < 5; k++) d += raw[i * 5 + k] * bas[b * 5 + k];
c[b] = d;
}
append(out, forma_unit4(set(c[0], c[1], c[2], c[3])));
}
return out;
}
// the 8-cell: every coordinate plus or minus one. Bit tests written as integer
// division and remainder, since VEX has no shift operator.
function vector4[] forma_cube16(){
vector4 out[];
for (int m = 0; m < 16; m++){
float a = (m % 2 == 1) ? 1.0 : -1.0;
float b = ((m / 2) % 2 == 1) ? 1.0 : -1.0;
float c = ((m / 4) % 2 == 1) ? 1.0 : -1.0;
float d = ((m / 8) % 2 == 1) ? 1.0 : -1.0;
append(out, forma_unit4(set(a, b, c, d)));
}
return out;
}
// the 16-cell: the eight signed axis directions
function vector4[] forma_cross8(){
vector4 out[];
for (int i = 0; i < 4; i++){
append(out, forma_axis(i, 1.0));
append(out, forma_axis(i, -1.0));
}
return out;
}
// the 24-cell: the D4 root system, every vector with two coordinates plus or
// minus one and two zero. That IS the root system; nothing is chosen.
function vector4[] forma_roots24(){
vector4 out[];
for (int i = 0; i < 4; i++)
for (int j = i + 1; j < 4; j++)
for (int a = 0; a < 2; a++)
for (int b = 0; b < 2; b++){
float sa = (a == 0) ? 1.0 : -1.0;
float sb = (b == 0) ? 1.0 : -1.0;
append(out, forma_unit4(forma_axis(i, sa) + forma_axis(j, sb)));
}
return out;
}
// the 600-cell: the binary icosahedral group of unit quaternions. Take the
// icosahedron whose twelve vertices are the cyclic permutations of
// (0, +-1, +-phi). A 120 degree turn about the face axis (1,1,1) and a 72
// degree turn about the vertex axis (0, 1, phi) are both symmetries of it, and
// a rotation by angle th about a unit axis is cos(th/2) + sin(th/2) times that
// axis. The first is (1+i+j+k)/2 outright; for the second cos 36 is phi/2 and
// sin 36 divided by sqrt(phi+2) collapses to 1/(2 phi). Closing the pair under
// multiplication terminates on its own at 120 elements, which is the order of
// the group and the vertex count of the figure at the same time. Membership is
// tested by squared distance because the closure has to compare quaternions
// that were reached by different products.
function vector4[] forma_icosians(){
float phi = (1.0 + sqrt(5.0)) / 2.0;
// set() of four floats is ambiguous between vector4 and matrix2 when it
// has no typed context to resolve against, and array() arguments give it
// none -- the cook names both candidates. Typed temporaries decide it.
vector4 forma_gen1 = set(0.5, 0.5, 0.5, 0.5);
vector4 forma_gen2 = set(phi / 2.0, 0.0, 1.0 / (2.0 * phi), 0.5);
vector4 gens[] = array(forma_gen1, forma_gen2);
vector4 G[];
append(G, set(1.0, 0.0, 0.0, 0.0));
int i = 0;
while (i < len(G)){
for (int g = 0; g < 2; g++){
vector4 q = forma_qmul(G[i], gens[g]);
int found = 0;
for (int k = 0; k < len(G); k++){
vector4 d = q - G[k];
if (forma_dot4(d, d) < 1e-8){ found = 1; break; }
}
if (found == 0) append(G, q);
}
i++;
}
return G;
}
// Edges as the pairs at minimum separation. The tolerance is not delicate:
// across all six figures the next distinct squared distance is at least twice
// the smallest (measured: 2.0, 2.0, 2.0, 2.618, 2.618, and the 5-cell has only
// one distance at all), so a part in ten thousand is four orders of magnitude
// inside the gap and safe in 32-bit float.
function int[] forma_edges(vector4 P[]){
int n = len(P);
float best = 1e18;
for (int i = 0; i < n; i++)
for (int j = i + 1; j < n; j++){
vector4 d = P[i] - P[j];
float q = forma_dot4(d, d);
if (q < best) best = q;
}
int E[];
for (int i = 0; i < n; i++)
for (int j = i + 1; j < n; j++){
vector4 d = P[i] - P[j];
float q = forma_dot4(d, d);
if (q < best * 1.0001){
append(E, i);
append(E, j);
}
}
return E;
}
// The cells of the 600-cell are tetrahedra, so four vertices form one exactly
// when all six of their pairs are edges: they are the four-cliques of the edge
// graph and need no face list. Their centroids, put back on the sphere, are
// the cell centres — and those 600 directions are the vertices of the 120-cell.
// Neighbour lists are a flat array of stride equal to the largest degree
// found, rather than a hard-coded twelve, so nothing here assumes the answer.
function vector4[] forma_cells600(vector4 G[]; int E[]){
int n = len(G);
int adj[];
resize(adj, n * n);
int deg[];
resize(deg, n);
for (int i = 0; i < n * n; i++) adj[i] = 0;
for (int i = 0; i < n; i++) deg[i] = 0;
for (int e = 0; e < len(E); e += 2){
int ea = E[e];
int eb = E[e + 1];
adj[ea * n + eb] = 1;
adj[eb * n + ea] = 1;
deg[ea] += 1;
deg[eb] += 1;
}
int md = 0;
for (int i = 0; i < n; i++) if (deg[i] > md) md = deg[i];
int nbr[];
resize(nbr, n * md);
int fill[];
resize(fill, n);
for (int i = 0; i < n; i++) fill[i] = 0;
for (int e = 0; e < len(E); e += 2){
int ea = E[e];
int eb = E[e + 1];
nbr[ea * md + fill[ea]] = eb;
fill[ea] += 1;
nbr[eb * md + fill[eb]] = ea;
fill[eb] += 1;
}
vector4 out[];
for (int i = 0; i < n; i++)
for (int a = 0; a < deg[i]; a++){
int j = nbr[i * md + a];
if (j <= i) continue;
for (int b = 0; b < deg[j]; b++){
int k = nbr[j * md + b];
if (k <= j) continue;
if (adj[i * n + k] == 0) continue;
for (int c = 0; c < deg[k]; c++){
int l = nbr[k * md + c];
if (l <= k) continue;
if (adj[i * n + l] == 0) continue;
if (adj[j * n + l] == 0) continue;
append(out, forma_unit4((G[i] + G[j] + G[k] + G[l]) / 4.0));
}
}
}
return out;
}
// ---------------------------------------------------------------------------
int forma_which = clamp(int(rint(p_poly)), 1, 6);
vector4 forma_V[];
vector4 forma_N[];
if (forma_which == 1){
// self-dual, and the duality is in the coordinates: the five vertices sum
// to zero, so the cell opposite a vertex has centroid minus that vertex
// over four, and the cell centres are the vertices negated
forma_V = forma_simplex5();
for (int i = 0; i < len(forma_V); i++) append(forma_N, -forma_V[i]);
} else if (forma_which == 2){
// the 8-cell, whose eight cubic cells sit at the signed axis directions
forma_V = forma_cube16();
forma_N = forma_cross8();
} else if (forma_which == 3){
// the 16-cell, whose sixteen tetrahedral cells are the sign orthants
forma_V = forma_cross8();
forma_N = forma_cube16();
} else if (forma_which == 4){
// the 24-cell. Its cell centres are the axis directions and the
// (+-1,+-1,+-1,+-1)/2 directions together, twenty-four in all, each
// supporting exactly the six roots of an octahedral cell. That set is
// another 24-cell, which is the sense in which this figure is its own dual
forma_V = forma_roots24();
forma_N = forma_cross8();
vector4 forma_half[] = forma_cube16();
for (int i = 0; i < len(forma_half); i++) append(forma_N, forma_half[i]);
} else {
vector4 forma_G[] = forma_icosians();
int forma_GE[] = forma_edges(forma_G);
vector4 forma_C[] = forma_cells600(forma_G, forma_GE);
if (forma_which == 5){
forma_V = forma_G;
forma_N = forma_C;
} else {
forma_V = forma_C;
forma_N = forma_G;
}
}
int forma_E[] = forma_edges(forma_V);
int forma_NV = len(forma_V);
// The cell being looked through, and how far the figure runs back from it.
vector4 forma_n = forma_N[0];
float forma_h = -1e18;
float forma_tmin = 1e18;
for (int i = 0; i < forma_NV; i++){
float d = forma_dot4(forma_n, forma_V[i]);
if (d > forma_h) forma_h = d;
if (d < forma_tmin) forma_tmin = d;
}
float forma_D = forma_h - forma_tmin;
// The eye sits at (h + eps) times the cell normal and must be beyond this cell
// and beyond no other, which for a regular figure — every cell at the same h —
// is (h + eps) cos(gamma) <= h for the nearest other cell centre, so
// eps <= h(sec(gamma) - 1). When no other cell centre is in front of the eye
// the limit is infinite: that is the 5-cell and the 8-cell, whose diagrams
// degenerate only in the limit of a parallel projection. The constant is a
// fraction of whichever limit applies, so a folded diagram is unreachable
// rather than clipped away.
float forma_cg = -1.0;
for (int i = 0; i < len(forma_N); i++){
float d = forma_dot4(forma_n, forma_N[i]);
if (d < 0.999999 && d > forma_cg) forma_cg = d;
}
float forma_klim = 1.0;
if (forma_cg > 1e-6){
float epsmax = forma_h * (1.0 / forma_cg - 1.0);
forma_klim = epsmax / (epsmax + forma_D);
}
float forma_kap = clamp(p_eye * forma_klim, 0.001, 0.999);
float forma_eps = forma_kap * forma_D / (1.0 - forma_kap);
// An orthonormal basis of the cell hyperplane, from the three coordinate axes
// least aligned with the cell normal. Any basis will do and this one is
// deterministic.
int forma_drop = 0;
for (int k = 1; k < 4; k++)
if (abs(forma_comp(forma_n, k)) > abs(forma_comp(forma_n, forma_drop)))
forma_drop = k;
vector4 forma_B[];
for (int k = 0; k < 4; k++){
if (k == forma_drop) continue;
if (len(forma_B) >= 3) continue;
vector4 u = forma_axis(k, 1.0);
u -= forma_dot4(u, forma_n) * forma_n;
for (int b = 0; b < len(forma_B); b++) u -= forma_dot4(u, forma_B[b]) * forma_B[b];
append(forma_B, forma_unit4(u));
}
// Project. The component along the cell normal is h for every image point, so
// it carries nothing and is dropped: what is left is s times the part of the
// vertex lying in the cell hyperplane, three numbers.
float forma_pa[];
float forma_pb[];
float forma_pc[];
float forma_tone[];
int forma_on[];
resize(forma_pa, forma_NV);
resize(forma_pb, forma_NV);
resize(forma_pc, forma_NV);
resize(forma_tone, forma_NV);
resize(forma_on, forma_NV);
for (int i = 0; i < forma_NV; i++){
float tt = forma_dot4(forma_n, forma_V[i]);
float s = forma_eps / (forma_h + forma_eps - tt);
forma_pa[i] = s * forma_dot4(forma_B[0], forma_V[i]);
forma_pb[i] = s * forma_dot4(forma_B[1], forma_V[i]);
forma_pc[i] = s * forma_dot4(forma_B[2], forma_V[i]);
forma_on[i] = (tt > forma_h - 1e-6) ? 1 : 0;
// how deep into the diagram this vertex sits: 0 on the cell being looked
// through, 1 at the far end
forma_tone[i] = 0.82 + 0.30 * (forma_h - tt) / forma_D;
}
// One polyline per edge. The plate's screen vertical is the third basis
// direction, so it goes onto Houdini's y.
for (int e = 0; e < len(forma_E); e += 2){
int ea = forma_E[e];
int eb = forma_E[e + 1];
int outer = forma_on[ea] * forma_on[eb];
int prim = addprim(0, "polyline");
for (int q = 0; q < 2; q++){
int vi = (q == 0) ? ea : eb;
int pt = addpoint(0, set(forma_pa[vi], forma_pc[vi], forma_pb[vi]));
vector col = (outer == 1) ? set(1.0, 1.0, 1.0) : forma_ramp(forma_tone[vi]);
setpointattrib(0, "Cd", pt, col);
addvertex(0, prim, pt);
}
}
AFTER EFFECTS · EXPRESSION
The same published mathematics as a Shape Layer path expression. Paste it onto a Path property; every constant is the published value plus a Slider Control named
// FORMA — PL. 139 · SCHLEGEL DIAGRAM — Victor Schlegel, 1883 · the six figures: Ludwig Schläfli, 1850–1852
// {3,3,3} {4,3,3} {3,3,4} {3,4,3} {3,3,5} {5,3,3}
// cell centre n̂, h = n̂·x on that cell, eye at v = (h + ε)n̂
// x ↦ s·(x − (n̂·x)n̂), s = ε/(h + ε − n̂·x)
// one diagram while ε < h(sec γ − 1), γ = angle between adjacent cell centres
// After Effects port — paste onto a Shape Layer's Path property
// (Contents › Shape › Path). Written from the published mathematics, not
// adapted from any code. Constants arrive at their published values; add a
// Slider Control (Effect › Expression Controls) named <k>_tweak and that
// constant moves in its own units, starting at 0 — the published figure.
// The plate's comet and its reveal are Trim Paths; the stroke colour is
// FORMA's LATTICES accent, #FFE84D. Animation runs on time.
// This plate draws 2 separate paths at its published constants:
// duplicate the group (Contents › Group) that many times and each copy draws
// its own part, read from its position in the layer. A Slider Control named
// "part" on the layer pins one instead.
// https://forma-gen.com/#plate=schlegel
// A missing slider reads 0, so a bare paste already draws the figure.
function forma_tweak(n){ try { return effect(n)("Slider"); } catch (e){ return 0; } }
var p_poly = 4 + forma_tweak("poly_tweak"); // figure — 1:5 2:8 3:16 4:24 5:600 6:120 cells · live 1 .. 6
var p_eye = 0.62 + forma_tweak("eye_tweak"); // eye, as a fraction of the Schlegel limit · live 0.2 .. 0.92
var p_tilt = 32 + forma_tweak("tilt_tweak"); // elevation (deg) · live 12 .. 78
var p_azim = 0 + forma_tweak("azim_tweak"); // view azimuth ° · live -180 .. 180
// The frame: the plate's W × H canvas is this comp, with the origin at the
// layer's anchor; canvas y already runs down, as After Effects' does.
var forma_W = thisComp.width, forma_H = thisComp.height, forma_t = time;
var forma_phase = 0.3612630639690906; // this plate's own fixed phase, as the page has it
function forma_pt(x, y){ return [x - forma_W / 2, y - forma_H / 2]; }
function forma_partIndex(){
try { return Math.round(effect("part")("Slider")); } catch (e){}
try { return thisProperty.propertyGroup(3).propertyIndex - 1; } catch (e){ return 0; }
}
var forma_part = forma_partIndex();
// One path through a connected set of segments (each [x0, y0, x1, y1]):
// vertices within eps are one vertex, a vertex on the inside of a segment
// splits it, duplicate segments are one segment, and a depth-first walk takes
// every segment out and back, so each is drawn on itself and the pen never
// leaves the figure.
function forma_graphPath(segs, eps){
var cell = eps * 4, verts = [], grid = {}, edges = [], ekey = {};
function vid(x, y){
var gx = Math.floor(x / cell), gy = Math.floor(y / cell);
for (var i = -1; i <= 1; i++) for (var j = -1; j <= 1; j++){
var list = grid[(gx + i) + "," + (gy + j)];
if (!list) continue;
for (var k = 0; k < list.length; k++){
var v = verts[list[k]];
if (Math.abs(v[0] - x) <= eps && Math.abs(v[1] - y) <= eps) return list[k];
}
}
var id = verts.length;
verts.push([x, y, []]);
(grid[gx + "," + gy] = grid[gx + "," + gy] || []).push(id);
return id;
}
for (var e = 0; e < segs.length; e++) edges.push([vid(segs[e][0], segs[e][1]), vid(segs[e][2], segs[e][3])]);
// T-junctions: a vertex on the inside of an edge splits it there
var split = [];
for (var g = 0; g < edges.length; g++){
var A = verts[edges[g][0]], B = verts[edges[g][1]], dx = B[0] - A[0], dy = B[1] - A[1], L2 = dx * dx + dy * dy;
var on = [];
if (L2 > eps * eps){
var lo0 = Math.min(A[0], B[0]) - eps, hi0 = Math.max(A[0], B[0]) + eps;
var lo1 = Math.min(A[1], B[1]) - eps, hi1 = Math.max(A[1], B[1]) + eps;
for (var w = 0; w < verts.length; w++){
var V = verts[w];
if (w === edges[g][0] || w === edges[g][1] || V[0] < lo0 || V[0] > hi0 || V[1] < lo1 || V[1] > hi1) continue;
var u = ((V[0] - A[0]) * dx + (V[1] - A[1]) * dy) / L2;
if (u <= 0 || u >= 1) continue;
var px = A[0] + u * dx - V[0], py = A[1] + u * dy - V[1];
if (px * px + py * py <= eps * eps) on.push([u, w]);
}
}
on.sort(function (p, q){ return p[0] - q[0]; });
var prev = edges[g][0];
for (var o = 0; o < on.length; o++){ split.push([prev, on[o][1]]); prev = on[o][1]; }
split.push([prev, edges[g][1]]);
}
var used = [];
for (var s = 0; s < split.length; s++){
var a = split[s][0], b = split[s][1];
if (a === b) continue;
var key = a < b ? a + ":" + b : b + ":" + a;
if (ekey[key]) continue;
ekey[key] = 1;
verts[a][2].push([b, used.length]); verts[b][2].push([a, used.length]);
used.push(0);
}
var out = [];
for (var st = 0; st < verts.length; st++){
var fresh = false;
for (var q = 0; q < verts[st][2].length; q++) if (!used[verts[st][2][q][1]]) fresh = true;
if (!fresh) continue;
out.push([verts[st][0], verts[st][1]]);
var stack = [[st, 0]];
while (stack.length){
var top = stack[stack.length - 1], adj = verts[top[0]][2], found = -1;
while (top[1] < adj.length){
var ed = adj[top[1]++];
if (!used[ed[1]]){ used[ed[1]] = 1; found = ed[0]; break; }
}
if (found >= 0){ out.push([verts[found][0], verts[found][1]]); stack.push([found, 0]); }
else { stack.pop(); if (stack.length){ var bk = verts[stack[stack.length - 1][0]]; out.push([bk[0], bk[1]]); } }
}
}
return out;
}
// Schlegel diagrams of the six regular 4-polytopes, built as the page builds
// them: the 5-cell from the basis of five-space centred and dropped into
// four; the 8-cell (±1)⁴ and the 16-cell ±eᵢ, each the other's cell
// centres; the 24-cell as the D₄ roots; the 600-cell as the 120 icosians —
// the binary icosahedral group generated by two of its rotations — and the
// 120-cell as the centres of its 600 tetrahedral cells, found as the
// four-cliques of its edge graph. Edges are the nearest pairs. The eye sits
// `eye` of the way to the limit beyond which another cell folds in front of
// the one looked through, the projection drops the direction of the eye,
// and the finished diagram tumbles at 0.055 rad/s from this plate's phase at
// the elevation on the slider, inside a frame fitted over the whole turn.
// A diagram is a connected graph, so one path: part 0 walks every edge but
// the outer cell's, depth first, each out and back; part 1 walks the outer
// cell, the page's contrast mark. The depth banding is the stroke's.
// parts: 2
var TAU = 6.283185307179586, DEG = Math.PI / 180;
var which = Math.max(1, Math.min(6, Math.round(p_poly)));
function forma_dot4(a, b){ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]; }
function forma_unit(v){ var r = Math.sqrt(forma_dot4(v, v)); return [v[0] / r, v[1] / r, v[2] / r, v[3] / r]; }
function forma_minEdges(P){
var n = P.length, E = [], best = Infinity;
for (var i = 0; i < n; i++) for (var j = i + 1; j < n; j++){
var d = 0;
for (var k = 0; k < 4; k++){ var u = P[i][k] - P[j][k]; d += u * u; }
if (d < best * 0.999999){ best = d; E = [i, j]; }
else if (d < best * 1.000001) E.push(i, j);
}
return E;
}
var V = null, N = null, i, j, k, m;
if (which === 1){
var raw = [];
for (i = 0; i < 5; i++){ var v5 = [-0.2, -0.2, -0.2, -0.2, -0.2]; v5[i] += 1; raw.push(v5); }
var basis = [];
for (i = 0; i < 4; i++){
var u5 = raw[i].slice();
for (j = 0; j < basis.length; j++){
var dd = 0;
for (k = 0; k < 5; k++) dd += u5[k] * basis[j][k];
for (k = 0; k < 5; k++) u5[k] -= dd * basis[j][k];
}
var rr = 0;
for (k = 0; k < 5; k++) rr += u5[k] * u5[k];
rr = Math.sqrt(rr);
for (k = 0; k < 5; k++) u5[k] /= rr;
basis.push(u5);
}
V = [];
for (i = 0; i < 5; i++){
var w4 = [];
for (j = 0; j < 4; j++){ var s5 = 0; for (k = 0; k < 5; k++) s5 += raw[i][k] * basis[j][k]; w4.push(s5); }
V.push(forma_unit(w4));
}
N = [];
for (i = 0; i < 5; i++) N.push([-V[i][0], -V[i][1], -V[i][2], -V[i][3]]);
} else if (which === 2 || which === 3){
var cube = [], cross = [];
for (m = 0; m < 16; m++) cube.push(forma_unit([(m & 1) ? 1 : -1, (m & 2) ? 1 : -1, (m & 4) ? 1 : -1, (m & 8) ? 1 : -1]));
for (i = 0; i < 4; i++){ var vp = [0, 0, 0, 0]; vp[i] = 1; cross.push(vp); var vm = [0, 0, 0, 0]; vm[i] = -1; cross.push(vm); }
if (which === 2){ V = cube; N = cross; } else { V = cross; N = cube; }
} else if (which === 4){
V = []; N = [];
for (i = 0; i < 4; i++) for (j = i + 1; j < 4; j++) for (var a4 = 1; a4 >= -1; a4 -= 2) for (var b4 = 1; b4 >= -1; b4 -= 2){
var r4 = [0, 0, 0, 0]; r4[i] = a4; r4[j] = b4; V.push(forma_unit(r4));
}
for (i = 0; i < 4; i++){ var np = [0, 0, 0, 0]; np[i] = 1; N.push(np); var nm = [0, 0, 0, 0]; nm[i] = -1; N.push(nm); }
for (m = 0; m < 16; m++) N.push(forma_unit([(m & 1) ? 1 : -1, (m & 2) ? 1 : -1, (m & 4) ? 1 : -1, (m & 8) ? 1 : -1]));
} else {
var PHI = (1 + Math.sqrt(5)) / 2;
function forma_qmul(a, b){
return [a[0] * b[0] - a[1] * b[1] - a[2] * b[2] - a[3] * b[3],
a[0] * b[1] + a[1] * b[0] + a[2] * b[3] - a[3] * b[2],
a[0] * b[2] - a[1] * b[3] + a[2] * b[0] + a[3] * b[1],
a[0] * b[3] + a[1] * b[2] - a[2] * b[1] + a[3] * b[0]];
}
var gens = [[0.5, 0.5, 0.5, 0.5], [PHI / 2, 0, 1 / (2 * PHI), 0.5]];
function forma_qkey(q){ var o = []; for (var z = 0; z < 4; z++) o.push((Math.abs(q[z]) < 1e-6 ? 0 : q[z]).toFixed(6)); return o.join(','); }
var G = [[1, 0, 0, 0]], seen = {};
seen[forma_qkey(G[0])] = 1;
for (i = 0; i < G.length; i++) for (j = 0; j < 2; j++){
var rq = forma_qmul(G[i], gens[j]), kq = forma_qkey(rq);
if (!seen[kq]){ seen[kq] = 1; G.push(rq); }
}
var E0 = forma_minEdges(G), NG = G.length, A = {}, nb = [];
for (i = 0; i < NG; i++) nb.push([]);
for (var e0 = 0; e0 < E0.length; e0 += 2){
var ia = E0[e0], ib = E0[e0 + 1];
A[ia * NG + ib] = 1; A[ib * NG + ia] = 1; nb[ia].push(ib); nb[ib].push(ia);
}
var C = [];
for (i = 0; i < NG; i++) for (var ji = 0; ji < nb[i].length; ji++){
j = nb[i][ji];
if (j <= i) continue;
for (var ki = 0; ki < nb[j].length; ki++){
k = nb[j][ki];
if (k <= j || !A[i * NG + k]) continue;
for (var li = 0; li < nb[k].length; li++){
var l = nb[k][li];
if (l <= k || !A[i * NG + l] || !A[j * NG + l]) continue;
var cc = [0, 0, 0, 0], four = [i, j, k, l];
for (var f = 0; f < 4; f++) for (var d4 = 0; d4 < 4; d4++) cc[d4] += G[four[f]][d4] / 4;
C.push(forma_unit(cc));
}
}
}
if (which === 5){ V = G; N = C; } else { V = C; N = G; }
}
var E = forma_minEdges(V), NV = V.length, nrm = N[0];
var h = -Infinity, tmin = Infinity;
for (i = 0; i < NV; i++){ var dv = forma_dot4(nrm, V[i]); if (dv > h) h = dv; if (dv < tmin) tmin = dv; }
var D = h - tmin, cg = -1;
for (i = 0; i < N.length; i++){ var dn = forma_dot4(nrm, N[i]); if (dn < 0.999999 && dn > cg) cg = dn; }
var epsMax = cg > 1e-9 ? h * (1 / cg - 1) : Infinity;
var kLim = isFinite(epsMax) ? epsMax / (epsMax + D) : 1;
var kap = Math.max(0.001, Math.min(0.999, p_eye * kLim)), eps = kap * D / (1 - kap);
var B = [], drop = 0;
for (k = 1; k < 4; k++) if (Math.abs(nrm[k]) > Math.abs(nrm[drop])) drop = k;
for (k = 0; k < 4 && B.length < 3; k++){
if (k === drop) continue;
var ub = [0, 0, 0, 0]; ub[k] = 1;
var db = forma_dot4(ub, nrm);
for (m = 0; m < 4; m++) ub[m] -= db * nrm[m];
for (j = 0; j < B.length; j++){ db = forma_dot4(ub, B[j]); for (m = 0; m < 4; m++) ub[m] -= db * B[j][m]; }
B.push(forma_unit(ub));
}
var P3 = [], onCell = [], fitH = 1e-9, rad = Math.sqrt(Math.max(1e-9, 1 - h * h));
for (i = 0; i < NV; i++){
var tt = forma_dot4(nrm, V[i]), s3 = eps / (h + eps - tt);
P3.push([s3 * forma_dot4(B[0], V[i]), s3 * forma_dot4(B[1], V[i]), s3 * forma_dot4(B[2], V[i])]);
onCell.push(tt > h - 1e-9 ? 1 : 0);
var axr = Math.sqrt(P3[i][0] * P3[i][0] + P3[i][1] * P3[i][1]);
if (axr > fitH) fitH = axr;
}
var rot = forma_t * 0.055 + forma_phase * TAU + p_azim * DEG, co = Math.cos(rot), si = Math.sin(rot);
var el = p_tilt * DEG, ce = Math.cos(el), se = Math.sin(el), fv = 1e-9;
for (i = 0; i < NV; i++){
var qv = Math.abs(P3[i][2]) * ce + Math.sqrt(P3[i][0] * P3[i][0] + P3[i][1] * P3[i][1]) * se;
if (qv > fv) fv = qv;
}
var sc = Math.min(forma_W * 0.44 / fitH, forma_H * 0.44 / fv), cx = forma_W / 2, cy = forma_H / 2;
var sx = [], sy = [];
for (i = 0; i < NV; i++){
var X = P3[i][0] * co + P3[i][1] * si, Dh = -P3[i][0] * si + P3[i][1] * co;
sx.push(cx + X * sc); sy.push(cy - (P3[i][2] * ce - Dh * se) * sc);
}
var segs = [], outer = forma_part === 1;
for (var e = 0; e < E.length; e += 2){
var ei = E[e], ej = E[e + 1], onOuter = !!(onCell[ei] && onCell[ej]);
if (onOuter !== outer) continue;
segs.push([sx[ei], sy[ei], sx[ej], sy[ej]]);
}
var walk = forma_graphPath(segs, 0.001), pts = [];
for (var w = 0; w < walk.length; w++) pts.push(forma_pt(walk[w][0], walk[w][1]));
createPath(pts, [], [], false);