|
|
|
@@ -3,12 +3,14 @@ import * as THREE from 'three';
|
|
|
|
|
|
|
|
|
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.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 { sceneColors } from './lib/theme.js';
|
|
|
|
|
import { bindWorld } from './lib/transit.js';
|
|
|
|
|
import { bindGlow } from './lib/glow.js';
|
|
|
|
|
import { makeTube } from './lib/edgeTubes.js';
|
|
|
|
|
import { bindCameraMemory } from './lib/cameraMemory.js';
|
|
|
|
|
|
|
|
|
|
const colors = sceneColors();
|
|
|
|
@@ -25,6 +27,12 @@ camera.position.z = 12;
|
|
|
|
|
camera.position.x = 10;
|
|
|
|
|
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
|
|
|
|
|
const debugFlags = new URLSearchParams( window.location.search );
|
|
|
|
|
|
|
|
|
@@ -263,15 +271,12 @@ let renderFlower = (radius = 1) => {
|
|
|
|
|
transparent: true
|
|
|
|
|
} );
|
|
|
|
|
|
|
|
|
|
const occupiedSpots = new Set();
|
|
|
|
|
|
|
|
|
|
// one circle per spot: if any group already placed a circle here (fire and
|
|
|
|
|
// earth share all eight cube corners), later ones are skipped
|
|
|
|
|
const addCircleMeshes = ( point, group ) => {
|
|
|
|
|
const spot = point.toArray().map( ( n ) => n.toFixed( 2 ) ).join( ',' );
|
|
|
|
|
if ( occupiedSpots.has( spot ) ) return;
|
|
|
|
|
occupiedSpots.add( spot );
|
|
|
|
|
// 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
|
|
|
|
|
// visible owner renders its circle - every group keeps its full set
|
|
|
|
|
const circleRegistry = new Map();
|
|
|
|
|
|
|
|
|
|
const placeCircleMeshes = ( point, group ) => {
|
|
|
|
|
const sphere = new THREE.Mesh( circleSphereGeometry, circleSphereMaterial );
|
|
|
|
|
sphere.position.copy( point );
|
|
|
|
|
group.add( sphere );
|
|
|
|
@@ -281,12 +286,35 @@ let renderFlower = (radius = 1) => {
|
|
|
|
|
ring.position.copy( point );
|
|
|
|
|
ring.castShadow = true;
|
|
|
|
|
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 makeVertexCircles = ( geometry, rotation ) => {
|
|
|
|
|
const group = new THREE.Group();
|
|
|
|
|
const effectivelyVisible = ( object ) => {
|
|
|
|
|
for ( let o = object; o && o !== world; o = o.parent ) {
|
|
|
|
|
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 vertices = [];
|
|
|
|
|
const positions = geometry.attributes.position;
|
|
|
|
|
const vertex = new THREE.Vector3();
|
|
|
|
|
|
|
|
|
@@ -295,11 +323,18 @@ let renderFlower = (radius = 1) => {
|
|
|
|
|
const key = vertex.toArray().map( ( n ) => n.toFixed( 3 ) ).join( ',' );
|
|
|
|
|
if ( seen.has( key ) ) continue;
|
|
|
|
|
seen.add( key );
|
|
|
|
|
|
|
|
|
|
const point = vertex.clone().applyMatrix4( rotation );
|
|
|
|
|
addCircleMeshes( point, group );
|
|
|
|
|
vertices.push( vertex.clone() );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
@@ -435,6 +470,11 @@ let renderFlower = (radius = 1) => {
|
|
|
|
|
world.add( icosaLines );
|
|
|
|
|
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 );
|
|
|
|
|
|
|
|
|
|
// 2.29 * 1.003: tuned against the circle pattern
|
|
|
|
@@ -462,18 +502,88 @@ let renderFlower = (radius = 1) => {
|
|
|
|
|
world.add( dodecaLines );
|
|
|
|
|
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
|
|
|
|
|
const innerCircles = new THREE.Group();
|
|
|
|
|
|
|
|
|
|
// the six edge midpoints of the 2D pattern's inner ring - Water's inner
|
|
|
|
|
// circle centres
|
|
|
|
|
const innerPoints = [];
|
|
|
|
|
for ( let s of [ -1, 1 ] ) {
|
|
|
|
|
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
|
|
|
|
|
let circleGeometry = new THREE.TorusGeometry( 5 * radius, params['tube'], 64, 64);
|
|
|
|
@@ -487,23 +597,55 @@ let renderFlower = (radius = 1) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
gui = new GUI();
|
|
|
|
|
if ( window.innerWidth < 700 ) gui.close();
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
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');
|
|
|
|
|
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');
|
|
|
|
|
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');
|
|
|
|
|
guiDict['water'] = {'folder': water, 'object': icosa, 'objectLines': icosaLines, 'circles': icosaCircles};
|
|
|
|
|
let fakeWaterFolder = gui.addFolder('Water');
|
|
|
|
|
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')
|
|
|
|
|
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 = {
|
|
|
|
|
show: true,
|
|
|
|
@@ -514,7 +656,9 @@ let renderFlower = (radius = 1) => {
|
|
|
|
|
|
|
|
|
|
for (let fld of Object.keys(guiDict)) {
|
|
|
|
|
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]['object'].scale.x = value
|
|
|
|
@@ -524,6 +668,7 @@ let renderFlower = (radius = 1) => {
|
|
|
|
|
guiDict[fld]['objectLines'].scale.y = value
|
|
|
|
|
guiDict[fld]['objectLines'].scale.z = 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) => {
|
|
|
|
@@ -553,13 +698,32 @@ let renderFlower = (radius = 1) => {
|
|
|
|
|
if (guiDict[fld]['circles']) {
|
|
|
|
|
guiDict[fld]['folder'].add( { circles: guiDict[fld]['circles'].visible }, 'circles' ).onChange( (value) => {
|
|
|
|
|
guiDict[fld]['circles'].visible = value;
|
|
|
|
|
refreshCircleDupes();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
gui.add( { innerCircles: true }, 'innerCircles' ).onChange( (value) => {
|
|
|
|
|
innerCircles.visible = value;
|
|
|
|
|
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' ) );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
visibilityControllers.push( gui.add( { connections: false }, 'connections' ).onChange( ( 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
|
|
|
|
|
// slider being dragged - so the scene only rebuilds when the drag ends
|
|
|
|
|
gui.add( params, 'radius', 0.5, 1.5).onFinishChange( (value) => {
|
|
|
|
@@ -569,6 +733,8 @@ let renderFlower = (radius = 1) => {
|
|
|
|
|
renderFlower (params['radius'])
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
refreshCircleDupes();
|
|
|
|
|
|
|
|
|
|
// ?only=<fire|air|earth|water|aether> hides the other solids, for
|
|
|
|
|
// checking a single shape against the reference
|
|
|
|
|
const only = debugFlags.get( 'only' );
|
|
|
|
|