Last active
November 15, 2025 16:37
-
-
Save 903124/872af01bcbc7fa412f89a07b494aa3af to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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">0°</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">0°</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">0°</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">0°</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">0°</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