diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1336378 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +CHANGELOG +========= + +## v1.0 - 16/11/2025 + +It's been [two years](https://mikelynch.org/2023/Sep/02/120-cell/) 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. +` diff --git a/fourDShape.js b/fourDShape.js index 888ebd7..7cd76db 100644 --- a/fourDShape.js +++ b/fourDShape.js @@ -1,9 +1,12 @@ import * as THREE from 'three'; +import { TaperedLink } from './taperedLink.js'; + const HYPERPLANE = 2.0; const W_FORESHORTENING = 0.04; + class FourDShape extends THREE.Group { constructor(node_ms, link_ms, face_ms, structure) { @@ -26,12 +29,12 @@ class FourDShape extends THREE.Group { // if a node/link has no label, use the 0th material - getMaterial(entity, materials) { - if( "label" in entity ) { - return materials[entity.label]; - } else { - return materials[0]; - } + getMaterialLabel(entity) { + if( "label" in entity ) { + return entity.label + } else { + return 0; + } } makeNode(material, v3, scale) { @@ -42,45 +45,23 @@ class FourDShape extends THREE.Group { return sphere; } - makeLink(material, link) { + makeLink(materialLabel, link) { const n1 = this.nodes3[link.source]; const n2 = this.nodes3[link.target]; - 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, - 16, 1, true - ); - 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); + 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 ); return edge; } updateLink(link, links_show) { const n1 = this.nodes3[link.source]; const n2 = this.nodes3[link.target]; - 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); - // take the average of the ends as the thickness - as a workaround, - // because I haven't worked out how to reshape tapered links without - // having to reassign a new geometry to every link - const link_mean = this.link_scale * (s1 + s2) * 0.5; - link.object.scale.copy(new THREE.Vector3(link_mean, link_mean, length)); - link.object.position.copy(centre); - link.object.lookAt(n2.v3); - link.object.children[0].rotation.x = Math.PI / 2.0; + 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 || link.label in links_show); } @@ -110,15 +91,6 @@ 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 ); } @@ -140,7 +112,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.getMaterial(n, this.node_ms); + const material = this.node_ms[this.getMaterialLabel(n)]; this.nodes3[n.id] = { v3: v3, scale: k, @@ -149,11 +121,11 @@ class FourDShape extends THREE.Group { }; } for( const l of this.links ) { - const material = this.getMaterial(l, this.link_ms); - l.object = this.makeLink(material, l); + const mLabel = this.getMaterialLabel(l); + l.object = this.makeLink(mLabel, l); } for( const f of this.faces ) { - const material = this.getMaterial(f, this.face_ms); + const material = this.face_ms(this.getMaterialLabel(f)); f.object = this.makeFace(material, f); } } diff --git a/gui.js b/gui.js index baba2ea..8e4755a 100644 --- a/gui.js +++ b/gui.js @@ -2,11 +2,10 @@ import { GUI } from 'lil-gui'; const DEFAULTS = { - nodesize: 0.25, + nodesize: 0.6, nodeopacity: 1, - linksize: 0.2, + linksize: 1.0, linkopacity: 0.75, - link2opacity: 0.75, shape: '120-cell', option: 'none', visibility: 5, @@ -41,7 +40,6 @@ class FourDGUI { inscribe_all: this.link['inscribe_all'], linksize: this.link['linksize'], linkopacity: this.link['linkopacity'], - link2opacity: this.link['linkopacity'], nodesize: this.link['nodesize'], nodeopacity: this.link['nodeopacity'], depth: this.link['depth'], @@ -72,15 +70,10 @@ class FourDGUI { }); 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); + this.gui.add(this.params, 'nodesize', 0, 1.5); this.gui.add(this.params, 'nodeopacity', 0, 1).onChange(setNodeOpacity); - this.gui.add(this.params, 'linksize', 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, 'linksize', 0, 2); + this.gui.add(this.params, 'linkopacity', 0, 1).onChange(setLinkOpacity); 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' ]); @@ -143,7 +136,6 @@ class FourDGUI { this.link['zoom'] = this.numParam('zoom', parseFloat); this.link['linksize'] = this.numParam('linksize', 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)); @@ -163,7 +155,6 @@ class FourDGUI { 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("link2opacity", this.params.link2opacity.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()); diff --git a/index.html b/index.html index 7791e9a..a96fe8c 100644 --- a/index.html +++ b/index.html @@ -14,6 +14,17 @@ font-family: sans-serif; padding: 1em; } + div#release_notes { + position: fixed; + top: 0; + left: 0; + width: 50%; + z-index: 2; + font-family: sans-serif; + padding: 1em; + visibility: none; + background: #ffffff; + } div#info { position: fixed; bottom:0; @@ -26,7 +37,10 @@
-
by Mike Lynch - +
+
release 1.0 | + + by Mike Lynch | source
- \ No newline at end of file + diff --git a/linktest.js b/linktest.js new file mode 100644 index 0000000..dc2157e --- /dev/null +++ b/linktest.js @@ -0,0 +1,119 @@ +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(); diff --git a/main.js b/main.js index 04d8497..45ef86a 100644 --- a/main.js +++ b/main.js @@ -1,5 +1,16 @@ import * as THREE from 'three'; +const RELEASE_NOTES = ` +

v1.0 - 16/11/2025

+ +

It's been two years 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.

+`; + import * as POLYTOPES from './polytopes.js'; @@ -31,19 +42,17 @@ 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})); @@ -62,7 +71,7 @@ link_ms.map((m) => { const face_ms = [ - new THREE.MeshLambertMaterial( { color: 0x44ff44 } ) + new THREE.MeshStandardMaterial( { color: 0x44ff44 } ) ]; for( const face_m of face_ms ) { @@ -104,7 +113,6 @@ function displayDocs(name) { } function showDocs(visible) { - console.log(`showDocs ${visible}`); const docdiv = document.getElementById("description"); if( visible ) { docdiv.style.display = ''; @@ -113,34 +121,55 @@ function showDocs(visible) { } } +function releaseNotes() { + showDocs(false); + const docdiv = document.getElementById("release_notes"); + docdiv.style.display = ''; + docdiv.innerHTML = RELEASE_NOTES + '

[hide]'; + const goaway = document.getElementById("no_notes"); + goaway.addEventListener('click', noNotes); + } + +function noNotes() { + const docdiv = document.getElementById("release_notes"); + docdiv.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]); - } - material.color = new THREE.Color(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)); + } } 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) { - 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); - } - } + link_ms.map((lm) => lm.opacity = o); + if( shape ) { + shape.links.map((l) => l.object.material.opacity = o); + } } function setNodeOpacity(o) { diff --git a/polytopes.js b/polytopes.js index 91816c2..d39a834 100644 --- a/polytopes.js +++ b/polytopes.js @@ -55,8 +55,28 @@ 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 { @@ -844,6 +864,7 @@ export const icosahedron = () => { export const build_all = () => { return [ + linkTest(), tetrahedron(), octahedron(), cube(), @@ -863,4 +884,4 @@ export const build_all = () => { export const radii = (shape) => { return shape.nodes.map(n => Math.sqrt(n.x * n.x + n.y * n.y + n.z * n.z + n.w * n.w)) -} \ No newline at end of file +} diff --git a/taperedLink.js b/taperedLink.js new file mode 100644 index 0000000..1e88975 --- /dev/null +++ b/taperedLink.js @@ -0,0 +1,66 @@ +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 };