Skip to content

Instantly share code, notes, and snippets.

@903124
Last active November 15, 2025 16:37
Show Gist options
  • Select an option

  • Save 903124/872af01bcbc7fa412f89a07b494aa3af to your computer and use it in GitHub Desktop.

Select an option

Save 903124/872af01bcbc7fa412f89a07b494aa3af to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Baseball Seam visualizer</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
margin: 0;
padding: 0;
background-color: #1a1a1a;
color: #f0f0f0;
display: flex;
flex-direction: column;
align-items: center;
}
#container {
width: 100%;
max-width: 1000px;
margin: 1rem auto;
border-radius: 12px;
overflow: hidden;
background-color: #2a2a2a;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
#canvas-container {
width: 100%;
aspect-ratio: 16 / 10;
position: relative;
}
canvas { display: block; width: 100%; height: 100%; }
#controls {
padding: 1.25rem 1.5rem 1rem 1.5rem;
background-color: #333;
border-top: 1px solid #444;
}
h1 {
text-align: center;
color: #fff;
margin-top: 1.2rem;
margin-bottom: 0.6rem;
font-weight: 500;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(240px, 1fr));
gap: 1rem 2rem;
}
.slider-group label {
display: block;
margin-bottom: 0.35rem;
font-weight: 500;
}
.slider-group label span {
float: right;
font-weight: normal;
color: #aaa;
background-color: #444;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.9em;
min-width: 48px;
text-align: center;
}
input[type="range"] { width: 100%; cursor: pointer; }
#legend {
display: flex;
justify-content: center;
gap: 1.5rem;
padding: 0.6rem 0 0.4rem 0;
font-size: 0.9em;
}
.legend-item { display: flex; align-items: center; gap: 0.5rem; }
.color-box { width: 14px; height: 14px; border-radius: 3px; }
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/controls/OrbitControls.js"></script>
</head>
<body>
<h1>Baseball Seam visualizer</h1>
<div id="container">
<div id="canvas-container"></div>
<div id="legend">
<div class="legend-item"><div class="color-box" style="background-color:#ff0000;"></div><span>Seam</span></div>
<div class="legend-item"><div class="color-box" style="background-color:#00ff00;"></div><span>Pitch Direction (+Y)</span></div>
<div class="legend-item"><div class="color-box" style="background-color:#0000ff;"></div><span>Final Spin Axis</span></div>
</div>
<div id="controls">
<div class="grid">
<div class="slider-group">
<label for="spinDirection">Spin Direction (°) <span id="spinDirectionValue"></span></label>
<input type="range" id="spinDirection" min="0" max="360" value="0" step="1">
</div>
<div class="slider-group">
<label for="spinModeToggle">Use Spin Efficiency (toggle on) / Gyro Spin (toggle off)</label>
<input type="checkbox" id="spinModeToggle" checked>
<label for="spinEfficiency"><span id="spinLabelText">Spin Efficiency</span> <span id="spinEfficiencyValue">1.00</span></label>
<input type="range" id="spinEfficiency" min="0" max="1" value="1" step="0.01">
</div>
<div class="slider-group">
<label for="longtitude">Longtitude (°) <span id="longtitudeValue"></span></label>
<input type="range" id="longtitude" min="-180" max="180" value="0" step="1">
</div>
<div class="slider-group">
<label for="latitude">Latitude (°) <span id="latitudeValue"></span></label>
<input type="range" id="latitude" min="-90" max="90" value="0" step="1">
</div>
<div class="slider-group">
<label for="spinRate">Spin Rate (RPM) <span id="spinRateValue">30</span></label>
<input type="range" id="spinRate" min="0" max="3600" value="30" step="10">
</div>
<div class="slider-group">
<label for="azimuth">Azimuth (°) <span id="azimuthValue"></span></label>
<input type="range" id="azimuth" min="-180" max="180" value="0" step="1">
</div>
<div class="slider-group">
<label for="elevation">Elevation (°) <span id="elevationValue"></span></label>
<input type="range" id="elevation" min="-90" max="90" value="0" step="1">
</div>
<div class="slider-group">
<label for="viewMesh">Toggle seam sweep surface</label>
<input type="checkbox" id="viewMesh" checked>
</div>
<div classs="slider-group">
<label for="showSeam">Show Seam</label>
<input type="checkbox" id="showSeam" checked>
</div>
</div>
</div>
</div>
<script>
// --- Parameters ---
const A = 0.4; // Seam curve parameter
const SEAM_POINTS = 160; // Seam points for speed
const T_MAX = 4 * Math.PI; // Parametric range
const WIDTH_SEGMENTS = 36; // Sphere resolution (lon) - sparser
const HEIGHT_SEGMENTS = 18; // Sphere resolution (lat) - sparser
const ROTATION_STEPS = 180; // Steps per revolution
const SEAM_WIDTH_DEG = 3.0; // Seam half-width (degrees)
const SEAM_RADIUS = 0.025; // Seam tube radius
// --- Globals ---
let scene, camera, renderer, controls;
let mesh, geometry, colorsAttr, positionsAttr;
let wireframe;
let pitchArrow, axisArrow;
let rotationIndicatorGroup, rotationRing, rotationDirArrow;
let lastCameraQuat = new THREE.Quaternion();
let pitchArrowQuat = new THREE.Quaternion();
let seamLine, seamLinePositions;
let seamGroup, seamTube;
let finalAxisVec = new THREE.Vector3(0, 1, 0);
let rotationAxisVec = new THREE.Vector3(0, 1, 0);
let seamPointsOriginal = [];
let seamPointsBase = [];
let startTimeMs = 0;
let latArray, lonArray, sinLatArray, cosLatArray, dLat, dLon, latMin, lonMin;
// UI Elements
const spinDirectionSlider = document.getElementById('spinDirection');
const spinEfficiencySlider = document.getElementById('spinEfficiency');
const spinModeToggle = document.getElementById('spinModeToggle');
const longtitudeSlider = document.getElementById('longtitude');
const latitudeSlider = document.getElementById('latitude');
const spinRateSlider = document.getElementById('spinRate');
const spinDirectionValue = document.getElementById('spinDirectionValue');
const spinEfficiencyValue = document.getElementById('spinEfficiencyValue');
const spinLabelText = document.getElementById('spinLabelText');
const longtitudeValue = document.getElementById('longtitudeValue');
const latitudeValue = document.getElementById('latitudeValue');
const spinRateValue = document.getElementById('spinRateValue');
const viewMeshCheckbox = document.getElementById('viewMesh');
const showSeamCheckbox = document.getElementById('showSeam');
const azimuthSlider = document.getElementById('azimuth');
const elevationSlider = document.getElementById('elevation');
const azimuthValue = document.getElementById('azimuthValue');
const elevationValue = document.getElementById('elevationValue');
// Track sign for gyro mode when user switches back and forth
let gyroSpinSign = 1; // default positive rotation
let currentSpinSign = 1; // applied in animation and indicator
let lastSpinModeIsEfficiency = null; // sentinel to avoid double conversion on init
// Seam parametric point on unit sphere (matches seam.html)
function getSeamPoint(t) {
const cos_t = Math.cos(t);
const sin_t = Math.sin(t);
const cos_2t = Math.cos(2 * t);
const sin_2t = Math.sin(2 * t);
const y = (1 - A) * cos_t * sin_2t + A * sin_t;
const z = (1 - A) * sin_t * sin_2t + A * cos_t;
const yz_sq = y * y + z * z;
const sqrt_term = Math.sqrt(Math.max(0, 1 - yz_sq));
const x = Math.sign(cos_2t) * sqrt_term;
return new THREE.Vector3(x, y, z);
}
function generateOriginalSeamPoints() {
seamPointsOriginal = [];
for (let i = 0; i < SEAM_POINTS; i++) {
const t = (i / (SEAM_POINTS - 1)) * T_MAX;
seamPointsOriginal.push(getSeamPoint(t));
}
}
function createSphereGeometry(widthSegs, heightSegs) {
// Build a UV sphere grid with (heightSegs+1) x (widthSegs+1) vertices
const numVerts = (heightSegs + 1) * (widthSegs + 1);
const positions = new Float32Array(numVerts * 3);
const colors = new Float32Array(numVerts * 3);
const idx = new Uint32Array(heightSegs * widthSegs * 6);
latMin = -Math.PI / 2; const latMax = Math.PI / 2; dLat = (latMax - latMin) / heightSegs;
lonMin = 0.0; const lonMax = 2 * Math.PI; dLon = (lonMax - lonMin) / widthSegs;
latArray = new Float32Array(heightSegs + 1);
sinLatArray = new Float32Array(heightSegs + 1);
cosLatArray = new Float32Array(heightSegs + 1);
lonArray = new Float32Array(widthSegs + 1);
for (let i = 0; i <= heightSegs; i++) {
const lat = latMin + i * dLat;
latArray[i] = lat;
sinLatArray[i] = Math.sin(lat);
cosLatArray[i] = Math.cos(lat);
}
for (let j = 0; j <= widthSegs; j++) {
lonArray[j] = lonMin + j * dLon;
}
// Fill positions on unit sphere and init colors
let v = 0;
for (let i = 0; i <= heightSegs; i++) {
const cosLat = cosLatArray[i];
const sinLat = sinLatArray[i];
for (let j = 0; j <= widthSegs; j++) {
const lon = lonArray[j];
const x = cosLat * Math.cos(lon);
const y = cosLat * Math.sin(lon);
const z = sinLat;
positions[3 * v + 0] = x;
positions[3 * v + 1] = y;
positions[3 * v + 2] = z;
colors[3 * v + 0] = 0.1;
colors[3 * v + 1] = 0.1;
colors[3 * v + 2] = 0.1;
v++;
}
}
// Build indices
let k = 0;
for (let i = 0; i < heightSegs; i++) {
for (let j = 0; j < widthSegs; j++) {
const i0 = i * (widthSegs + 1) + j;
const i1 = (i + 1) * (widthSegs + 1) + j;
const i2 = i * (widthSegs + 1) + (j + 1);
const i3 = (i + 1) * (widthSegs + 1) + (j + 1);
idx[k++] = i0; idx[k++] = i1; idx[k++] = i2;
idx[k++] = i1; idx[k++] = i3; idx[k++] = i2;
}
}
const geom = new THREE.BufferGeometry();
positionsAttr = new THREE.BufferAttribute(positions, 3);
colorsAttr = new THREE.BufferAttribute(colors, 3);
geom.setAttribute('position', positionsAttr);
geom.setAttribute('color', colorsAttr);
geom.setIndex(new THREE.BufferAttribute(idx, 1));
geom.computeVertexNormals();
return geom;
}
function colormapPlasma01(t) {
// 6-stop approximation of Plasma colormap (0..1)
const stops = [
[13/255, 8/255, 135/255],
[84/255, 2/255, 163/255],
[148/255, 55/255, 157/255],
[208/255, 109/255, 100/255],
[253/255, 188/255, 64/255],
[240/255, 249/255, 33/255]
];
const x = Math.min(1, Math.max(0, t));
const n = stops.length - 1;
const f = x * n; const i = Math.floor(f); const w = f - i;
const c0 = stops[i]; const c1 = stops[Math.min(n, i + 1)];
return [
c0[0] * (1 - w) + c1[0] * w,
c0[1] * (1 - w) + c1[1] * w,
c0[2] * (1 - w) + c1[2] * w
];
}
function quaternionFromAxisAngle(axis, angle) {
const q = new THREE.Quaternion();
q.setFromAxisAngle(axis, angle);
return q;
}
let updateQueued = false;
function scheduleUpdate() {
if (updateQueued) return;
updateQueued = true;
requestAnimationFrame(() => { updateQueued = false; computeAndColor(); });
}
function init() {
const container = document.getElementById('canvas-container');
scene = new THREE.Scene();
const aspect = container.clientWidth / container.clientHeight;
camera = new THREE.PerspectiveCamera(50, aspect, 0.1, 100);
camera.position.set(0, 1.6, 3.6);
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(renderer.domElement);
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; controls.dampingFactor = 0.05;
controls.addEventListener('change', onControlsChange);
scene.add(new THREE.AmbientLight(0x808080));
const dir = new THREE.DirectionalLight(0xffffff, 0.9); dir.position.set(2, 3, 4); scene.add(dir);
// Sphere geometry & mesh (unit sphere with vertex colors)
geometry = createSphereGeometry(WIDTH_SEGMENTS, HEIGHT_SEGMENTS);
const surfaceMaterial = new THREE.MeshPhongMaterial({ vertexColors: true, shininess: 10 });
const meshMaterial = new THREE.MeshPhongMaterial({ color: 0xffffff, shininess: 20, vertexColors: false });
mesh = new THREE.Mesh(geometry, surfaceMaterial);
mesh.userData.surfaceMaterial = surfaceMaterial;
mesh.userData.meshMaterial = meshMaterial;
scene.add(mesh);
// Wireframe mesh (edges) for toggle view
const edges = new THREE.EdgesGeometry(geometry);
const lineMat = new THREE.LineBasicMaterial({ color: 0xffffff, opacity: 0.9, transparent: true });
wireframe = new THREE.LineSegments(edges, lineMat);
wireframe.visible = false;
scene.add(wireframe);
// Arrows
pitchArrow = new THREE.ArrowHelper(new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 0), 1.6, 0x00ff00, 0.2, 0.1);
axisArrow = new THREE.ArrowHelper(new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 0), 1.6, 0x0000ff, 0.2, 0.1);
scene.add(pitchArrow); scene.add(axisArrow);
// Rotation direction indicator (curved arrow around axis)
rotationIndicatorGroup = new THREE.Group();
scene.add(rotationIndicatorGroup);
// Seam points (original, on unit sphere)
generateOriginalSeamPoints();
// Seam group (will hold either tube or line)
seamGroup = new THREE.Group();
scene.add(seamGroup);
// Fallback line (hidden when tube present)
const seamGeom = new THREE.BufferGeometry();
seamLinePositions = new Float32Array(SEAM_POINTS * 3);
seamGeom.setAttribute('position', new THREE.BufferAttribute(seamLinePositions, 3));
const seamMat = new THREE.LineBasicMaterial({ color: 0xff0000 });
seamLine = new THREE.Line(seamGeom, seamMat);
seamLine.visible = false;
seamGroup.add(seamLine);
const updateLabel = () => {
spinDirectionValue.textContent = `${spinDirectionSlider.value}°`;
// Display value per mode (efficiency 0..1 or gyro -1..1)
spinEfficiencyValue.textContent = `${parseFloat(spinEfficiencySlider.value).toFixed(2)}`;
longtitudeValue.textContent = `${longtitudeSlider.value}°`;
latitudeValue.textContent = `${latitudeSlider.value}°`;
spinRateValue.textContent = `${spinRateSlider.value}`;
// label text no longer shows mode; checkbox text is static
azimuthValue.textContent = `${azimuthSlider.value}°`;
elevationValue.textContent = `${elevationSlider.value}°`;
};
spinDirectionSlider.addEventListener('input', () => { updateLabel(); scheduleUpdate(); });
spinEfficiencySlider.addEventListener('input', () => {
// Update gyro sign only in gyro mode
if (!spinModeToggle.checked) {
const g = parseFloat(spinEfficiencySlider.value);
if (g !== 0) gyroSpinSign = -Math.sign(g);
}
updateLabel();
scheduleUpdate();
});
longtitudeSlider.addEventListener('input', () => { updateLabel(); scheduleUpdate(); });
latitudeSlider.addEventListener('input', () => { updateLabel(); scheduleUpdate(); });
spinRateSlider.addEventListener('input', () => { updateLabel(); });
const updatePitchFromSliders = () => {
const az = parseFloat(azimuthSlider.value) * Math.PI / 180;
const el = parseFloat(elevationSlider.value) * Math.PI / 180;
const qAz = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), az);
const qEl = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), el);
const q = qAz.clone().multiply(qEl);
pitchArrowQuat.copy(q);
// Reset camera delta baseline so sliders don't fight with next drag
lastCameraQuat.copy(camera.quaternion);
// Update arrow immediately
const vPitch = new THREE.Vector3(0, 1, 0).applyQuaternion(pitchArrowQuat).normalize();
pitchArrow.setDirection(vPitch);
updateLabel();
};
azimuthSlider.addEventListener('input', updatePitchFromSliders);
elevationSlider.addEventListener('input', updatePitchFromSliders);
const applyViewMode = () => {
const surfaceOn = viewMeshCheckbox.checked;
if (mesh) mesh.material = surfaceOn ? mesh.userData.surfaceMaterial : mesh.userData.meshMaterial;
if (wireframe) wireframe.visible = false; // keep hidden; using solid white sphere for Mesh view
// label text no longer shows mode; checkbox text is static
};
viewMeshCheckbox.addEventListener('input', applyViewMode);
const applySeamVisibility = () => {
if (seamGroup) seamGroup.visible = !!showSeamCheckbox.checked;
};
showSeamCheckbox.addEventListener('input', applySeamVisibility);
const applySpinMode = () => {
const isEfficiencyMode = !!spinModeToggle.checked;
// Convert only if switching modes after initial setup
const currentVal = parseFloat(spinEfficiencySlider.value);
if (lastSpinModeIsEfficiency === null) {
// First run: set ranges/labels without converting
if (isEfficiencyMode) {
spinEfficiencySlider.min = "0";
spinEfficiencySlider.max = "1";
spinEfficiencySlider.step = "0.01";
spinLabelText.textContent = "Spin Efficiency";
} else {
spinEfficiencySlider.min = "-1";
spinEfficiencySlider.max = "1";
spinEfficiencySlider.step = "0.01";
spinLabelText.textContent = "Gyro Spin";
}
} else if (lastSpinModeIsEfficiency !== isEfficiencyMode) {
if (isEfficiencyMode) {
// Switching to efficiency: eff = 1 - |g|
const g = Math.max(-1, Math.min(1, currentVal));
if (g !== 0) gyroSpinSign = -Math.sign(g);
const eff = Math.sqrt(Math.max(0, 1 - g * g));
spinEfficiencySlider.min = "0";
spinEfficiencySlider.max = "1";
spinEfficiencySlider.step = "0.01";
spinEfficiencySlider.value = eff.toFixed(2);
spinLabelText.textContent = "Spin Efficiency";
} else {
// Switching to gyro: g = sign * (1 - eff)
const eff = Math.max(0, Math.min(1, currentVal));
const gyro = -(gyroSpinSign || 1) * Math.sqrt(Math.max(0, 1 - eff * eff));
spinEfficiencySlider.min = "-1";
spinEfficiencySlider.max = "1";
spinEfficiencySlider.step = "0.01";
spinEfficiencySlider.value = gyro.toFixed(2);
spinLabelText.textContent = "Gyro Spin";
}
} else {
// Same mode toggle event; ensure ranges/labels are correct
if (isEfficiencyMode) {
spinEfficiencySlider.min = "0";
spinEfficiencySlider.max = "1";
spinEfficiencySlider.step = "0.01";
spinLabelText.textContent = "Spin Efficiency";
} else {
spinEfficiencySlider.min = "-1";
spinEfficiencySlider.max = "1";
spinEfficiencySlider.step = "0.01";
spinLabelText.textContent = "Gyro Spin";
}
}
lastSpinModeIsEfficiency = isEfficiencyMode;
updateLabel();
scheduleUpdate();
};
spinModeToggle.addEventListener('input', applySpinMode);
window.addEventListener('resize', () => {
const w = container.clientWidth; const h = container.clientHeight;
camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h);
});
// Initial compute
updateLabel();
computeAndColor();
animate();
startTimeMs = performance.now();
lastCameraQuat.copy(camera.quaternion);
applyViewMode();
applySeamVisibility();
applySpinMode(); // ensure labels/ranges match mode at boot
// Ensure sliders reflect current arrow orientation at boot
syncSlidersFromPitchArrow();
}
function onControlsChange() {
const current = camera.quaternion.clone();
const dq = current.clone().multiply(lastCameraQuat.clone().invert());
// Apply camera delta rotation to the pitch direction orientation
pitchArrowQuat.multiply(dq);
lastCameraQuat.copy(current);
// Update sliders from the new arrow orientation
syncSlidersFromPitchArrow();
// Need to re-compute spin axis since pitch direction changed
scheduleUpdate();
}
function syncSlidersFromPitchArrow() {
const v = new THREE.Vector3(0, 1, 0).applyQuaternion(pitchArrowQuat).normalize();
// Elevation measured from +Y toward +Z (positive tilts forward along +Z)
const elRad = Math.atan2(v.z, v.y);
// Azimuth around Y, 0 at +Z, increasing toward +X (consistent with atan2(x,z))
const azRad = Math.atan2(v.x, v.z);
const elDeg = THREE.MathUtils.clamp(THREE.MathUtils.radToDeg(elRad), -90, 90);
let azDeg = THREE.MathUtils.radToDeg(azRad);
if (azDeg > 180) azDeg -= 360; if (azDeg < -180) azDeg += 360;
azimuthSlider.value = `${Math.round(azDeg)}`;
elevationSlider.value = `${Math.round(elDeg)}`;
azimuthValue.textContent = `${azimuthSlider.value}°`;
elevationValue.textContent = `${elevationSlider.value}°`;
}
function animate() {
requestAnimationFrame(animate);
controls.update();
// Update animated seam line based on RPM (surface stays static)
const rpm = parseFloat(spinRateSlider.value);
const now = performance.now();
const elapsedSec = (now - startTimeMs) / 1000;
const angle = rpm * (2 * Math.PI / 60) * elapsedSec; // always positive; axis encodes direction (RHR)
const qStep = quaternionFromAxisAngle(rotationAxisVec, angle);
// Rotate seam group to animate seam
if (seamGroup) seamGroup.setRotationFromQuaternion(qStep);
// Rotate the ball (mesh and wireframe) around the same axis/angle
if (mesh) mesh.setRotationFromQuaternion(qStep);
if (wireframe) wireframe.setRotationFromQuaternion(qStep);
// Update pitch direction arrow from accumulated drag rotation
const vPitch = new THREE.Vector3(0, 1, 0).applyQuaternion(pitchArrowQuat).normalize();
pitchArrow.setDirection(vPitch);
renderer.render(scene, camera);
}
function mod2pi(a) {
const TWO_PI = Math.PI * 2;
let x = a % TWO_PI; if (x < 0) x += TWO_PI; return x;
}
function computeAndColor() {
// Read params
const spinDirectionDeg = parseFloat(spinDirectionSlider.value);
const rawSpinValue = parseFloat(spinEfficiencySlider.value); // 0..1 if efficiency, -1..1 if gyro
// Map to efficiency and determine sign
let spinEfficiency;
if (spinModeToggle.checked) {
// Efficiency mode
spinEfficiency = Math.max(0, Math.min(1, rawSpinValue));
currentSpinSign = gyroSpinSign || 1; // keep last known sign
} else {
// Gyro mode
const g = Math.max(-1, Math.min(1, rawSpinValue));
spinEfficiency = Math.sqrt(Math.max(0, 1 - g * g));
currentSpinSign = g === 0 ? (gyroSpinSign || 1) : -Math.sign(g);
gyroSpinSign = currentSpinSign;
}
const seamLonDeg = parseFloat(longtitudeSlider.value);
const seamLatDeg = parseFloat(latitudeSlider.value);
// --- FIX START ---
// Physics-constrained axis: cone about the pitch direction
const spinDirRad = spinDirectionDeg * Math.PI / 180;
// 1. Get the actual pitch direction vector (from the green arrow's orientation)
const v_hat = new THREE.Vector3(0, 1, 0).applyQuaternion(pitchArrowQuat).normalize();
// 2. Create a "right" vector (t_axis_1) perpendicular to the pitch direction.
// We'll base this on the world's "up" vector (0,1,0) to get a stable "right".
const worldUp = new THREE.Vector3(0, 1, 0);
let t_axis_1 = new THREE.Vector3().crossVectors(v_hat, worldUp);
// Handle the case where the pitch arrow is pointing straight up or down (parallel to worldUp)
if (t_axis_1.lengthSq() < 1e-8) {
// v_hat is (0,1,0) or (0,-1,0). The cross product is zero.
// In this case, let's define "right" (0 deg spin) as the world X axis.
t_axis_1.set(1, 0, 0);
} else {
t_axis_1.normalize(); // Normalize the "right" vector
}
// 3. Create an "up" vector (t_axis_2) that is also perpendicular to the pitch direction
// and perpendicular to our new "right" vector.
let t_axis_2 = new THREE.Vector3().crossVectors(v_hat, t_axis_1).normalize();
// 4. Define the final transverse vector (t_hat) by rotating in the (t_axis_1, t_axis_2) plane
// by the spin direction angle.
const t_hat = new THREE.Vector3()
.addScaledVector(t_axis_1, Math.cos(spinDirRad)) // Component along "right"
.addScaledVector(t_axis_2, Math.sin(spinDirRad)); // Component along "up"
// 5. Combine the pitch vector (v_hat) and transverse vector (t_hat)
// based on the spin efficiency to get the final spin axis.
const alpha = Math.asin(Math.max(0, Math.min(1, spinEfficiency))); // cone angle
const finalAxis = new THREE.Vector3()
.addScaledVector(v_hat, Math.cos(alpha))
.addScaledVector(t_hat, Math.sin(alpha))
.normalize();
// --- FIX END ---
finalAxisVec.copy(finalAxis);
// Choose rotation axis; in Gyro mode, flip axis for negative sign (RHR)
const isGyroMode = !spinModeToggle.checked;
const rotationAxis = finalAxis.clone();
if (isGyroMode && (currentSpinSign < 0)) rotationAxis.negate();
axisArrow.setDirection(rotationAxis);
rotationAxisVec.copy(rotationAxis);
// Rebuild rotation indicator properly oriented to the axis
rebuildRotationIndicatorArrow(rotationAxis);
// Seam static offset (apply to seam curve only)
const lonRad = seamLonDeg * Math.PI / 180; const latRad = seamLatDeg * Math.PI / 180;
const qLon = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), lonRad);
const qLat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), latRad);
const qOffset = qLon.clone().multiply(qLat);
const seamPoints = seamPointsOriginal.map(p => p.clone().applyQuaternion(qOffset));
seamPointsBase = seamPoints.map(p => p.clone());
// Rebuild seam tube from base seam (no spin angle)
rebuildSeamTube(seamPointsBase);
// Sweep computation (static surface coloring)
const widthRad = SEAM_WIDTH_DEG * Math.PI / 180;
const cosWidth = Math.cos(widthRad);
const numVerts = (HEIGHT_SEGMENTS + 1) * (WIDTH_SEGMENTS + 1);
const covered = new Uint8Array(numVerts);
const counts = new Uint16Array(numVerts);
const rowStride = WIDTH_SEGMENTS + 1;
const TWO_PI = Math.PI * 2;
for (let step = 0; step < ROTATION_STEPS; step++) {
covered.fill(0);
const angle = (step / ROTATION_STEPS) * TWO_PI;
const qStep = quaternionFromAxisAngle(rotationAxis, angle);
for (let s = 0; s < seamPoints.length; s++) {
const p = seamPoints[s].clone().applyQuaternion(qStep);
const lat_s = Math.asin(Math.max(-1, Math.min(1, p.z)));
let lon_s = mod2pi(Math.atan2(p.y, p.x));
const sinLat_s = Math.sin(lat_s), cosLat_s = Math.cos(lat_s);
// Determine latitude band indices to check
let iMin = Math.ceil((lat_s - widthRad - latMin) / dLat); if (iMin < 0) iMin = 0;
let iMax = Math.floor((lat_s + widthRad - latMin) / dLat); if (iMax > HEIGHT_SEGMENTS) iMax = HEIGHT_SEGMENTS;
for (let i = iMin; i <= iMax; i++) {
const sinLat_i = sinLatArray[i]; const cosLat_i = cosLatArray[i];
const denom = cosLat_s * cosLat_i;
let fullRow = false; let skipRow = false; let dLonMax = Math.PI;
if (Math.abs(denom) < 1e-8) {
const cosDelta = sinLat_s * sinLat_i; // minimal delta ignoring Δlon
if (cosDelta >= cosWidth) { fullRow = true; } else { skipRow = true; }
} else {
let r = (cosWidth - sinLat_s * sinLat_i) / denom;
if (r <= -1) { fullRow = true; }
else if (r >= 1) { skipRow = true; }
else { dLonMax = Math.acos(Math.max(-1, Math.min(1, r))); }
}
if (skipRow) continue;
const base = i * rowStride;
if (fullRow) {
for (let j = 0; j <= WIDTH_SEGMENTS; j++) covered[base + j] = 1;
continue;
}
const start = lon_s - dLonMax; const end = lon_s + dLonMax;
const markRange = (a, b) => {
for (let jj = a; jj <= b; jj++) {
const jWrapped = ((jj % (WIDTH_SEGMENTS + 1)) + (WIDTH_SEGMENTS + 1)) % (WIDTH_SEGMENTS + 1);
covered[base + jWrapped] = 1;
}
};
let jStart = Math.floor(start / dLon); let jEnd = Math.floor(end / dLon);
if (jEnd - jStart >= WIDTH_SEGMENTS + 1) {
for (let j = 0; j <= WIDTH_SEGMENTS; j++) covered[base + j] = 1;
} else {
markRange(jStart, jEnd);
}
}
}
// Accumulate per-vertex coverage once per step
for (let v = 0; v < numVerts; v++) if (covered[v]) counts[v]++;
}
// Convert to fraction and colorize
for (let v = 0; v < counts.length; v++) {
const frac = counts[v] / ROTATION_STEPS; // 0..1
const c = colormapPlasma01(frac);
colorsAttr.array[3 * v + 0] = c[0];
colorsAttr.array[3 * v + 1] = c[1];
colorsAttr.array[3 * v + 2] = c[2];
}
colorsAttr.needsUpdate = true;
}
function rebuildSeamTube(points) {
// Remove previous tube
if (seamTube) {
if (seamTube.geometry) seamTube.geometry.dispose();
if (seamTube.material) seamTube.material.dispose();
seamGroup.remove(seamTube);
seamTube = null;
}
// Build a smooth curve from sampled points (closed seam path)
const curve = new THREE.CatmullRomCurve3(points, false, 'catmullrom', 0.5);
const tubularSegments = Math.max(100, SEAM_POINTS * 2);
const tubeGeom = new THREE.TubeGeometry(curve, tubularSegments, SEAM_RADIUS, 8, false);
const tubeMat = new THREE.MeshPhongMaterial({ color: 0xff2222, shininess: 30 });
seamTube = new THREE.Mesh(tubeGeom, tubeMat);
seamGroup.add(seamTube);
// Keep fallback line hidden
if (seamLine) seamLine.visible = false;
}
function rebuildRotationIndicatorArrow(axis) {
// Clear previous elements
if (rotationRing) {
rotationIndicatorGroup.remove(rotationRing);
if (rotationRing.geometry) rotationRing.geometry.dispose();
if (rotationRing.material) rotationRing.material.dispose();
rotationRing = null;
}
if (rotationDirArrow) {
rotationIndicatorGroup.remove(rotationDirArrow);
rotationDirArrow = null;
}
const ringRadius = 0.35;
const ringSegments = 64;
// Orthonormal basis perpendicular to axis
const n = axis.clone().normalize();
const arbitrary = Math.abs(n.x) < 0.9 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 0, 1);
const u = n.clone().cross(arbitrary).normalize();
const v = n.clone().cross(u).normalize();
const center = n.clone().multiplyScalar(1.2);
// Arc for positive rotation (right-hand rule about +axis)
const startA = -0.75 * Math.PI; // -135°
const endA = 0.75 * Math.PI; // +135°
const ringPositions = new Float32Array((ringSegments + 1) * 3);
for (let i = 0; i <= ringSegments; i++) {
const a = startA + (i / ringSegments) * (endA - startA);
const offset = u.clone().multiplyScalar(Math.cos(a)).add(v.clone().multiplyScalar(Math.sin(a))).multiplyScalar(ringRadius);
const p = center.clone().add(offset);
ringPositions[i*3 + 0] = p.x;
ringPositions[i*3 + 1] = p.y;
ringPositions[i*3 + 2] = p.z;
}
const ringGeom = new THREE.BufferGeometry();
ringGeom.setAttribute('position', new THREE.BufferAttribute(ringPositions, 3));
const ringMat = new THREE.LineBasicMaterial({ color: 0xffff00 });
rotationRing = new THREE.Line(ringGeom, ringMat);
rotationIndicatorGroup.add(rotationRing);
// Arrowhead at the end of the arc
const endOffset = u.clone().multiplyScalar(Math.cos(endA)).add(v.clone().multiplyScalar(Math.sin(endA))).multiplyScalar(ringRadius);
const endP = center.clone().add(endOffset);
// Tangent direction at the end of the arc
const tangent = u.clone().multiplyScalar(-Math.sin(endA)).add(v.clone().multiplyScalar(Math.cos(endA))).normalize();
rotationDirArrow = new THREE.ArrowHelper(tangent, endP, 0, 0xffff00, 0.1, 0.08);
rotationIndicatorGroup.add(rotationDirArrow);
}
// --- Start ---
init();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment