Blog polyhedron
The interactive logo from the navigation: a lit physical material morphing between polyhedra, with pointer drag and eased auto-rotation.
View source Hide source 756 lines
'use client';
import React, { useRef, useEffect } from 'react';
import * as THREE from 'three';
/**
* Logo: a glossy mesh that animates through higher-face
* polyhedra. Coloured orbiting point lights provide swimming specular
* highlights via clearcoat. Auto-spin, hover pause, and damped
* pointer-drag.
*/
export default function IcosahedronLogo({ size = 56 }) {
const mountRef = useRef(null);
useEffect(() => {
const el = mountRef.current;
if (!el) return undefined;
const AUTO_SPIN_X = 0.006;
const AUTO_SPIN_Y = 0.01;
const HOVER_SLOWDOWN_EASING = 0.03;
const RESTORE_EASING = 0.12;
const MOTION_STOP_EASING = 0.075;
const MOTION_START_EASING = 0.055;
const MIN_SEGMENT_DURATION = 5.0;
const MAX_SEGMENT_DURATION = 8.5;
const MORPH_FRACTION = 0.45;
const EDGE_OPACITY = 0.38;
const MIN_FACE_COUNT = 12;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 0, 4);
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
});
renderer.setSize(size, size);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
el.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0xffffff, 0.35));
const keyLight = new THREE.DirectionalLight(0xffffff, 1.1);
keyLight.position.set(3, 2, 4);
scene.add(keyLight);
const lightA = new THREE.PointLight(0x7dd3fc, 2.4, 12, 1.6);
const lightB = new THREE.PointLight(0xff8fd1, 2.0, 12, 1.6);
const lightC = new THREE.PointLight(0xfff2b0, 1.4, 12, 1.6);
scene.add(lightA, lightB, lightC);
const makeCubeGeometry = () => new THREE.BoxGeometry(1.45, 1.45, 1.45, 1, 1, 1);
const vectorKey = (vector) => (
`${vector.x.toFixed(5)}:${vector.y.toFixed(5)}:${vector.z.toFixed(5)}`
);
const getFaceCenter = (face, vertices) => {
const center = new THREE.Vector3();
face.forEach((index) => center.add(vertices[index]));
return center.multiplyScalar(1 / face.length);
};
const orientFaceOutward = (face, vertices) => {
if (face.length < 3) return face;
const a = vertices[face[0]];
const b = vertices[face[1]];
const c = vertices[face[2]];
const center = getFaceCenter(face, vertices);
const normal = new THREE.Vector3()
.subVectors(b, a)
.cross(new THREE.Vector3().subVectors(c, a));
return normal.dot(center) >= 0 ? face : [...face].reverse();
};
const sortIndicesAroundAxis = (indices, getPoint, axisSource) => {
const axis = axisSource.clone().normalize();
const fallback = Math.abs(axis.y) < 0.92
? new THREE.Vector3(0, 1, 0)
: new THREE.Vector3(1, 0, 0);
const u = new THREE.Vector3().crossVectors(axis, fallback).normalize();
const v = new THREE.Vector3().crossVectors(axis, u).normalize();
const center = new THREE.Vector3();
indices.forEach((index) => center.add(getPoint(index)));
center.multiplyScalar(1 / indices.length);
return [...indices].sort((a, b) => {
const pa = getPoint(a).clone().sub(center);
const pb = getPoint(b).clone().sub(center);
const angleA = Math.atan2(pa.dot(v), pa.dot(u));
const angleB = Math.atan2(pb.dot(v), pb.dot(u));
return angleA - angleB;
});
};
const extractPolyhedronData = (geometry) => {
const position = geometry.attributes.position;
const index = geometry.index;
const triangleVertexCount = index ? index.count : position.count;
const vertices = [];
const vertexMap = new Map();
const triangles = [];
const getUniqueVertexIndex = (bufferIndex) => {
const vertex = new THREE.Vector3().fromBufferAttribute(position, bufferIndex);
const key = vectorKey(vertex);
if (!vertexMap.has(key)) {
vertexMap.set(key, vertices.length);
vertices.push(vertex);
}
return vertexMap.get(key);
};
for (let i = 0; i < triangleVertexCount; i += 3) {
const a = getUniqueVertexIndex(index ? index.getX(i) : i);
const b = getUniqueVertexIndex(index ? index.getX(i + 1) : i + 1);
const c = getUniqueVertexIndex(index ? index.getX(i + 2) : i + 2);
if (a !== b && b !== c && a !== c) triangles.push([a, b, c]);
}
const planeGroups = [];
triangles.forEach((triangle) => {
const a = vertices[triangle[0]];
const b = vertices[triangle[1]];
const c = vertices[triangle[2]];
const normal = new THREE.Vector3()
.subVectors(b, a)
.cross(new THREE.Vector3().subVectors(c, a))
.normalize();
let d = normal.dot(a);
if (d < 0) {
normal.negate();
d = -d;
}
let group = planeGroups.find(
(candidate) =>
candidate.normal.dot(normal) > 0.9999 &&
Math.abs(candidate.d - d) < 1e-4
);
if (!group) {
group = { normal, d, vertexIds: new Set() };
planeGroups.push(group);
}
triangle.forEach((vertexId) => group.vertexIds.add(vertexId));
});
const faces = planeGroups.map((group) => {
const face = sortIndicesAroundAxis(
[...group.vertexIds],
(index) => vertices[index],
group.normal
);
return orientFaceOutward(face, vertices);
});
geometry.dispose();
return { vertices, faces };
};
const normalizeData = (data) => {
const center = new THREE.Vector3();
data.vertices.forEach((vertex) => center.add(vertex));
center.multiplyScalar(1 / data.vertices.length);
const vertices = data.vertices.map((vertex) => vertex.clone().sub(center));
const maxRadius = vertices.reduce(
(max, vertex) => Math.max(max, vertex.length()),
0
) || 1;
return {
vertices: vertices.map((vertex) => vertex.multiplyScalar(1 / maxRadius)),
faces: data.faces.map((face) => [...face]),
};
};
const rotateData = (data, rotations = {}) => {
const euler = new THREE.Euler(rotations.x || 0, rotations.y || 0, rotations.z || 0);
return normalizeData({
vertices: data.vertices.map((vertex) => vertex.clone().applyEuler(euler)),
faces: data.faces.map((face) => [...face]),
});
};
const createEdgeKey = (a, b) => (a < b ? `${a}:${b}` : `${b}:${a}`);
const createRectifiedData = (baseData) => {
const vertices = [];
const edgeVertexIds = new Map();
const getEdgeVertex = (a, b) => {
const key = createEdgeKey(a, b);
if (!edgeVertexIds.has(key)) {
const midpoint = baseData.vertices[a].clone()
.add(baseData.vertices[b])
.multiplyScalar(0.5);
edgeVertexIds.set(key, vertices.length);
vertices.push(midpoint);
}
return edgeVertexIds.get(key);
};
const faces = baseData.faces.map((face) => (
face.map((vertexId, index) => getEdgeVertex(vertexId, face[(index + 1) % face.length]))
));
const vertexToEdgeVertices = new Map();
baseData.faces.forEach((face) => {
face.forEach((vertexId, index) => {
const nextVertexId = face[(index + 1) % face.length];
const edgeVertexId = getEdgeVertex(vertexId, nextVertexId);
if (!vertexToEdgeVertices.has(vertexId)) vertexToEdgeVertices.set(vertexId, new Set());
if (!vertexToEdgeVertices.has(nextVertexId)) {
vertexToEdgeVertices.set(nextVertexId, new Set());
}
vertexToEdgeVertices.get(vertexId).add(edgeVertexId);
vertexToEdgeVertices.get(nextVertexId).add(edgeVertexId);
});
});
vertexToEdgeVertices.forEach((edgeVertexSet, vertexId) => {
const sortedFace = sortIndicesAroundAxis(
[...edgeVertexSet],
(index) => vertices[index],
baseData.vertices[vertexId]
);
faces.push(sortedFace);
});
return normalizeData({ vertices, faces });
};
const createTruncatedData = (baseData, cut = 1 / 3) => {
const vertices = [];
const directedVertexIds = new Map();
const getDirectedVertex = (from, to) => {
const key = `${from}:${to}`;
if (!directedVertexIds.has(key)) {
const point = baseData.vertices[from].clone()
.multiplyScalar(1 - cut)
.add(baseData.vertices[to].clone().multiplyScalar(cut));
directedVertexIds.set(key, vertices.length);
vertices.push(point);
}
return directedVertexIds.get(key);
};
const faces = baseData.faces.map((face) => {
const truncatedFace = [];
face.forEach((vertexId, index) => {
const previousVertexId = face[(index - 1 + face.length) % face.length];
const nextVertexId = face[(index + 1) % face.length];
truncatedFace.push(getDirectedVertex(vertexId, previousVertexId));
truncatedFace.push(getDirectedVertex(vertexId, nextVertexId));
});
return truncatedFace;
});
const vertexToDirectedVertices = new Map();
baseData.faces.forEach((face) => {
face.forEach((vertexId, index) => {
const previousVertexId = face[(index - 1 + face.length) % face.length];
const nextVertexId = face[(index + 1) % face.length];
if (!vertexToDirectedVertices.has(vertexId)) {
vertexToDirectedVertices.set(vertexId, new Set());
}
vertexToDirectedVertices.get(vertexId).add(getDirectedVertex(vertexId, previousVertexId));
vertexToDirectedVertices.get(vertexId).add(getDirectedVertex(vertexId, nextVertexId));
});
});
vertexToDirectedVertices.forEach((directedVertexSet, vertexId) => {
const sortedFace = sortIndicesAroundAxis(
[...directedVertexSet],
(index) => vertices[index],
baseData.vertices[vertexId]
);
faces.push(sortedFace);
});
return normalizeData({ vertices, faces });
};
const cubeData = normalizeData(extractPolyhedronData(makeCubeGeometry()));
const octahedronData = normalizeData(
extractPolyhedronData(new THREE.OctahedronGeometry(1, 0))
);
const dodecahedronData = normalizeData(
extractPolyhedronData(new THREE.DodecahedronGeometry(1, 0))
);
const icosahedronData = normalizeData(
extractPolyhedronData(new THREE.IcosahedronGeometry(1, 0))
);
const cuboctahedronData = createRectifiedData(cubeData);
const icosidodecahedronData = createRectifiedData(icosahedronData);
const truncatedCubeData = createTruncatedData(cubeData);
const truncatedOctahedronData = createTruncatedData(octahedronData);
const truncatedDodecahedronData = createTruncatedData(dodecahedronData);
const truncatedIcosahedronData = createTruncatedData(icosahedronData);
const polyhedronDefinitions = [
{
key: 'dodecahedron',
visualFamily: 'dodecahedron',
category: 'polyhedron',
faceCount: 12,
edgeCount: 30,
createData: () => rotateData(dodecahedronData, { x: Math.PI / 9 }),
},
{
key: 'cuboctahedron',
visualFamily: 'cuboctahedron',
category: 'polyhedron',
faceCount: 14,
edgeCount: 24,
createData: () => rotateData(cuboctahedronData, { x: Math.PI / 8 }),
},
{
key: 'truncated-cube',
visualFamily: 'truncated-cube',
category: 'polyhedron',
faceCount: 14,
edgeCount: 36,
createData: () => rotateData(truncatedCubeData, { y: Math.PI / 7 }),
},
{
key: 'truncated-octahedron',
visualFamily: 'truncated-octahedron',
category: 'polyhedron',
faceCount: 14,
edgeCount: 36,
createData: () => rotateData(truncatedOctahedronData, { x: Math.PI / 7 }),
},
{
key: 'icosahedron',
visualFamily: 'icosahedron',
category: 'polyhedron',
faceCount: 20,
edgeCount: 30,
createData: () => rotateData(icosahedronData, { y: Math.PI / 6 }),
},
{
key: 'icosidodecahedron',
visualFamily: 'icosidodecahedron',
category: 'polyhedron',
faceCount: 32,
edgeCount: 60,
createData: () => rotateData(icosidodecahedronData, { z: Math.PI / 10 }),
},
{
key: 'truncated-dodecahedron',
visualFamily: 'truncated-dodecahedron',
category: 'polyhedron',
faceCount: 32,
edgeCount: 90,
createData: () => rotateData(truncatedDodecahedronData, { x: Math.PI / 9 }),
},
{
key: 'truncated-icosahedron',
visualFamily: 'truncated-icosahedron',
category: 'polyhedron',
faceCount: 32,
edgeCount: 90,
createData: () => rotateData(truncatedIcosahedronData, { y: Math.PI / 9 }),
},
].filter(({ faceCount }) => faceCount >= MIN_FACE_COUNT);
const applyVertexColors = (geometry) => {
const position = geometry.attributes.position;
const colors = [];
const c1 = new THREE.Color('#1ee3a6');
const c2 = new THREE.Color('#2b5cff');
const blend = new THREE.Color();
let minY = Infinity;
let maxY = -Infinity;
for (let i = 0; i < position.count; i++) {
const y = position.getY(i);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
}
const spanY = maxY - minY || 1;
for (let i = 0; i < position.count; i++) {
const t = THREE.MathUtils.clamp((position.getY(i) - minY) / spanY, 0, 1);
blend.copy(c1).lerp(c2, 1 - t);
colors.push(blend.r, blend.g, blend.b);
}
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
return geometry;
};
const getFacePlanes = (data) => data.faces.map((face) => {
const orientedFace = orientFaceOutward(face, data.vertices);
const a = data.vertices[orientedFace[0]];
const b = data.vertices[orientedFace[1]];
const c = data.vertices[orientedFace[2]];
const normal = new THREE.Vector3()
.subVectors(b, a)
.cross(new THREE.Vector3().subVectors(c, a))
.normalize();
let d = normal.dot(a);
if (d < 0) {
normal.negate();
d = -d;
}
return { normal, d };
});
const sphereGeometry = new THREE.IcosahedronGeometry(1, 3).toNonIndexed();
const spherePosition = sphereGeometry.attributes.position;
const sphereDirections = [];
for (let i = 0; i < spherePosition.count; i++) {
sphereDirections.push(
new THREE.Vector3()
.fromBufferAttribute(spherePosition, i)
.normalize()
);
}
const computeMorphTarget = (data) => {
const facePlanes = getFacePlanes(data);
const positions = new Float32Array(sphereDirections.length * 3);
const faceIds = new Int32Array(sphereDirections.length);
let maxRadius = 0;
sphereDirections.forEach((direction, index) => {
let bestFaceIndex = 0;
let bestDot = -Infinity;
facePlanes.forEach((plane, faceIndex) => {
const dot = plane.normal.dot(direction);
if (dot > bestDot) {
bestDot = dot;
bestFaceIndex = faceIndex;
}
});
const plane = facePlanes[bestFaceIndex];
const scale = bestDot > 0.0001 ? plane.d / bestDot : 1;
const point = direction.clone().multiplyScalar(scale);
const offset = index * 3;
positions[offset] = point.x;
positions[offset + 1] = point.y;
positions[offset + 2] = point.z;
faceIds[index] = bestFaceIndex;
maxRadius = Math.max(maxRadius, point.length());
});
if (maxRadius > 0) {
for (let i = 0; i < positions.length; i++) {
positions[i] /= maxRadius;
}
}
const edgeSet = new Set();
const edgeIndices = [];
const addEdge = (a, b) => {
if (faceIds[a] === faceIds[b]) return;
const key = a < b ? `${a}:${b}` : `${b}:${a}`;
if (edgeSet.has(key)) return;
edgeSet.add(key);
edgeIndices.push(a, b);
};
for (let i = 0; i < sphereDirections.length; i += 3) {
addEdge(i, i + 1);
addEdge(i + 1, i + 2);
addEdge(i, i + 2);
}
return { positions, edgeIndices };
};
const polyhedronTargets = polyhedronDefinitions.map((definition) => {
const data = normalizeData(definition.createData());
return {
...definition,
actualFaceCount: data.faces.length,
data,
morph: computeMorphTarget(data),
};
}).filter(({ actualFaceCount }) => actualFaceCount >= MIN_FACE_COUNT);
if (polyhedronTargets.length === 0) {
renderer.dispose();
if (el.contains(renderer.domElement)) el.removeChild(renderer.domElement);
return undefined;
}
const polyhedronTargetsByKey = Object.fromEntries(
polyhedronTargets.map((target) => [target.key, target])
);
const shapeKeys = polyhedronTargets.map(({ key }) => key);
const visualFamilies = Object.fromEntries(
polyhedronTargets.map(({ key, visualFamily }) => [key, visualFamily])
);
const currentInitialKey = shapeKeys.includes('icosidodecahedron')
? 'icosidodecahedron'
: shapeKeys[0];
const geometry = sphereGeometry.clone();
geometry.setAttribute(
'position',
new THREE.Float32BufferAttribute(polyhedronTargetsByKey[currentInitialKey].morph.positions, 3)
);
geometry.computeVertexNormals();
applyVertexColors(geometry);
const material = new THREE.MeshPhysicalMaterial({
vertexColors: true,
metalness: 0.45,
roughness: 0.18,
clearcoat: 1.0,
clearcoatRoughness: 0.08,
reflectivity: 0.6,
flatShading: true,
opacity: 1,
});
const polyhedronGroup = new THREE.Group();
const mesh = new THREE.Mesh(geometry, material);
polyhedronGroup.add(mesh);
const edgeMaterial = new THREE.LineBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: EDGE_OPACITY,
depthTest: true,
depthWrite: false,
});
const edgeGeometry = new THREE.BufferGeometry();
const edges = new THREE.LineSegments(edgeGeometry, edgeMaterial);
edges.renderOrder = 2;
polyhedronGroup.add(edges);
scene.add(polyhedronGroup);
const randomDuration = () => (
MIN_SEGMENT_DURATION + Math.random() * (MAX_SEGMENT_DURATION - MIN_SEGMENT_DURATION)
);
const pickNextShape = (currentKey, previousKey) => {
if (shapeKeys.length <= 1) return currentKey;
const candidates = shapeKeys.filter(
(key) =>
key !== currentKey &&
key !== previousKey &&
visualFamilies[key] !== visualFamilies[currentKey] &&
(!previousKey || visualFamilies[key] !== visualFamilies[previousKey])
);
const pool = candidates.length > 0
? candidates
: shapeKeys.filter((key) => key !== currentKey);
return pool[Math.floor(Math.random() * pool.length)];
};
let previousShapeKey = null;
let currentShapeKey = currentInitialKey;
let nextShapeKey = pickNextShape(currentShapeKey, previousShapeKey);
let transitionElapsed = 0;
let transitionDuration = randomDuration();
const getMergedEdgeIndices = (fromTarget, toTarget) => {
const edgeSet = new Set();
const merged = [];
const addPair = (a, b) => {
const key = a < b ? `${a}:${b}` : `${b}:${a}`;
if (edgeSet.has(key)) return;
edgeSet.add(key);
merged.push(a, b);
};
[fromTarget, toTarget].forEach((target) => {
for (let i = 0; i < target.morph.edgeIndices.length; i += 2) {
addPair(target.morph.edgeIndices[i], target.morph.edgeIndices[i + 1]);
}
});
return merged;
};
const applyPhase = (fromKey, toKey, t) => {
const fromTarget = polyhedronTargetsByKey[fromKey];
const toTarget = polyhedronTargetsByKey[toKey];
if (!fromTarget || !toTarget) return;
const phaseT = t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2;
const position = geometry.attributes.position;
const fromPositions = fromTarget.morph.positions;
const toPositions = toTarget.morph.positions;
for (let i = 0; i < position.array.length; i++) {
position.array[i] = THREE.MathUtils.lerp(fromPositions[i], toPositions[i], phaseT);
}
position.needsUpdate = true;
geometry.computeVertexNormals();
const edgeIndices = getMergedEdgeIndices(fromTarget, toTarget);
const edgePositions = new Float32Array(edgeIndices.length * 3);
edgeIndices.forEach((vertexIndex, index) => {
const sourceOffset = vertexIndex * 3;
const targetOffset = index * 3;
edgePositions[targetOffset] = position.array[sourceOffset];
edgePositions[targetOffset + 1] = position.array[sourceOffset + 1];
edgePositions[targetOffset + 2] = position.array[sourceOffset + 2];
});
edgeGeometry.setAttribute('position', new THREE.Float32BufferAttribute(edgePositions, 3));
};
applyPhase(currentShapeKey, nextShapeKey, 0);
let isDragging = false;
let isHovered = false;
let lastX = 0;
let lastY = 0;
let velX = AUTO_SPIN_Y;
let velY = AUTO_SPIN_X;
const onPointerDown = (e) => {
isDragging = true;
lastX = e.clientX;
lastY = e.clientY;
velX = 0;
velY = 0;
};
const onPointerMove = (e) => {
if (!isDragging) return;
const dx = e.clientX - lastX;
const dy = e.clientY - lastY;
polyhedronGroup.rotation.y += dx * 0.015;
polyhedronGroup.rotation.x += dy * 0.015;
velX = dy * 0.015;
velY = dx * 0.015;
lastX = e.clientX;
lastY = e.clientY;
};
const onPointerUp = () => { isDragging = false; };
const onPointerEnter = () => { isHovered = true; };
const onPointerLeave = () => { isHovered = false; };
renderer.domElement.addEventListener('pointerdown', onPointerDown);
renderer.domElement.addEventListener('pointerenter', onPointerEnter);
renderer.domElement.addEventListener('pointerleave', onPointerLeave);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
const clock = new THREE.Clock();
let rafId;
let lightTime = 0;
let motionSpeed = 1;
const animate = () => {
rafId = requestAnimationFrame(animate);
const delta = Math.min(clock.getDelta(), 0.05);
const targetMotionSpeed = isHovered ? 0 : 1;
const motionEasing = isHovered ? MOTION_STOP_EASING : MOTION_START_EASING;
motionSpeed += (targetMotionSpeed - motionSpeed) * motionEasing;
if (motionSpeed < 0.001) motionSpeed = 0;
if (motionSpeed > 0.999) motionSpeed = 1;
const animatedDelta = delta * motionSpeed;
if (animatedDelta > 0) {
transitionElapsed += animatedDelta;
lightTime += animatedDelta;
if (transitionElapsed >= transitionDuration) {
transitionElapsed -= transitionDuration;
previousShapeKey = currentShapeKey;
currentShapeKey = nextShapeKey;
nextShapeKey = pickNextShape(currentShapeKey, previousShapeKey);
transitionDuration = randomDuration();
}
}
const localRaw = transitionElapsed / transitionDuration;
const holdEach = (1 - MORPH_FRACTION) / 2;
const morphRaw = Math.max(0, Math.min(1, (localRaw - holdEach) / MORPH_FRACTION));
const t = morphRaw < 0.5
? 4 * morphRaw * morphRaw * morphRaw
: 1 - Math.pow(-2 * morphRaw + 2, 3) / 2;
applyPhase(currentShapeKey, nextShapeKey, t);
lightA.position.set(
Math.cos(lightTime * 0.9) * 3.2,
Math.sin(lightTime * 0.7) * 2.4,
Math.sin(lightTime * 0.6) * 2.0 + 1.5
);
lightB.position.set(
Math.cos(lightTime * 0.6 + 2.1) * 3.0,
Math.cos(lightTime * 0.85 + 1.0) * 2.6,
Math.sin(lightTime * 0.5 + 0.5) * 2.0 + 1.0
);
lightC.position.set(
Math.sin(lightTime * 1.2 + 0.8) * 2.4,
Math.cos(lightTime * 1.0 + 2.5) * 2.0,
Math.cos(lightTime * 0.7) * 1.6 + 1.2
);
if (!isDragging) {
const targetX = AUTO_SPIN_X * motionSpeed;
const targetY = AUTO_SPIN_Y * motionSpeed;
const easing = isHovered ? HOVER_SLOWDOWN_EASING : RESTORE_EASING;
velX += (targetX - velX) * easing;
velY += (targetY - velY) * easing;
polyhedronGroup.rotation.x += velX;
polyhedronGroup.rotation.y += velY;
}
renderer.render(scene, camera);
};
animate();
return () => {
cancelAnimationFrame(rafId);
renderer.domElement.removeEventListener('pointerdown', onPointerDown);
renderer.domElement.removeEventListener('pointerenter', onPointerEnter);
renderer.domElement.removeEventListener('pointerleave', onPointerLeave);
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
sphereGeometry.dispose();
geometry.dispose();
edgeGeometry.dispose();
material.dispose();
edgeMaterial.dispose();
renderer.dispose();
if (el.contains(renderer.domElement)) el.removeChild(renderer.domElement);
};
}, [size]);
return (
<div
ref={mountRef}
style={{
width: size,
height: size,
display: 'inline-block',
cursor: 'pointer',
flexShrink: 0,
}}
/>
);
}