finish metatron

This commit is contained in:
Vincent van der Wal
2026-07-19 11:31:48 +02:00
parent 99c2851285
commit abc4deb270
10 changed files with 729 additions and 784 deletions
+501 -753
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -4,7 +4,7 @@
}, },
"devDependencies": { "devDependencies": {
"sass": "^1.101.0", "sass": "^1.101.0",
"vite": "^7.3.0" "vite": "^8.1.5"
}, },
"scripts": { "scripts": {
"start": "vite", "start": "vite",
+9
View File
@@ -182,6 +182,15 @@ html[data-theme='light'] .lil-gui {
--string-color: #c2410c; --string-color: #c2410c;
} }
// below 900px the nav and the panel would share the top edge, so the panel
// drops underneath the nav (three classes to outrank lil-gui's own
// .lil-gui.autoPlace top: 0)
@media (max-width: 900px) {
.lil-gui.root.autoPlace {
top: 76px;
}
}
@media (max-width: 600px) { @media (max-width: 600px) {
.lil-gui.root { .lil-gui.root {
width: calc(100vw - 24px); width: calc(100vw - 24px);
+7
View File
@@ -26,6 +26,12 @@ camera.position.z = 12;
camera.position.x = 10; camera.position.x = 10;
camera.position.y = 4; camera.position.y = 4;
// portrait and narrow screens crop the sides, hiding the projection wall -
// start further back so wall and shapes both fit the frame
if ( window.innerWidth < window.innerHeight || window.innerWidth < 700 ) {
camera.position.multiplyScalar( 1.8 );
}
const renderer = new THREE.WebGLRenderer( { antialias: true, alpha: true } ); const renderer = new THREE.WebGLRenderer( { antialias: true, alpha: true } );
renderer.setPixelRatio( Math.min( window.devicePixelRatio, 2 ) ); renderer.setPixelRatio( Math.min( window.devicePixelRatio, 2 ) );
@@ -282,6 +288,7 @@ let renderFlower = (size = 3, radius = 1) => {
const gui = new GUI(); const gui = new GUI();
if ( window.innerWidth < 700 ) gui.close();
gui.add( params, 'xangle', 0, 360 ).step(0.1).onChange( (value) => { gui.add( params, 'xangle', 0, 360 ).step(0.1).onChange( (value) => {
for (let sphere of spheres) { for (let sphere of spheres) {
[sphere.position.x, sphere.position.y, sphere.position.z] = rotatePoint3D(sphere.start_x, sphere.start_y, sphere.start_z, params['yangle'], value, params['zangle']) [sphere.position.x, sphere.position.y, sphere.position.z] = rotatePoint3D(sphere.start_x, sphere.start_y, sphere.start_z, params['yangle'], value, params['zangle'])
+6
View File
@@ -20,6 +20,12 @@ export function createStage( { target = [ 0, 0, 0 ] } = {} ) {
const camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 0.1, 500 ); const camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 0.1, 500 );
camera.position.set( 10, 4, 12 ); camera.position.set( 10, 4, 12 );
// portrait and narrow screens crop the sides, hiding the projection wall -
// start further back so wall and shapes both fit the frame
if ( window.innerWidth < window.innerHeight || window.innerWidth < 700 ) {
camera.position.multiplyScalar( 1.8 );
}
const renderer = new THREE.WebGLRenderer( { antialias: true, alpha: true } ); const renderer = new THREE.WebGLRenderer( { antialias: true, alpha: true } );
renderer.setPixelRatio( Math.min( window.devicePixelRatio, 2 ) ); renderer.setPixelRatio( Math.min( window.devicePixelRatio, 2 ) );
renderer.setSize( window.innerWidth, window.innerHeight ); renderer.setSize( window.innerWidth, window.innerHeight );
+1
View File
@@ -111,6 +111,7 @@ onUpdate( ( time ) => {
} ); } );
const gui = new GUI(); const gui = new GUI();
if ( window.innerWidth < 700 ) gui.close();
gui.add( params, 'scale', 1, 4 ).step( 0.1 ).onChange( renderMatrix ); gui.add( params, 'scale', 1, 4 ).step( 0.1 ).onChange( renderMatrix );
gui.add( params, 'edges' ).onChange( ( value ) => { parts[ 0 ].visible = value; } ); gui.add( params, 'edges' ).onChange( ( value ) => { parts[ 0 ].visible = value; } );
gui.add( params, 'spokes' ).onChange( ( value ) => { parts[ 1 ].visible = value; } ); gui.add( params, 'spokes' ).onChange( ( value ) => { parts[ 1 ].visible = value; } );
+196 -30
View File
@@ -3,12 +3,14 @@ import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GUI } from 'three/addons/libs/lil-gui.module.min.js'; import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
import { ConvexGeometry } from 'three/addons/geometries/ConvexGeometry.js';
import './lib/chrome.js'; import './lib/chrome.js';
import { sceneColors } from './lib/theme.js'; import { sceneColors } from './lib/theme.js';
import { bindWorld } from './lib/transit.js'; import { bindWorld } from './lib/transit.js';
import { bindGlow } from './lib/glow.js'; import { bindGlow } from './lib/glow.js';
import { makeTube } from './lib/edgeTubes.js';
import { bindCameraMemory } from './lib/cameraMemory.js'; import { bindCameraMemory } from './lib/cameraMemory.js';
const colors = sceneColors(); const colors = sceneColors();
@@ -25,6 +27,12 @@ camera.position.z = 12;
camera.position.x = 10; camera.position.x = 10;
camera.position.y = 4; camera.position.y = 4;
// portrait and narrow screens crop the sides, hiding the projection wall -
// start further back so wall and shapes both fit the frame
if ( window.innerWidth < window.innerHeight || window.innerWidth < 700 ) {
camera.position.multiplyScalar( 1.8 );
}
// debug switches to bisect driver issues: ?noaa disables MSAA, ?noshadow disables shadow maps // debug switches to bisect driver issues: ?noaa disables MSAA, ?noshadow disables shadow maps
const debugFlags = new URLSearchParams( window.location.search ); const debugFlags = new URLSearchParams( window.location.search );
@@ -263,15 +271,12 @@ let renderFlower = (radius = 1) => {
transparent: true transparent: true
} ); } );
const occupiedSpots = new Set(); // circles that share a spot (fire and earth own the same cube corners, the
// two waters share their outer points) are tracked so that only the first
// one circle per spot: if any group already placed a circle here (fire and // visible owner renders its circle - every group keeps its full set
// earth share all eight cube corners), later ones are skipped const circleRegistry = new Map();
const addCircleMeshes = ( point, group ) => {
const spot = point.toArray().map( ( n ) => n.toFixed( 2 ) ).join( ',' );
if ( occupiedSpots.has( spot ) ) return;
occupiedSpots.add( spot );
const placeCircleMeshes = ( point, group ) => {
const sphere = new THREE.Mesh( circleSphereGeometry, circleSphereMaterial ); const sphere = new THREE.Mesh( circleSphereGeometry, circleSphereMaterial );
sphere.position.copy( point ); sphere.position.copy( point );
group.add( sphere ); group.add( sphere );
@@ -281,12 +286,35 @@ let renderFlower = (radius = 1) => {
ring.position.copy( point ); ring.position.copy( point );
ring.castShadow = true; ring.castShadow = true;
group.add( ring ); group.add( ring );
const spot = point.toArray().map( ( n ) => n.toFixed( 2 ) ).join( ',' );
if ( ! circleRegistry.has( spot ) ) circleRegistry.set( spot, [] );
circleRegistry.get( spot ).push( { sphere, ring, group } );
}; };
// circles on a solid's vertices const effectivelyVisible = ( object ) => {
const makeVertexCircles = ( geometry, rotation ) => { for ( let o = object; o && o !== world; o = o.parent ) {
const group = new THREE.Group(); if ( ! o.visible ) return false;
}
return true;
};
const refreshCircleDupes = () => {
for ( const owners of circleRegistry.values() ) {
if ( owners.length < 2 ) continue;
let taken = false;
for ( const owner of owners ) {
const show = effectivelyVisible( owner.group ) && ! taken;
if ( show ) taken = true;
owner.sphere.visible = show;
owner.ring.visible = show;
}
}
};
const uniqueVertices = ( geometry ) => {
const seen = new Set(); const seen = new Set();
const vertices = [];
const positions = geometry.attributes.position; const positions = geometry.attributes.position;
const vertex = new THREE.Vector3(); const vertex = new THREE.Vector3();
@@ -295,11 +323,18 @@ let renderFlower = (radius = 1) => {
const key = vertex.toArray().map( ( n ) => n.toFixed( 3 ) ).join( ',' ); const key = vertex.toArray().map( ( n ) => n.toFixed( 3 ) ).join( ',' );
if ( seen.has( key ) ) continue; if ( seen.has( key ) ) continue;
seen.add( key ); seen.add( key );
vertices.push( vertex.clone() );
const point = vertex.clone().applyMatrix4( rotation );
addCircleMeshes( point, group );
} }
return vertices;
};
// circles on a solid's vertices
const makeVertexCircles = ( geometry, rotation ) => {
const group = new THREE.Group();
for ( const vertex of uniqueVertices( geometry ) ) {
placeCircleMeshes( vertex.applyMatrix4( rotation ), group );
}
return group; return group;
}; };
@@ -435,6 +470,11 @@ let renderFlower = (radius = 1) => {
world.add( icosaLines ); world.add( icosaLines );
world.add( icosa ); world.add( icosa );
// the true icosahedron is hidden by default; the pattern-faithful "Water"
// (the former fake water) stands in for it
icosa.visible = false;
icosaLines.visible = false;
const icosaCircles = addCircles( makeVertexCircles( icosaGeometry, icosaRotation ), false ); const icosaCircles = addCircles( makeVertexCircles( icosaGeometry, icosaRotation ), false );
// 2.29 * 1.003: tuned against the circle pattern // 2.29 * 1.003: tuned against the circle pattern
@@ -462,18 +502,88 @@ let renderFlower = (radius = 1) => {
world.add( dodecaLines ); world.add( dodecaLines );
world.add( dodeca ); world.add( dodeca );
const dodecaCircles = addCircles( makeVertexCircles( dodecaGeometry, new THREE.Matrix4().makeRotationFromEuler( dodeca.rotation ) ), false );
// the in-between circles of the 2D pattern: the six edge midpoints that
// form the inner ring // the six edge midpoints of the 2D pattern's inner ring - Water's inner
const innerCircles = new THREE.Group(); // circle centres
const innerPoints = []; const innerPoints = [];
for ( let s of [ -1, 1 ] ) { for ( let s of [ -1, 1 ] ) {
innerPoints.push( [ s * d, s * d, 0 ], [ s * d, 0, s * d ], [ 0, s * d, s * d ] ); innerPoints.push( [ s * d, s * d, 0 ], [ s * d, 0, s * d ], [ 0, s * d, s * d ] );
} }
for ( const p of innerPoints ) {
addCircleMeshes( new THREE.Vector3( ...p ).applyMatrix4( gridRotation ), innerCircles ); // Fake water: the icosahedron the flat pattern implies - the real water's
// six outer hexagon points (the other six vertices project near the middle
// and are skipped) plus the six inner-ring circle centres. The frame shows
// only the hull edges; the all-pairs strands live in the web toggle.
const innerCentres = innerPoints.map( ( p ) => new THREE.Vector3( ...p ).applyMatrix4( gridRotation ) );
const outerPoints = uniqueVertices( icosaGeometry )
.map( ( v ) => v.applyMatrix4( icosaRotation ) )
.sort( ( a, b ) => Math.hypot( b.y, b.z ) - Math.hypot( a.y, a.z ) )
.slice( 0, 6 );
const fakeWaterPoints = [ ...outerPoints, ...innerCentres ];
const fakeWaterGeometry = new ConvexGeometry( fakeWaterPoints );
const fakeWater = makeEdgeTubes( fakeWaterGeometry, colors.line, params['tube'] );
// same fill values as the real water solid
const fakeWaterFill = new THREE.Mesh(
fakeWaterGeometry,
new THREE.MeshStandardMaterial( {
color: colors.fills.water,
metalness: 0.7,
roughness: 0.3,
opacity: 0.8,
transparent: true
} )
);
// the full circle set; the outer spots overlap real water's circles and
// the registry renders each spot only once
const fakeWaterCircles = new THREE.Group();
for ( const point of fakeWaterPoints ) {
placeCircleMeshes( point, fakeWaterCircles );
} }
addCircles( innerCircles, true );
world.add( fakeWater );
world.add( fakeWaterFill );
solids.push( fakeWater, fakeWaterFill );
addCircles( fakeWaterCircles, true );
// inner webs: every vertex of an element connected to every other with
// thin strands, hidden by default
const webMaterial = new THREE.MeshBasicMaterial( { color: colors.line, opacity: 0.5, transparent: true } );
const makeWeb = ( points ) => {
const group = new THREE.Group();
for ( let i = 0; i < points.length; i++ ) {
for ( let j = i + 1; j < points.length; j++ ) {
group.add( makeTube( points[ i ], points[ j ], webMaterial, params['tube'] * 0.4 ) );
}
}
group.visible = false;
world.add( group );
solids.push( group );
return group;
};
const worldVertices = ( geometry, mesh ) =>
uniqueVertices( geometry ).map( ( v ) => v.applyMatrix4( new THREE.Matrix4().makeRotationFromEuler( mesh.rotation ) ) );
const earthWeb = makeWeb( worldVertices( boxGeometry, cube ) );
const fireWeb = makeWeb( [ ...worldVertices( tetraGeometry, tetra ), ...worldVertices( tetraGeometry2, tetra2 ) ] );
const airWeb = makeWeb( worldVertices( octaGeometry, octa ) );
const waterWeb = makeWeb( worldVertices( icosaGeometry, icosa ) );
const aetherWeb = makeWeb( worldVertices( dodecaGeometry, dodeca ) );
const fakeWaterWeb = makeWeb( fakeWaterPoints );
// the connection points where earth and air meet: the six equatorial cube
// edge midpoints, completing the ring of circles
const connectionCircles = new THREE.Group();
for ( const s of [ -1, 1 ] ) {
for ( const p of [ [ s * d, -s * d, 0 ], [ s * d, 0, -s * d ], [ 0, s * d, -s * d ] ] ) {
placeCircleMeshes( new THREE.Vector3( ...p ).applyMatrix4( gridRotation ), connectionCircles );
}
}
addCircles( connectionCircles, false );
// Big circle: visible as a solid tube, same thickness as the shape edges // Big circle: visible as a solid tube, same thickness as the shape edges
let circleGeometry = new THREE.TorusGeometry( 5 * radius, params['tube'], 64, 64); let circleGeometry = new THREE.TorusGeometry( 5 * radius, params['tube'], 64, 64);
@@ -487,23 +597,55 @@ let renderFlower = (radius = 1) => {
gui = new GUI(); gui = new GUI();
if ( window.innerWidth < 700 ) gui.close();
let guiDict = {}; let guiDict = {};
// every show/fill/frame/circles/web checkbox, so "empty canvas" can
// uncheck them all through the GUI and keep the panel in sync
const visibilityControllers = [];
let fire = gui.addFolder('Fire'); let fire = gui.addFolder('Fire');
guiDict['fire'] = {'folder': fire, 'object': fireFill, 'objectLines': fireFrame, 'circles': fireCircles}; guiDict['fire'] = {'folder': fire, 'object': fireFill, 'objectLines': fireFrame, 'circles': fireCircles, 'web': fireWeb};
let air = gui.addFolder('Air'); let air = gui.addFolder('Air');
guiDict['air'] = {'folder': air, 'object': octa, 'objectLines': octaLines, 'circles': octaCircles}; guiDict['air'] = {'folder': air, 'object': octa, 'objectLines': octaLines, 'circles': octaCircles, 'web': airWeb};
let earth = gui.addFolder('Earth'); let earth = gui.addFolder('Earth');
guiDict['earth'] = {'folder': earth, 'object': cube, 'objectLines': cubeLines, 'circles': cubeCircles}; guiDict['earth'] = {'folder': earth, 'object': cube, 'objectLines': cubeLines, 'circles': cubeCircles, 'web': earthWeb};
let water = gui.addFolder('Water'); let fakeWaterFolder = gui.addFolder('Water');
guiDict['water'] = {'folder': water, 'object': icosa, 'objectLines': icosaLines, 'circles': icosaCircles}; fakeWaterFolder.close();
fakeWaterFolder.add( { scale: 1 }, 'scale', 0, 2 ).step( 0.001 ).onChange( ( value ) => {
fakeWater.scale.setScalar( value );
fakeWaterFill.scale.setScalar( value );
fakeWaterCircles.scale.setScalar( value );
fakeWaterWeb.scale.setScalar( value );
} );
fakeWaterFolder.add( { show: true }, 'show' ).onChange( ( value ) => {
fakeWater.visible = value;
fakeWaterFill.visible = value;
} );
fakeWaterFolder.add( { fill: true }, 'fill' ).onChange( ( value ) => {
fakeWaterFill.visible = value;
} );
fakeWaterFolder.add( { frame: true }, 'frame' ).onChange( ( value ) => {
fakeWater.visible = value;
} );
fakeWaterFolder.add( { circles: true }, 'circles' ).onChange( ( value ) => {
fakeWaterCircles.visible = value;
refreshCircleDupes();
} );
fakeWaterFolder.add( { web: false }, 'web' ).onChange( ( value ) => {
fakeWaterWeb.visible = value;
} );
visibilityControllers.push( ...fakeWaterFolder.controllers.filter( ( c ) => c.property !== 'scale' ) );
let aether = gui.addFolder('Aether') let aether = gui.addFolder('Aether')
guiDict['aether'] = {'folder': aether, 'object': dodeca, 'objectLines': dodecaLines}; guiDict['aether'] = {'folder': aether, 'object': dodeca, 'objectLines': dodecaLines, 'circles': dodecaCircles, 'web': aetherWeb};
let water = gui.addFolder('Real Water');
guiDict['water'] = {'folder': water, 'object': icosa, 'objectLines': icosaLines, 'circles': icosaCircles, 'web': waterWeb};
let folderStandard = { let folderStandard = {
show: true, show: true,
@@ -514,7 +656,9 @@ let renderFlower = (radius = 1) => {
for (let fld of Object.keys(guiDict)) { for (let fld of Object.keys(guiDict)) {
guiDict[fld]['folder'].close() guiDict[fld]['folder'].close()
folderStandard.show = true; folderStandard.show = guiDict[fld]['object'].visible;
folderStandard.fill = guiDict[fld]['object'].visible;
folderStandard.frame = guiDict[fld]['objectLines'].visible;
guiDict[fld]['folder'].add( folderStandard, 'scale', 0, 2 ).step(0.001).onChange( (value) => { guiDict[fld]['folder'].add( folderStandard, 'scale', 0, 2 ).step(0.001).onChange( (value) => {
guiDict[fld]['object'].scale.x = value guiDict[fld]['object'].scale.x = value
@@ -524,6 +668,7 @@ let renderFlower = (radius = 1) => {
guiDict[fld]['objectLines'].scale.y = value guiDict[fld]['objectLines'].scale.y = value
guiDict[fld]['objectLines'].scale.z = value guiDict[fld]['objectLines'].scale.z = value
if (guiDict[fld]['circles']) guiDict[fld]['circles'].scale.setScalar(value); if (guiDict[fld]['circles']) guiDict[fld]['circles'].scale.setScalar(value);
if (guiDict[fld]['web']) guiDict[fld]['web'].scale.setScalar(value);
}); });
guiDict[fld]['folder'].add( folderStandard, 'show', true).onChange((value) => { guiDict[fld]['folder'].add( folderStandard, 'show', true).onChange((value) => {
@@ -553,13 +698,32 @@ let renderFlower = (radius = 1) => {
if (guiDict[fld]['circles']) { if (guiDict[fld]['circles']) {
guiDict[fld]['folder'].add( { circles: guiDict[fld]['circles'].visible }, 'circles' ).onChange( (value) => { guiDict[fld]['folder'].add( { circles: guiDict[fld]['circles'].visible }, 'circles' ).onChange( (value) => {
guiDict[fld]['circles'].visible = value; guiDict[fld]['circles'].visible = value;
refreshCircleDupes();
}); });
} }
if (guiDict[fld]['web']) {
guiDict[fld]['folder'].add( { web: false }, 'web' ).onChange( (value) => {
guiDict[fld]['web'].visible = value;
});
}
visibilityControllers.push( ...guiDict[fld]['folder'].controllers.filter( ( c ) => c.property !== 'scale' ) );
} }
gui.add( { innerCircles: true }, 'innerCircles' ).onChange( (value) => { visibilityControllers.push( gui.add( { connections: false }, 'connections' ).onChange( ( value ) => {
innerCircles.visible = value; connectionCircles.visible = value;
}); refreshCircleDupes();
} ) );
visibilityControllers.push( gui.add( { bigCircle: true }, 'bigCircle' ).onChange( ( value ) => {
circleCircum.visible = value;
} ) );
gui.add( { emptyCanvas: () => {
for ( const controller of visibilityControllers ) controller.setValue( false );
} }, 'emptyCanvas' ).name( 'empty canvas' );
// rebuilding destroys and recreates the GUI, which would kill the very // rebuilding destroys and recreates the GUI, which would kill the very
// slider being dragged - so the scene only rebuilds when the drag ends // slider being dragged - so the scene only rebuilds when the drag ends
gui.add( params, 'radius', 0.5, 1.5).onFinishChange( (value) => { gui.add( params, 'radius', 0.5, 1.5).onFinishChange( (value) => {
@@ -569,6 +733,8 @@ let renderFlower = (radius = 1) => {
renderFlower (params['radius']) renderFlower (params['radius'])
}); });
refreshCircleDupes();
// ?only=<fire|air|earth|water|aether> hides the other solids, for // ?only=<fire|air|earth|water|aether> hides the other solids, for
// checking a single shape against the reference // checking a single shape against the reference
const only = debugFlags.get( 'only' ); const only = debugFlags.get( 'only' );
+1
View File
@@ -96,6 +96,7 @@ onUpdate( ( time ) => {
} ); } );
const gui = new GUI(); const gui = new GUI();
if ( window.innerWidth < 700 ) gui.close();
gui.add( params, 'count', 10, MAX_COUNT ).step( 10 ).onChange( renderPattern ); gui.add( params, 'count', 10, MAX_COUNT ).step( 10 ).onChange( renderPattern );
gui.add( params, 'angle', 130, 145 ).step( 0.001 ).onChange( renderPattern ); gui.add( params, 'angle', 130, 145 ).step( 0.001 ).onChange( renderPattern );
gui.add( params, 'resetAngle' ).name( 'reset to golden angle' ); gui.add( params, 'resetAngle' ).name( 'reset to golden angle' );
+6
View File
@@ -26,6 +26,12 @@ camera.position.z = 12;
camera.position.x = 10; camera.position.x = 10;
camera.position.y = 4; camera.position.y = 4;
// portrait and narrow screens crop the sides, hiding the projection wall -
// start further back so wall and shapes both fit the frame
if ( window.innerWidth < window.innerHeight || window.innerWidth < 700 ) {
camera.position.multiplyScalar( 1.8 );
}
const renderer = new THREE.WebGLRenderer( { antialias: true, alpha: true } ); const renderer = new THREE.WebGLRenderer( { antialias: true, alpha: true } );
renderer.setPixelRatio( Math.min( window.devicePixelRatio, 2 ) ); renderer.setPixelRatio( Math.min( window.devicePixelRatio, 2 ) );
+1
View File
@@ -100,6 +100,7 @@ onUpdate( ( time ) => {
} ); } );
const gui = new GUI(); const gui = new GUI();
if ( window.innerWidth < 700 ) gui.close();
gui.add( params, 'radius', 1, 5 ).step( 0.1 ).onChange( renderTorus ); gui.add( params, 'radius', 1, 5 ).step( 0.1 ).onChange( renderTorus );
gui.add( params, 'tube', 0.2, 3 ).step( 0.1 ).onChange( renderTorus ); gui.add( params, 'tube', 0.2, 3 ).step( 0.1 ).onChange( renderTorus );
gui.add( params, 'strands', 1, 32 ).step( 1 ).onChange( renderTorus ); gui.add( params, 'strands', 1, 32 ).step( 1 ).onChange( renderTorus );