Compare commits

..

No commits in common. "main" and "broken-cursed-links" have entirely different histories.

13 changed files with 138 additions and 1818 deletions

View File

@ -1,17 +0,0 @@
CHANGELOG
=========
## v1.1 - 1/1/2026
The 120-cell now includes a visualisation of its inscribed 5-cells, which honestly
looks like less of a mess than I expected it to.
## v1.0 - 16/11/2025
It's been [two years](https://mikelynch.org/2023/Sep/02/120-cell/)</a> since
I first made this, and I haven't updated it in a while, but I got tapered links to
work without too much performance overhead, so that seemed worth a version.
The results flicker a bit at low opacities but otherwise I'm pretty happy with
it.
`

View File

@ -1,26 +0,0 @@
# NOTES
New approach for the 5-cells:
Pick a tetrahedron of an inscribed 600-cell with vertices A, B, C, D
This gives pairs of vertices:
AB
AC
AD
BC
BD
CD
Each of these gives rise to seven pairs of 5-cells which are on neighboring vertices
of the 5 600-cells.
Try enumerating these and inspecting them to find one or more coherent sets of four
5-cells which lie on one tetrahedron from each of the 600-cells.
(I expect there to be more than one, like how there are two ways to partition the
120-cell vertices into 600-cells)

View File

@ -104,855 +104,6 @@ export const LAYERS120 = {
163,219,271,223,167]
};
export const CELL120_CELL5 = {
"tetras": {
},
"cell5s": {
"1": [
27,
28,
264,
309,
275
],
"2": [
223,
76,
238,
84,
225
],
"3": [
253,
44,
283,
304,
42
],
"4": [
419,
112,
197,
578,
521
],
"5": [
339,
14,
384,
382,
337
],
"6": [
331,
4,
335,
390,
386
],
"7": [
427,
160,
551,
146,
557
],
"8": [
265,
60,
64,
295,
246
],
"9": [
473,
100,
495,
213,
462
],
"10": [
393,
6,
328,
397,
326
],
"11": [
539,
164,
439,
561,
142
],
"12": [
511,
122,
456,
595,
181
],
"13": [
555,
154,
152,
545,
429
],
"14": [
95,
202,
486,
500,
465
],
"15": [
471,
208,
502,
484,
89
],
"16": [
347,
21,
348,
374,
373
],
"17": [
487,
203,
94,
468,
497
],
"18": [
165,
139,
542,
568,
434
],
"19": [
367,
18,
355,
368,
353
],
"20": [
231,
78,
86,
236,
217
],
"21": [
356,
17,
366,
354,
365
],
"22": [
503,
205,
470,
92,
481
],
"23": [
527,
106,
584,
195,
421
],
"24": [
239,
73,
222,
228,
81
],
"25": [
543,
138,
168,
435,
565
],
"26": [
48,
46,
302,
281,
255
],
"27": [
248,
62,
293,
58,
267
],
"28": [
440,
141,
562,
540,
163
],
"29": [
274,
30,
312,
261,
29
],
"30": [
179,
128,
597,
450,
505
],
"31": [
376,
22,
375,
346,
345
],
"32": [
320,
11,
405,
401,
319
],
"33": [
448,
173,
130,
587,
519
],
"34": [
460,
102,
211,
489,
479
],
"35": [
388,
2,
392,
329,
333
],
"36": [
512,
182,
596,
121,
455
],
"37": [
592,
170,
516,
443,
133
],
"38": [
120,
189,
529,
570,
411
],
"39": [
420,
198,
577,
522,
111
],
"40": [
272,
69,
65,
290,
243
],
"41": [
488,
93,
498,
204,
467
],
"42": [
156,
150,
547,
553,
431
],
"43": [
252,
53,
286,
297,
55
],
"44": [
532,
192,
117,
410,
571
],
"45": [
400,
7,
321,
396,
323
],
"46": [
580,
199,
417,
110,
523
],
"47": [
296,
63,
245,
266,
59
],
"48": [
600,
125,
178,
508,
451
],
"49": [
68,
72,
269,
242,
291
],
"50": [
324,
8,
399,
322,
395
],
"51": [
499,
96,
485,
466,
201
],
"52": [
406,
12,
317,
318,
402
],
"53": [
563,
144,
437,
162,
537
],
"54": [
234,
88,
219,
229,
80
],
"55": [
350,
24,
349,
371,
372
],
"56": [
444,
134,
515,
169,
591
],
"57": [
258,
40,
39,
277,
307
],
"58": [
285,
56,
251,
54,
298
],
"59": [
546,
151,
153,
430,
556
],
"60": [
98,
215,
475,
493,
464
],
"61": [
474,
214,
99,
461,
496
],
"62": [
357,
20,
363,
359,
364
],
"63": [
490,
212,
459,
101,
480
],
"64": [
185,
116,
415,
533,
574
],
"65": [
378,
16,
343,
341,
380
],
"66": [
218,
85,
235,
77,
232
],
"67": [
342,
15,
377,
379,
344
],
"68": [
458,
209,
491,
477,
104
],
"69": [
514,
135,
441,
590,
172
],
"70": [
226,
83,
75,
237,
224
],
"71": [
530,
119,
569,
190,
412
],
"72": [
38,
37,
257,
308,
278
],
"73": [
414,
113,
188,
575,
536
],
"74": [
362,
19,
358,
361,
360
],
"75": [
334,
1,
330,
387,
391
],
"76": [
438,
161,
538,
143,
564
],
"77": [
550,
157,
426,
560,
147
],
"78": [
566,
167,
137,
544,
436
],
"79": [
126,
177,
452,
507,
599
],
"80": [
284,
41,
254,
43,
303
],
"81": [
494,
97,
476,
463,
216
],
"82": [
200,
109,
418,
524,
579
],
"83": [
263,
25,
26,
276,
310
],
"84": [
300,
50,
52,
249,
287
],
"85": [
558,
145,
428,
159,
552
],
"86": [
403,
9,
316,
315,
407
],
"87": [
74,
82,
227,
221,
240
],
"88": [
289,
66,
244,
271,
70
],
"89": [
306,
34,
280,
33,
259
],
"90": [
572,
118,
531,
409,
191
],
"91": [
207,
90,
472,
483,
501
],
"92": [
369,
23,
370,
351,
352
],
"93": [
585,
132,
175,
517,
446
],
"94": [
105,
196,
528,
583,
422
],
"95": [
241,
67,
292,
71,
270
],
"96": [
425,
148,
559,
549,
158
],
"97": [
525,
193,
108,
423,
582
],
"98": [
174,
129,
588,
447,
520
],
"99": [
313,
10,
404,
408,
314
],
"100": [
413,
187,
576,
535,
114
],
"101": [
573,
186,
416,
115,
534
],
"102": [
49,
51,
299,
288,
250
],
"103": [
449,
180,
127,
598,
506
],
"104": [
469,
91,
206,
504,
482
],
"105": [
513,
171,
589,
136,
442
],
"106": [
279,
35,
305,
260,
36
],
"107": [
389,
3,
385,
336,
332
],
"108": [
593,
183,
509,
454,
124
],
"109": [
149,
155,
554,
432,
548
],
"110": [
325,
5,
394,
327,
398
],
"111": [
453,
123,
510,
184,
594
],
"112": [
383,
13,
338,
340,
381
],
"113": [
311,
31,
273,
32,
262
],
"114": [
581,
107,
526,
424,
194
],
"115": [
61,
57,
268,
247,
294
],
"116": [
87,
79,
230,
220,
233
],
"117": [
301,
47,
45,
256,
282
],
"118": [
131,
176,
445,
518,
586
],
"119": [
210,
103,
457,
478,
492
],
"120": [
140,
166,
567,
433,
541
]
},
};
// Schoute's partition via https://arxiv.org/abs/1010.4353
export const PARTITION600 = {

View File

@ -14,7 +14,6 @@ export const get_colours = (basis) => {
const hsl = colours.map((c) => Color("#" + c).hsl());
const resaturated = hsl.map((hslc) => hslc.saturationl(saturation).rgbNumber());
resaturated.unshift(basis);
console.log(resaturated);
return resaturated;
}

View File

@ -1 +0,0 @@

View File

@ -1,492 +0,0 @@
import * as POLYTOPES from './polytopes.js';
// exploring more inscriptions of the 120-cell
function choice(a) {
const r = Math.floor(Math.random() * a.length);
return a[r];
}
export function nodes_links(links, nodeid) {
return links.filter((l) => l.source === nodeid || l.target === nodeid);
}
export function linked(links, n1, n2) {
const ls = nodes_links(nodes_links(links, n1), n2);
if( ls.length ) {
return ls[0]
} else {
return false;
}
}
function fingerprint(ids) {
const sids = [...ids];
sids.sort();
return sids.join(',');
}
export function dist(n1, n2) {
return Math.sqrt((n1.x - n2.x) ** 2 + (n1.y - n2.y) ** 2 + (n1.z - n2.z) ** 2 + (n1.w - n2.w) ** 2);
}
export function make_120cell() {
const nodes = POLYTOPES.make_120cell_vertices();
const links = POLYTOPES.auto_detect_edges(nodes, 4);
return {
nodes: nodes,
links: links
}
}
function round_dist(raw) {
return Math.floor(raw * 100000) / 100000;
}
export function distance_groups(cell120) {
// get list of other nodes by distance
// sort them and dump them out
const dists = {};
cell120.nodes.map((n) => {
const draw = dist(cell120.nodes[0], n);
const dtrunc = round_dist(draw);
if( !(dtrunc in dists) ) {
dists[dtrunc] = [];
}
dists[dtrunc].push(n);
});
return dists;
}
function distance_group(cell120, n0, chord) {
const nodes = []
cell120.nodes.map((n) => {
const d = round_dist(dist(n0, n));
if( d == chord ) {
nodes.push(n);
}
});
// filter and return those whose chord is also the same
const equidistant = [];
for( const n1 of nodes ) {
for( const n2 of nodes ) {
if( n2.id > n1.id ) {
if( round_dist(dist(n1, n2)) == chord ) {
equidistant.push([n1, n2]);
}
}
}
}
return equidistant;
}
export function chord_survey() {
const cell120 = POLYTOPES.cell120_inscribed();
const dgroups = distance_groups(cell120);
const dists = Object.keys(dgroups);
dists.sort();
for( const d of dists ) {
const g0 = dgroups[d][0];
dgroups[d].map((g) => {
console.log(`${g0.id}-${g.id}: ${round_dist(dist(g0, g))}`);
});
}
}
function overlap(c1, c2) {
for( const l in c1 ) {
if( c1[l] === c2[l] ) {
return true;
}
}
return false;
}
function c5match(c1, c2) {
for( const l in c1 ) {
if( c1[l] != c2[l] ) {
return false;
}
}
return true;
}
export function gather_5cells(cell120) {
const CHORD5 = round_dist(Math.sqrt(2.5));
const bins = [];
const all = [];
cell120.nodes.filter((n) => n.label === 1).map((n) => {
const cells = [ ];
const g = distance_group(cell120, n, CHORD5);
for( const pair of g ) {
let seen = false;
for( const cell of cells ) {
const c = Object.values(cell);
if( c.includes(pair[0].id) || c.includes(pair[1].id) ) {
if( !c.includes(pair[0].id) ) {
cell[pair[0].label] = pair[0].id;
}
if( !c.includes(pair[1].id) ) {
cell[pair[1].label] = pair[1].id;
}
seen = true;
break;
}
}
if( !seen ) {
const cell = {};
cell[1]= n.id;
cell[pair[0].label] = pair[0].id;
cell[pair[1].label] = pair[1].id;
cells.push(cell);
}
}
all.push(...cells);
});
return all;
}
function audit_5cells(cells) {
// this verifies that for each label (a 600-cell set), each of its
// vertices is in exactly 7 5-cells. It checks out.
['1','2','3','4','5'].map((l) => {
const sets = {};
for( const cell of cells ) {
const lv = cell[l];
if( !(lv in sets) ) {
sets[lv] = [];
}
sets[lv].push(cell);
}
for( const lv in sets ) {
const ok = ( sets[lv].length === 7 ) ? 'ok' : 'miss';
console.log(`${l},${lv},${sets[lv].length},${ok}`);
}
});
}
function try_120_5_cells_fails(cell120, cells, l) {
// iterate over every vertex in the 600-cell defined by label l,
// get all 7 5-cells including that vertex, and add them if they are
// disjoint with what we already have
// this always runs out of disjoint nodes early
const vertices = cell120.nodes.filter((n) => n.label === l);
const cellset = [];
for( const v of vertices ) {
console.log(`Vertex ${v.id}`);
const vcells = cells.filter((c) => c[l] === v.id);
const overlap_any = (cs, c) => {
for( const seen of cs ) {
if( overlap(seen, c) ) {
console.log("overlap");
console.log(c);
return true;
}
}
return false;
}
const disjoint = vcells.filter((c) => ! overlap_any(cellset, c));
console.log(`Found ${disjoint.length} disjoint cells`);
if( disjoint.length > 0 ) {
cellset.push(choice(disjoint));
}
}
console.log(`Found total of ${cellset.length} disjoint cells`);
//console.log(cellset);
}
function overlap_any(cs, c) {
for( const seen of cs ) {
if( overlap(seen, c) ) {
return true;
}
}
return false;
}
function explore_disjoint(cell120, all5, l) {
const a = all5[0];
const overlaps = all5.filter((c) => overlap(c, a));
console.log(a);
console.log(overlaps.length);
console.log(overlaps);
}
// select a five-cell from a starting vertex v
// find a neighbor of v vn on its 600 cell, find all of the 5-cells which include
// vn. Then see if we can find any from that set which are similiar neighbours to
// the other four vertices in the first 5-cell
// the idea is that the 600-cells are a guide to finding the right subset of
// 5-cells
function neighbours600(cell120, vid) {
const v = cell120.nodes.filter((node) => node.id === vid)[0];
const label = v.label;
const links = cell120.links.filter((l) => {
return l.label === v.label && (l.source === v.id || l.target == v.id );
});
const nodes = links.map((l) => {
if( l.source === v.id ) {
return l.target;
} else {
return l.source;
}
});
return nodes;
}
function cell120node(cell120, nid) {
return cell120.nodes.filter((n) => n.id === nid)[0];
}
function node_dist(cell120, aid, bid) {
const a = cell120node(cell120, aid);
const b = cell120node(cell120, bid);
return dist(a, b);
}
function print_row(v1, v2, p, v5) {
console.log(`${v1.id},${v2.id},${p},${v5[1]},${v5[2]},${v5[3]},${v5[4]},${v5[5]}`);
}
// for a pair of vertices which are on the same inscribed 600 cell,
// this returns all 7 pairs of 5-cells which contain v1 and v2 and
// which are also evenly spaced (ie every pair of vertices on the
// same 600-cell is one edge apart)
function find_adjoining_5cells(cell120, all5, v1, v2) {
const DIST600 = round_dist(node_dist(cell120, v1.id, v2.id));
const v15s = all5.filter((c5) => c5[v1.label] === v1.id);
const v25s = all5.filter((c5) => c5[v2.label] === v2.id);
let p = 0;
const c5pairs = [];
for( const v5a of v15s ) {
for( const v5b of v25s ) {
let match = true;
const d = {};
for( const label in v5a ) {
d[label] = round_dist(node_dist(cell120, v5a[label], v5b[label]));
if( d[label] != DIST600 ) {
match = false;
}
}
if( match ) {
c5pairs.push([ v5a, v5b ]);
}
}
}
return c5pairs;
}
function tetras(cell120, v) {
// given a vertex v, find all of the 600-cell tetras it's on
const n600s = neighbours600(cell120, v.id);
// need to find all sets of three neighbours which are neighbours: there
// should be 20 of these because they're faces of an icosahedron
const tetras = new Set;
for( const v2id of n600s ) {
// find mutual neighbours of the first two
const n2600s = neighbours600(cell120, v2id);
const mutuals = n2600s.filter((nid) => {
return nid != v2id && nid != v.id && n600s.includes(nid)
});
for( const nm of mutuals ) {
const nnms = neighbours600(cell120, nm);
const mutuals2 = nnms.filter((nid) => {
return nid != nm && nid != v2id && nid != v.id && mutuals.includes(nid)
});
for( const m2 of mutuals2 ) {
const t = [ v.id, v2id, nm, m2 ];
t.sort((a, b) => a - b);
const tstr = t.join(',');
tetras.add(tstr);
}
}
}
const tarray = [];
for( const t of tetras ) {
const ta = t.split(',').map((v) => Number(v));
tarray.push(ta);
}
return tarray;
}
function vertices(hedra) {
const v = new Set;
for ( const h of hedra) {
for( const p of h ) {
v.add(p);
}
}
return Array.from(v);
}
function str5cell(c5) {
return ["1","2","3","4","5"].map((l) => String(c5[l]).padStart(3, '0')).join('-');
}
function tetra_sets(cell120, all5, tetra) {
// given a tetrahedron on a 600-cell, find the sets of adjacent 5-cells on
// all of the pairs
// this is ass-backwards. Need to find tetras on the other 4 vertices of a 5-cell
const vs = tetra.map((tid) => cell120node(cell120, tid));
const pairs = [[0,1], [0,2], [0, 3], [1, 2], [1, 3], [2, 3]];
for( const p of pairs ) {
const v1 = vs[p[0]];
const v2 = vs[p[1]];
const c5pairs = find_adjoining_5cells(cell120, all5, v1, v2);
console.log(v1.id, v2.id);
console.log(c5pairs.map((p) => str5cell(p[0]) + " " + str5cell(p[1])));
}
}
function cell5_neighbourhoods(cell120, all5, c5) {
const neighbours = {}
for( const l in c5 ) {
const v = cell120node(cell120, c5[l]);
neighbours[l] = vertices(tetras(cell120, v));
}
// now take the set of all 5-cells and filter it to only those whose vertices
// are in the neighour sets. On first inspection there are 13?
const n5cells = all5.filter((c5) => {
for( const l in c5 ) {
if( ! neighbours[l].includes(c5[l]) ) {
return false;
}
}
return true;
});
return n5cells;
}
function cell5_tetras(cell120, all5, c5) {
const nb = cell5_neighbourhoods(cell120, all5, c5);
const v1 = cell120node(cell120, c5["1"]);
const ts = tetras(cell120, v1);
const c5s = [];
for( const t of ts ) {
const nt = nb.filter((n) => {
for( const l in n ) {
if( t.includes(n[l]) ) {
return true;
}
}
return false
});
for( const nc5 of nt ) {
const exact = c5s.filter((c) => c5match(c, nc5));
if( exact.length === 0 ) {
const o = c5s.filter((c) => overlap(c, nc5));
if( o.length > 0 ) {
console.log("Overlap", c5, o);
} else {
c5s.push(nc5);
}
}
}
}
return c5s;
}
function coherent_5cells_r(cell120, all5, c5s, c50) {
// Find next set of c5s, see if there are any we haven't seen,
// recurse into those ones
const c5ns = cell5_tetras(cell120, all5, c50);
const c5unseen = c5ns.filter((c5) => {
const matched = c5s.filter((c5b) => c5match(c5b, c5));
return matched.length === 0;
});
for( const c5u of c5unseen ) {
c5s.push(c5u);
}
for( const c5u of c5unseen ) {
coherent_5cells_r(cell120, all5, c5s, c5u);
}
}
function coherent_5cells(cell120, all5) {
// pick a starting point, collect coherent 5_cells, continue till
// there aren't any new ones
const c5set = [];
let c5 = all5[0];
const c5s = [];
coherent_5cells_r(cell120, all5, c5s, c5);
return c5s;
}
const cell120 = POLYTOPES.cell120_inscribed();
const all5 = gather_5cells(cell120);
const c5s = coherent_5cells(cell120, all5);
const celli = c5s.map((c5) => [ "1", "2", "3", "4", "5" ].map((l) => c5[l]));
// check it because I don't believe it yet
const vertex_check = {};
for( const c5 of celli ) {
for( const l in c5 ) {
const v = c5[l];
if( v in vertex_check ) {
console.log(`Double count vertex ${v}`);
}
vertex_check[v] = 1;
}
}
for( let i = 1; i < 601; i++ ) {
if( !vertex_check[i] ) {
console.log(`v ${i} missing`);
}
}
const idict = {};
for( let i = 1; i < 121; i++ ) {
idict[i] = celli[i - 1];
}
console.log(JSON.stringify(idict, null, 2));

View File

@ -1,11 +1,8 @@
import * as THREE from 'three';
import { TaperedLink } from './taperedLink.js';
const HYPERPLANE = 2.0;
const W_FORESHORTENING = 0.04;
const W_FORESHORTENING = 0.4;
class FourDShape extends THREE.Group {
@ -29,12 +26,12 @@ class FourDShape extends THREE.Group {
// if a node/link has no label, use the 0th material
getMaterialLabel(entity) {
if( "label" in entity ) {
return entity.label
} else {
return 0;
}
getMaterial(entity, materials) {
if( "label" in entity ) {
return materials[entity.label];
} else {
return materials[0];
}
}
makeNode(material, v3, scale) {
@ -45,24 +42,62 @@ class FourDShape extends THREE.Group {
return sphere;
}
makeLink(materialLabel, link) {
makeLink(material, link) {
const n1 = this.nodes3[link.source];
const n2 = this.nodes3[link.target];
const s1 = this.link_scale * n1.scale;
const s2 = this.link_scale * n2.scale;
const basematerial = this.link_ms[materialLabel];
const edge = new TaperedLink(basematerial, materialLabel, n1, n2, s1, s2);
this.add( edge );
const s1 = n1.scale;
const s2 = n2.scale;
const length = n1.v3.distanceTo(n2.v3);
const centre = new THREE.Vector3();
centre.lerpVectors(n1.v3, n2.v3, 0.5);
const geometry = new THREE.CylinderGeometry(this.link_scale * s2, this.link_scale * s1, 1);
const cyl = new THREE.Mesh(geometry, material);
const edge = new THREE.Group();
edge.add(cyl);
edge.position.copy(centre);
edge.scale.copy(new THREE.Vector3(1, 1, length));
edge.lookAt(n2.v3);
cyl.rotation.x = Math.PI / 2.0;
this.add(edge);
return edge;
}
updateLink(link, links_show) {
const n1 = this.nodes3[link.source];
const n2 = this.nodes3[link.target];
const s1 = this.link_scale * n1.scale;
const s2 = this.link_scale * n2.scale;
link.object.update(n1, n2, s1, s2);
link.object.visible = (!links_show || links_show.includes(link.label));
const s1 = n1.scale;
const s2 = n2.scale;
const length = n1.v3.distanceTo(n2.v3);
const centre = new THREE.Vector3();
centre.lerpVectors(n1.v3, n2.v3, 0.5);
// is this really the only way to do this?
//const geometry = new THREE.CylinderGeometry(this.link_scale * s2, this.link_scale * s1, 1);
//link.object.children[0].geometry.dispose();
this.forshortenLink(link.object.children[0].geometry, this.link_scale * s2, this.link_scale * s1);
//const link_hack = this.link_scale * (s1 + s2) * 0.5;
link.object.scale.copy(new THREE.Vector3(1, 1, length));
link.object.position.copy(centre);
link.object.lookAt(n2.v3);
link.object.children[0].rotation.x = Math.PI / 2.0;
// link.object.children[0].geometry.needsUpdate = true;
// link.object.children[0].geometry.computeVertexNormals();
link.object.visible = (!links_show || link.label in links_show);
}
forshortenLink(geometry, top, bottom) {
const count = geometry.attributes.position.count;
for( let i = 0; i < count; i++ ) {
const x = geometry.attributes.position.getX(i);
const y = geometry.attributes.position.getY(i);
const z = geometry.attributes.position.getZ(i);
if( z == 0 ) {
geometry.attributes.position.setX(i, x * top);
geometry.attributes.position.setY(i, y * top);
} else {
geometry.attributes.position.setX(i, x * bottom);
geometry.attributes.position.setY(i, y * bottom);
}
}
}
@ -91,6 +126,15 @@ class FourDShape extends THREE.Group {
}
fourDtoV3_old(x, y, z, w, rotations) {
const v4 = new THREE.Vector4(x, y, z, w);
for ( const m4 of rotations ) {
v4.applyMatrix4(m4);
}
const k = this.fourDscale(v4.w);
return new THREE.Vector3(v4.x * k, v4.y * k, v4.z * k);
}
fourDscale(w) {
return this.hyperplane / ( this.hyperplane + w );
}
@ -112,7 +156,7 @@ class FourDShape extends THREE.Group {
for( const n of this.nodes4 ) {
const k = this.fourDscale(n.w);
const v3 = new THREE.Vector3(n.x * k, n.y * k, n.z * k);
const material = this.node_ms[this.getMaterialLabel(n)];
const material = this.getMaterial(n, this.node_ms);
this.nodes3[n.id] = {
v3: v3,
scale: k,
@ -121,11 +165,11 @@ class FourDShape extends THREE.Group {
};
}
for( const l of this.links ) {
const mLabel = this.getMaterialLabel(l);
l.object = this.makeLink(mLabel, l);
const material = this.getMaterial(l, this.link_ms);
l.object = this.makeLink(material, l);
}
for( const f of this.faces ) {
const material = this.face_ms(this.getMaterialLabel(f));
const material = this.getMaterial(f, this.face_ms);
f.object = this.makeFace(material, f);
}
}
@ -143,7 +187,7 @@ class FourDShape extends THREE.Group {
this.nodes3[n.id].scale = k * this.foreshortening;
this.nodes3[n.id].object.position.copy(v3);
this.nodes3[n.id].object.scale.copy(s3);
this.nodes3[n.id].object.visible = ( !nodes_show || nodes_show.includes(n.label) );
this.nodes3[n.id].object.visible = ( !nodes_show || n.label in nodes_show );
}
for( const l of this.links ) {
this.updateLink(l, links_show);

83
gui.js
View File

@ -2,12 +2,11 @@ import { GUI } from 'lil-gui';
const DEFAULTS = {
nodesize: 0.6,
nodeopacity: 1,
linksize: 1.0,
linkopacity: 0.75,
thickness: 0.02,
nodesize: 0.02,
linkopacity: 0.5,
link2opacity: 0.5,
shape: '120-cell',
link2opacity: 0.75,
option: 'none',
visibility: 5,
inscribed: false,
@ -16,11 +15,9 @@ const DEFAULTS = {
background: 0xd4d4d4,
hyperplane: 0.93,
zoom: 1,
xRotate: 'YZ',
yRotate: 'XZ',
xRotate: 'YW',
yRotate: 'XW',
dtheta: 0,
damping: false,
captions: true,
dpsi: 0,
}
@ -28,10 +25,9 @@ const DEFAULTS = {
class FourDGUI {
constructor(funcs) {
this.shapes = funcs.shapes;
constructor(shapes, changeShape, setColor, setBackground, setLinkOpacity, setVisibility) {
this.gui = new GUI();
const SHAPE_NAMES = this.shapes.map((s) => s.name);
const SHAPE_NAMES = shapes.map((s) => s.name);
this.parseLinkParams();
const guiObj = this;
@ -40,11 +36,10 @@ class FourDGUI {
option: this.link['option'],
inscribed: this.link['inscribed'],
inscribe_all: this.link['inscribe_all'],
linksize: this.link['linksize'],
thickness: this.link['thickness'],
linkopacity: this.link['linkopacity'],
link2opacity: this.link['link2opacity'],
link2opacity: this.link['linkopacity'],
nodesize: this.link['nodesize'],
nodeopacity: this.link['nodeopacity'],
depth: this.link['depth'],
color: this.link['color'],
background: this.link['background'],
@ -53,55 +48,44 @@ class FourDGUI {
xRotate: this.link['xRotate'],
yRotate: this.link['yRotate'],
damping: false,
captions: true,
dtheta: this.link['dtheta'],
dpsi: this.link['dpsi'],
"copy link": function () { guiObj.copyUrl() },
"copy link": function () { guiObj.copyUrl() }
};
if( funcs.extras ) {
for( const label in funcs.extras ) {
console.log(label);
console.log(funcs.extras[label]);
this.params[label] = funcs.extras[label];
}
}
let options_ctrl;
this.gui.add(this.params, 'shape', SHAPE_NAMES).onChange((shape) => {
const options = this.getShapeOptions(shape);
const options = this.getShapeOptions(shapes, shape);
options_ctrl = options_ctrl.options(options).onChange((option) => {
funcs.setVisibility(option)
setVisibility(option)
});
options_ctrl.setValue(options[0])
funcs.changeShape(shape)
changeShape(shape)
});
const options = this.getShapeOptions(this.params['shape']);
const options = this.getShapeOptions(shapes, this.params['shape']);
options_ctrl = this.gui.add(this.params, 'option').options(options).onChange((option) => {
funcs.setVisibility(option)
setVisibility(option)
});
this.gui.add(this.params, 'hyperplane', 0.5, 1 / 0.8);
this.gui.add(this.params, 'zoom', 0.1, 2.0);
this.gui.add(this.params, 'nodesize', 0, 1.5);
this.gui.add(this.params, 'nodeopacity', 0, 1).onChange(funcs.setNodeOpacity);
this.gui.add(this.params, 'linksize', 0, 2);
console.log(funcs.setLinkOpacity);
this.gui.add(this.params, 'linkopacity', 0, 1).onChange((v) => funcs.setLinkOpacity(v, true));
this.gui.add(this.params, 'link2opacity', 0, 1).onChange((v) => funcs.setLinkOpacity(v, false));
this.gui.addColor(this.params, 'color').onChange(funcs.setColor);
this.gui.addColor(this.params, 'background').onChange(funcs.setBackground);
this.gui.add(this.params, 'thickness', 0, 0.1);
this.gui.add(this.params, 'linkopacity', 0, 1).onChange(
(v) => setLinkOpacity(v, true)
);
this.gui.add(this.params, 'link2opacity', 0, 1).onChange(
(v) => setLinkOpacity(v, false)
);
this.gui.add(this.params, 'nodesize', 0, 0.1);
this.gui.addColor(this.params, 'color').onChange(setColor);
this.gui.addColor(this.params, 'background').onChange(setBackground);
this.gui.add(this.params, 'xRotate', [ 'YW', 'YZ', 'ZW' ]);
this.gui.add(this.params, 'yRotate', [ 'XZ', 'XY', 'XW' ]);
this.gui.add(this.params, 'captions').onChange(this.showDocs);
this.gui.add(this.params, 'damping');
this.gui.add(this.params, 'copy link');
if( funcs.extras ) {
for( const label in funcs.extras ) {
this.gui.add(this.params, label);
}
}
}
getShapeOptions(shape) {
const spec = this.shapes.filter((s) => s.name === shape);
getShapeOptions(shapes, shape) {
const spec = shapes.filter((s) => s.name === shape);
if( spec && spec[0].options ) {
return spec[0].options.map((o) => o.name);
} else {
@ -150,11 +134,10 @@ class FourDGUI {
}
this.link['hyperplane'] = this.numParam('hyperplane', parseFloat);
this.link['zoom'] = this.numParam('zoom', parseFloat);
this.link['linksize'] = this.numParam('linksize', parseFloat);
this.link['thickness'] = this.numParam('thickness', parseFloat);
this.link['linkopacity'] = this.numParam('linkopacity', parseFloat);
this.link['link2opacity'] = this.numParam('link2opacity', parseFloat);
this.link['nodesize'] = this.numParam('nodesize', parseFloat);
this.link['nodeopacity'] = this.numParam('nodeopacity', parseFloat);
this.link['color'] = this.numParam('color', (s) => guiObj.stringToHex(s));
this.link['background'] = this.numParam('background', (s) => guiObj.stringToHex(s));
this.link['dpsi'] = this.numParam('dpsi', parseFloat);
@ -168,10 +151,10 @@ class FourDGUI {
url.searchParams.append("option", this.params.option);
url.searchParams.append("inscribed", this.params.inscribed ? 'y': 'n');
url.searchParams.append("inscribe_all", this.params.inscribe_all ? 'y': 'n');
url.searchParams.append("linksize", this.params.linksize.toString());
url.searchParams.append("thickness", this.params.thickness.toString());
url.searchParams.append("nodesize", this.params.nodesize.toString());
url.searchParams.append("nodeopacity", this.params.nodesize.toString());
url.searchParams.append("linkopacity", this.params.nodeopacity.toString());
url.searchParams.append("linkopacity", this.params.thickness.toString());
url.searchParams.append("link2opacity", this.params.nodesize.toString());
url.searchParams.append("color", this.hexToString(this.params.color));
url.searchParams.append("background", this.hexToString(this.params.background));
url.searchParams.append("hyperplane", this.params.hyperplane.toString());

View File

@ -14,15 +14,6 @@
font-family: sans-serif;
padding: 1em;
}
div#release_notes {
position: fixed;
top: 0;
left: 0;
width: 20%;
z-index: 2;
padding: 1em;
font-family: sans-serif;
}
div#info {
position: fixed;
bottom:0;
@ -35,10 +26,7 @@
<body>
<script type="module" src="/main.js"></script>
<div id="description"></div>
<div id="release_notes"></div>
<div id="info"><a href="#" id="show_notes">release 1.1</a> |
by <a target="_blank" href="https://mikelynch.org/">Mike Lynch</a> |
<div id="info">by <a target="_blank" href="https://mikelynch.org/">Mike Lynch</a> -
<a target="_blank" href="https://git.tilde.town/bombinans/fourdjs">source</a></div>
</body>
</html>

View File

@ -1,119 +0,0 @@
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GUI } from 'lil-gui';
import { TaperedLink } from './taperedLink.js';
const FACE_OPACITY = 0.3;
const CAMERA_K = 5;
// scene, lights and camera
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
const light = new THREE.PointLight(0xffffff, 2);
light.position.set(10, 10, 10);
scene.add(light);
const light2 = new THREE.PointLight(0xffffff, 2);
light2.position.set(-10, 5, 10);
scene.add(light);
const amblight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(amblight);
camera.position.set(0, 0, CAMERA_K / 2);
camera.lookAt(0, 0, 0);
camera.position.z = 8;
const renderer = new THREE.WebGLRenderer({antialias: true});
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.localClippingEnabled = true;
const controls = new OrbitControls( camera, renderer.domElement );
controls.autoRotate = true;
document.body.appendChild( renderer.domElement );
const NODEC = 0x3293a9;
const LINKC = 0x00ff88;
const BACKGROUNDC = 0xd4d4d4;
scene.background = new THREE.Color(BACKGROUNDC);
const material = new THREE.MeshStandardMaterial({ color: LINKC });
material.transparent = true;
material.opacity = 0.7;
const node_mat = new THREE.MeshStandardMaterial({ color: NODEC });
node_mat.transparent = true;
node_mat.opacity = 0.5;
const params = {
r1: 0.5,
r2: 0.6,
sync: false,
l: 9,
rotx: 1,
roty: 0,
rotz: 0,
};
const gui = new GUI();
gui.add(params, "r1", 0.01, 1.5);
gui.add(params, "r2", 0.01, 1.5);
gui.add(params, "sync");
gui.add(params, "l", 0, 10);
gui.add(params, "rotx", 0, 4);
gui.add(params, "roty", 0, 4);
gui.add(params, "rotz", 0, 4);
function makeNode(material, pos, r) {
const geometry = new THREE.SphereGeometry(1);
const sphere = new THREE.Mesh(geometry, material);
const node = {
v3: pos,
object: sphere
};
updateNode(node, pos, r);
return node;
}
function updateNode(node, pos, r) {
node.v3 = pos;
node.object.scale.copy(new THREE.Vector3(r, r, r));
node.object.position.copy(pos);
}
const n1 = makeNode(node_mat, new THREE.Vector3(-params["l"], -1, -1), params["r1"]);
const n2 = makeNode(node_mat, new THREE.Vector3(params["l"], 1, 1), params["r2"]);
const tl = new TaperedLink(material, n1, n2, params["r1"], params["r2"]);
scene.add(n1.object);
scene.add(n2.object);
scene.add(tl);
function animate() {
requestAnimationFrame(animate);
const r1 = params["r1"];
const r2 = params["sync"] ? r1 : params["r2"]
updateNode(n1, new THREE.Vector3(- params["l"], -1, -1), r1);
updateNode(n2, new THREE.Vector3(params["l"], 1, 1), r2);
tl.update(n1, n2, r1, r2, params["rotx"], params["roty"], params["rotz"]);
controls.update();
renderer.render(scene, camera);
}
animate();

128
main.js
View File

@ -1,21 +1,5 @@
import * as THREE from 'three';
const RELEASE_NOTES = `
<p><b>v1.1 - 1/1/2026</b></p>
<p>The 120-cell now includes a visualisation of its inscribed 5-cells, which honestly
looks like less of a mess than I expected it to.</p>
<p><b>v1.0 - 16/11/2025</b></p>
<p>It's been <a target="_blank" href="https://mikelynch.org/2023/Sep/02/120-cell/">two years</a> since
I first made this, and I haven't updated it in a while, but I got tapered links to
work without too much performance overhead, so that seemed worth a version.</p>
<p>The results flicker a bit at low opacities but otherwise I'm pretty happy with
it.</p>
`;
import * as POLYTOPES from './polytopes.js';
@ -47,37 +31,30 @@ camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({antialias: true});
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.localClippingEnabled = true;
document.body.appendChild( renderer.domElement );
// set up colours and materials for gui callbacks
scene.background = new THREE.Color(DEFAULTS.background);
const material = new THREE.MeshStandardMaterial({ color: DEFAULTS.color });
const node_colours = get_colours(DEFAULTS.color);
material.transparent = true;
material.opacity = 0.5;
const node_ms = node_colours.map((c) => new THREE.MeshStandardMaterial({color: c}));
const link_ms = node_colours.map((c) => new THREE.MeshStandardMaterial({color: c}));
node_ms.map((m) => {
m.transparent = true;
m.opacity = 1.0;
}
);
link_ms.map((m) => {
m.transparent = true;
m.opacity = 0.5;
}
);
console.log("link_ms", link_ms);
)
const face_ms = [
new THREE.MeshStandardMaterial( { color: 0x44ff44 } )
new THREE.MeshLambertMaterial( { color: 0x44ff44 } )
];
for( const face_m of face_ms ) {
@ -118,75 +95,36 @@ function displayDocs(name) {
}
}
function showDocs(visible) {
const docdiv = document.getElementById("description");
if( visible ) {
docdiv.style.display = '';
} else {
docdiv.style.display = 'none';
}
}
function releaseNotes() {
showDocs(false);
const reldiv = document.getElementById("release_notes");
reldiv.style.display = '';
reldiv.innerHTML = RELEASE_NOTES + '<p><a id="no_notes" href="#">[hide]</a>';
const goaway = document.getElementById("no_notes");
goaway.addEventListener('click', noNotes);
}
function noNotes() {
const reldiv = document.getElementById("release_notes");
reldiv.style.display = 'none';
}
const relnotes = document.getElementById('show_notes');
relnotes.addEventListener('click', releaseNotes);
// initialise gui and read params from URL
// callbacks to do things which are triggered by controls: reset the shape,
// change the colors. Otherwise we just read stuff from gui.params.
function setColors(c) {
const nc = get_colours(c);
for( let i = 0; i < node_ms.length; i++ ) {
node_ms[i].color = new THREE.Color(nc[i]);
link_ms[i].color = new THREE.Color(nc[i]);
}
if( shape ) {
// taperedLink.set_color updates according to the link index
shape.links.map((l) => l.object.set_color(nc));
}
const nc = get_colours(c);
for( let i = 0; i < node_ms.length; i++ ) {
node_ms[i].color = new THREE.Color(nc[i]);
link_ms[i].color = new THREE.Color(nc[i]);
}
material.color = new THREE.Color(c);
}
function setBackground(c) {
scene.background = new THREE.Color(c)
}
// taperedLinks have their own materials so we have to set opacity
// on them individually. And also set the base materials as they
// will get updated from it when the shape changes
function setLinkOpacity(o, primary) {
link_ms.map((lm) => lm.opacity = o);
if( shape ) {
shape.links.map((l) => {
if( (primary && l.label == 0) || (!primary && l.label !== 0) ) {
l.object.material.opacity = o
}
});
}
if( structure.nolink2opacity ) {
link_ms.map((lm) => lm.opacity = o);
} else {
if( primary ) {
link_ms[0].opacity = o;
} else {
link_ms.slice(1).map((lm) => lm.opacity = o);
}
}
}
function setNodeOpacity(o) {
node_ms.map((nm) => nm.opacity = o);
}
let gui;
@ -196,8 +134,6 @@ function changeShape() {
}
function setVisibility(option_name) {
console.log("setVisibility", option_name);
console.log(structure.options);
const option = structure.options.filter((o) => o.name === option_name);
if( option.length ) {
node_show = option[0].nodes;
@ -209,16 +145,12 @@ function setVisibility(option_name) {
gui = new FourDGUI(
{
shapes: STRUCTURES,
changeShape: changeShape,
setColors: setColors,
setBackground: setBackground,
setNodeOpacity: setNodeOpacity,
setLinkOpacity: setLinkOpacity,
setVisibility: setVisibility,
showDocs: showDocs,
}
STRUCTURES,
changeShape,
setColors,
setBackground,
setLinkOpacity,
setVisibility
);
// these are here to pick up colour settings from the URL params
@ -284,8 +216,8 @@ function animate() {
shape.hyperplane = 1 / gui.params.hyperplane;
camera.position.set(0, 0, gui.params.zoom * CAMERA_K * gui.params.hyperplane);
shape.link_scale = gui.params.thickness;
shape.node_scale = gui.params.nodesize;
shape.link_scale = gui.params.linksize * gui.params.nodesize * 0.5;
shape.render3(rotations, node_show, link_show);

View File

@ -55,28 +55,8 @@ export function auto_detect_edges(nodes, neighbours, debug=false) {
return links;
}
export const linkTest = () => {
return {
name: 'linky',
nodes: [
{ id:1, label: 1, x: -1, y: -1, z:-1, w: 0 },
{ id:2, label: 2, x: 1, y: 1, z: 1, w: 0 },
],
links: [
{ id: 1, source: 1, target: 2 }
],
options: [ { name: '--' }],
description: `link`,
}
};
// too small and simple to calculate
export const cell5 = () => {
const c1 = Math.sqrt(5) / 4;
return {
@ -443,15 +423,6 @@ export const cell120_inscribed = () => {
links.push(...links600);
}
const CELL5S = CELLINDEX.CELL120_CELL5.cell5s;
for( const c5 in CELL5S ) {
const nodes5 = nodes.filter((n) => CELL5S[c5].includes(n.id));
const links5 = auto_detect_edges(nodes5, 5);
links5.map((l) => l.label = 8);
links.push(...links5);
}
return {
name: '120-cell',
nodes: nodes,
@ -459,44 +430,17 @@ export const cell120_inscribed = () => {
options: [
{ name: "none", links: [ 0 ]},
{ name: "one inscribed 600-cell", links: [ 0, 1 ] },
{ name: "five inscribed 600-cells", links: [ 0, 1, 2, 3, 4, 5 ] },
{ name: "120 inscribed 5-cells", links: [ 0, 8 ] },
{ name: "five inscribed 600-cells", links: [ 0, 1, 2, 3, 4, 5 ] }
],
description: `The 120-cell is the four-dimensional analogue of the
dodecahedron, and consists of 120 dodecahedra joined at 720 faces,
with three dodecahedra around each edge. It is dual to the 600-cell,
and five 600-cells can be inscribed in its vertices. The converse
of this allows 120 5-cells (each of which has one vertex in each
of the 5 600-cells) to be inscribed in the 120-cell.`,
and five 600-cells can be inscribed in its vertices.`,
}
}
export const cell120_inscribed_cell5 = () => {
const nodes = make_120cell_vertices();
const links = auto_detect_edges(nodes, 4);
for( const cstr in CELLINDEX.INDEX120 ) {
label_nodes(nodes, CELLINDEX.INDEX120[cstr], Number(cstr));
}
links.map((l) => l.label = 0);
return {
name: '120-cell-5-cell',
nodes: nodes,
links: links,
options: [
{ name: "5-cells", links: [ 0, 1, 2, 3, 4, 5, 6, 7, 8 ] },
],
description: `The 120-cell with one of its 5-cells.`,
}
}
function partition_coord(i, coords, invert) {

View File

@ -1,66 +0,0 @@
import * as THREE from 'three';
const EPSILON = 0.001;
class TaperedLink extends THREE.Group {
constructor(baseMaterial, color_i, n1, n2, r1, r2) {
super();
const geometry = new THREE.ConeGeometry( 1, 1, 16, true );
const cplane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0.5);
this.color_i = color_i;
this.material = baseMaterial.clone();
this.material.clippingPlanes = [ cplane ];
this.object = new THREE.Mesh( geometry, this.material );
this.add( this.object );
this.update(n1, n2, r1, r2);
}
update(n1, n2, r1, r2) {
const kraw = r1 - r2;
let k = ( Math.abs(kraw) < EPSILON ) ? EPSILON : kraw;
let nbase = n1.v3;
let napex = n2.v3;
let rbase = r1;
let rapex = r2;
if( k < 0 ) {
nbase = n2.v3;
napex = n1.v3;
rbase = r2;
rapex = r1;
k = -k;
}
const l = nbase.distanceTo(napex);
const lapex = l * rapex / k;
const h = l + lapex;
this.scale.copy(new THREE.Vector3(rbase, rbase, h));
const h_offset = 0.5 * h / l;
const pos = new THREE.Vector3();
pos.lerpVectors(nbase, napex, h_offset);
this.position.copy(pos); // the group, not the cone!!
this.lookAt(nbase);
this.children[0].rotation.x = 3 * Math.PI / 2.0;
this.visible = true;
const clipnorm = new THREE.Vector3();
clipnorm.copy(napex);
clipnorm.sub(nbase);
clipnorm.negate();
clipnorm.normalize();
this.material.clippingPlanes[0].setFromNormalAndCoplanarPoint(
clipnorm, napex
);
}
set_color(colors) {
this.material.color = new THREE.Color(colors[this.color_i]);
}
}
export { TaperedLink };