Created
July 1, 2025 01:01
-
-
Save dylan-chong/63bbde6cdea13898610969b8ef3db67a to your computer and use it in GitHub Desktop.
this is so dumb
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>3D Poker Chip Renderer</title> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/cannon.min.js"></script> | |
<style> | |
body { | |
margin: 0; | |
padding: 0; | |
font-family: Arial, sans-serif; | |
background: linear-gradient(135deg, #1e3c72, #2a5298); | |
color: white; | |
height: 100vh; | |
overflow: hidden; | |
} | |
.app-container { | |
display: flex; | |
height: 100vh; | |
} | |
.sidebar { | |
width: 320px; | |
background: rgba(0, 0, 0, 0.8); | |
backdrop-filter: blur(10px); | |
padding: 20px; | |
overflow-y: auto; | |
border-right: 1px solid rgba(255, 255, 255, 0.1); | |
} | |
.main-content { | |
flex: 1; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
background: rgba(0, 0, 0, 0.2); | |
} | |
h1 { | |
text-align: center; | |
margin-bottom: 30px; | |
text-shadow: 2px 2px 4px rgba(0,0,0,0.5); | |
font-size: 24px; | |
} | |
.controls { | |
display: flex; | |
flex-direction: column; | |
gap: 20px; | |
margin-bottom: 30px; | |
} | |
.file-input-container { | |
background: rgba(255,255,255,0.1); | |
padding: 15px; | |
border-radius: 10px; | |
backdrop-filter: blur(10px); | |
width: 100%; | |
box-sizing: border-box; | |
} | |
.file-input-container label { | |
display: block; | |
margin-bottom: 8px; | |
font-weight: bold; | |
font-size: 14px; | |
} | |
/* Image selector styling */ | |
.image-selector { | |
margin-bottom: 10px; | |
width: 100%; | |
} | |
.image-dropdown { | |
background: rgba(255,255,255,0.15); | |
border: 1px solid rgba(255,255,255,0.3); | |
border-radius: 5px; | |
padding: 8px 12px; | |
color: white; | |
cursor: pointer; | |
width: 100%; | |
font-size: 12px; | |
display: flex; | |
align-items: center; | |
justify-content: space-between; | |
min-height: 32px; | |
box-sizing: border-box; | |
} | |
.image-dropdown:hover { | |
background: rgba(255,255,255,0.2); | |
} | |
.dropdown-arrow { | |
font-size: 10px; | |
transition: transform 0.3s; | |
} | |
.dropdown-arrow.open { | |
transform: rotate(180deg); | |
} | |
.dropdown-menu { | |
top: 100%; | |
left: 0; | |
background: rgba(0,0,0,0.95); | |
backdrop-filter: blur(10px); | |
border: 1px solid rgba(255,255,255,0.3); | |
border-radius: 5px; | |
z-index: 1000; | |
display: none; | |
min-width: 100%; | |
width: 100%; | |
box-sizing: border-box; | |
margin: 0; | |
position: static; | |
margin-top: 8px; | |
width: 100%; | |
min-width: 0; | |
z-index: auto; | |
} | |
.dropdown-menu.open { | |
display: block; | |
} | |
.dropdown-item { | |
display: flex; | |
align-items: center; | |
padding: 8px 12px; | |
cursor: pointer; | |
border-bottom: 1px solid rgba(255,255,255,0.1); | |
transition: background 0.2s; | |
position: relative; | |
} | |
.dropdown-item:hover { | |
background: rgba(255,255,255,0.1); | |
} | |
.dropdown-item:last-child { | |
border-bottom: none; | |
} | |
.dropdown-preview { | |
width: 32px; | |
height: 32px; | |
border-radius: 4px; | |
margin-right: 10px; | |
object-fit: cover; | |
border: 1px solid rgba(255,255,255,0.3); | |
} | |
.dropdown-text { | |
flex: 1; | |
font-size: 11px; | |
overflow: hidden; | |
text-overflow: ellipsis; | |
white-space: nowrap; | |
} | |
.dropdown-remove { | |
color: #ff5555; | |
background: none; | |
border: none; | |
font-size: 16px; | |
font-weight: bold; | |
cursor: pointer; | |
margin-left: 8px; | |
padding: 0 4px; | |
border-radius: 3px; | |
transition: background 0.2s; | |
z-index: 2; | |
} | |
.dropdown-remove:hover { | |
background: rgba(255,85,85,0.15); | |
} | |
.selected-preview { | |
width: 24px; | |
height: 24px; | |
border-radius: 3px; | |
margin-right: 8px; | |
object-fit: cover; | |
border: 1px solid rgba(255,255,255,0.5); | |
} | |
input[type="file"] { | |
background: white; | |
padding: 8px; | |
border: none; | |
border-radius: 5px; | |
cursor: pointer; | |
width: 100%; | |
font-size: 12px; | |
box-sizing: border-box; | |
} | |
input[type="range"] { | |
width: 100%; | |
margin: 10px 0 5px 0; | |
} | |
#rotation-value { | |
font-size: 12px; | |
color: #ccc; | |
} | |
.fine-control-btn { | |
width: 30px; | |
height: 30px; | |
background: #4CAF50; | |
color: white; | |
border: none; | |
border-radius: 50%; | |
cursor: pointer; | |
font-size: 16px; | |
font-weight: bold; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
transition: background 0.3s; | |
} | |
.fine-control-btn:hover { | |
background: #45a049; | |
} | |
.fine-control-btn:active { | |
transform: scale(0.95); | |
} | |
#flip-strip { | |
margin-top: 12px; | |
padding: 8px 12px; | |
background: #4CAF50; | |
color: white; | |
border: none; | |
border-radius: 4px; | |
cursor: pointer; | |
width: 100%; | |
font-size: 12px; | |
transition: background 0.3s; | |
box-sizing: border-box; | |
} | |
#flip-strip:hover { | |
background: #45a049; | |
} | |
#toggle-dirty-chip { | |
margin-top: 8px; | |
padding: 8px 12px; | |
background: #FF9800; | |
color: white; | |
border: none; | |
border-radius: 4px; | |
cursor: pointer; | |
width: 100%; | |
font-size: 12px; | |
transition: background 0.3s; | |
display: none; /* Initially hidden */ | |
box-sizing: border-box; | |
} | |
#toggle-dirty-chip:hover { | |
background: #F57C00; | |
} | |
#toggle-dirty-chip.active { | |
background: #4CAF50; | |
} | |
#toggle-dirty-chip.active:hover { | |
background: #45a049; | |
} | |
#canvas-container { | |
height: calc(100vh - 40px); | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
padding: 20px; | |
position: relative; | |
} | |
canvas { | |
display: block; | |
} | |
.instructions { | |
font-size: 13px; | |
opacity: 0.8; | |
line-height: 1.4; | |
background: rgba(255,255,255,0.05); | |
padding: 15px; | |
border-radius: 8px; | |
border-left: 3px solid #4CAF50; | |
} | |
.instructions h3 { | |
margin: 0 0 10px 0; | |
font-size: 14px; | |
color: #4CAF50; | |
} | |
.instructions ul { | |
margin: 0; | |
padding-left: 15px; | |
} | |
.instructions li { | |
margin-bottom: 5px; | |
} | |
/* Mobile responsiveness */ | |
@media (max-width: 768px) { | |
.app-container { | |
flex-direction: column; | |
} | |
.sidebar { | |
width: 100%; | |
height: auto; | |
max-height: 40vh; | |
overflow-y: auto; | |
} | |
body { | |
overflow: auto; | |
} | |
.main-content { | |
flex: 1; | |
min-height: 60vh; | |
} | |
} | |
.action-btn { | |
margin-top: 8px; | |
padding: 8px 12px; | |
background: #4CAF50; | |
color: white; | |
border: none; | |
border-radius: 4px; | |
cursor: pointer; | |
width: 100%; | |
font-size: 12px; | |
transition: background 0.3s; | |
box-sizing: border-box; | |
} | |
.action-btn:hover { | |
background: #45a049; | |
} | |
.action-btn.loading { | |
opacity: 0.7; | |
pointer-events: none; | |
position: relative; | |
} | |
#canvas-loading-overlay { | |
position: absolute; | |
top: 0; left: 0; right: 0; bottom: 0; | |
background: rgba(30, 60, 114, 0.7); | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
z-index: 10; | |
pointer-events: all; | |
} | |
.canvas-spinner { | |
border: 4px solid #fff; | |
border-top: 4px solid #4CAF50; | |
border-radius: 50%; | |
width: 48px; | |
height: 48px; | |
animation: spin 1s linear infinite; | |
} | |
@keyframes spin { | |
0% { transform: rotate(0deg);} | |
100% { transform: rotate(360deg);} | |
} | |
</style> | |
</head> | |
<body> | |
<div class="app-container"> | |
<div class="sidebar"> | |
<h1>🎲 3D Poker Chip Renderer</h1> | |
<div id="fps-display" style="text-align:center; font-size:13px; margin-bottom:18px; color:#4CAF50; font-weight:bold; letter-spacing:1px;">FPS: --</div> | |
<div class="controls"> | |
<div class="file-input-container"> | |
<label for="face-texture">Face (PNG)</label> | |
<div class="image-selector"> | |
<div class="image-dropdown" id="face-dropdown"> | |
<span>Select face texture...</span> | |
<span class="dropdown-arrow">▼</span> | |
</div> | |
<div class="dropdown-menu" id="face-dropdown-menu"></div> | |
</div> | |
<input type="file" id="face-texture" accept=".png" /> | |
</div> | |
<div class="file-input-container"> | |
<label for="strip-texture">Strip (PNG)</label> | |
<div class="image-selector"> | |
<div class="image-dropdown" id="strip-dropdown"> | |
<span>Select strip texture...</span> | |
<span class="dropdown-arrow">▼</span> | |
</div> | |
<div class="dropdown-menu" id="strip-dropdown-menu"></div> | |
</div> | |
<input type="file" id="strip-texture" accept=".png" /> | |
</div> | |
<div class="file-input-container"> | |
<label for="dirty-strip-texture">Dirty Stack Strip (PNG)</label> | |
<div class="image-selector"> | |
<div class="image-dropdown" id="dirty-dropdown"> | |
<span>Select dirty strip texture...</span> | |
<span class="dropdown-arrow">▼</span> | |
</div> | |
<div class="dropdown-menu" id="dirty-dropdown-menu"></div> | |
</div> | |
<input type="file" id="dirty-strip-texture" accept=".png" /> | |
<button id="toggle-dirty-chip">Hide Dirty Chip</button> | |
</div> | |
<div class="file-input-container"> | |
<label for="face-rotation">Face Alignment</label> | |
<div style="display: flex; align-items: center; gap: 10px; margin-top: 8px;"> | |
<div id="rotation-value" style="width: 48px; text-align: right; font-size: 12px; color: #ccc;">0°</div> | |
<button id="rotation-minus" class="fine-control-btn">-</button> | |
<input | |
type="range" | |
id="face-rotation" | |
min="0" | |
max="360" | |
value="0" | |
step="1" | |
style="flex: 1; margin: 0 8px;" | |
/> | |
<button id="rotation-plus" class="fine-control-btn">+</button> | |
</div> | |
<button id="flip-strip">Flip Strip 180°</button> | |
<button id="auto-align" class="action-btn">Auto align</button> | |
</div> | |
</div> | |
<div class="instructions"> | |
<h3>Controls</h3> | |
<ul> | |
<li><b>Left-click and drag a chip to pick up and throw it (physics!)</b></li> | |
<li>Right-click and drag to rotate the chip stack</li> | |
<li>Scroll to zoom in/out</li> | |
<li>Load custom textures to see your design</li> | |
<li>Use +/- buttons for precise 0.25° face alignment</li> | |
<li>Load a "Dirty Stack Chip" texture for variety</li> | |
<li>Dirty chip automatically shows when texture is loaded</li> | |
<li>Previously uploaded images are saved and can be reselected</li> | |
</ul> | |
</div> | |
</div> | |
<div class="main-content"> | |
<div id="canvas-container" style="position: relative;"> | |
<!-- Canvas loading overlay --> | |
<div id="canvas-loading-overlay" style="display:none; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(30, 60, 114, 0.7); display: none; align-items: center; justify-content: center; z-index: 10; pointer-events: all;"> | |
<div class="canvas-spinner"></div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<script> | |
// Image memory system | |
class ImageMemory { | |
constructor() { | |
this.faceImages = this.loadFromStorage('face-images') || {}; | |
this.stripImages = this.loadFromStorage('strip-images') || {}; | |
this.currentSelections = this.loadFromStorage('current-selections') || {}; | |
this.faceSettings = this.loadFromStorage('face-settings') || {}; // { filename: { angle, flip } } | |
} | |
loadFromStorage(key) { | |
try { | |
const data = localStorage.getItem(`poker-chip-${key}`); | |
return data ? JSON.parse(data) : null; | |
} catch (e) { | |
console.warn(`Failed to load ${key} from storage:`, e); | |
return null; | |
} | |
} | |
saveToStorage(key, data) { | |
try { | |
localStorage.setItem(`poker-chip-${key}`, JSON.stringify(data)); | |
} catch (e) { | |
console.warn(`Failed to save ${key} to storage:`, e); | |
} | |
} | |
addImage(type, filename, dataUrl) { | |
let storage; | |
switch (type) { | |
case 'face': storage = this.faceImages; break; | |
case 'strip': storage = this.stripImages; break; | |
case 'dirty-strip': storage = this.stripImages; break; | |
default: return; | |
} | |
// Remove existing image with same filename if it exists | |
if (storage[filename]) { | |
delete storage[filename]; | |
} | |
// Add new image | |
storage[filename] = { | |
dataUrl: dataUrl, | |
timestamp: Date.now() | |
}; | |
this.saveToStorage(`${type}-images`, storage); | |
} | |
getImages(type) { | |
let storage; | |
switch (type) { | |
case 'face': storage = this.faceImages; break; | |
case 'strip': storage = this.stripImages; break; | |
case 'dirty-strip': storage = this.stripImages; break; | |
default: return {}; | |
} | |
// Sort by timestamp (newest first) | |
const entries = Object.entries(storage); | |
entries.sort((a, b) => b[1].timestamp - a[1].timestamp); | |
const sortedStorage = {}; | |
entries.forEach(([key, value]) => { | |
sortedStorage[key] = value; | |
}); | |
return sortedStorage; | |
} | |
setCurrentSelection(type, filename) { | |
this.currentSelections[type] = filename; | |
this.saveToStorage('current-selections', this.currentSelections); | |
} | |
getCurrentSelection(type) { | |
return this.currentSelections[type]; | |
} | |
getImageData(type, filename) { | |
const images = this.getImages(type); | |
return images[filename]?.dataUrl; | |
} | |
saveFaceSettings() { | |
this.saveToStorage('face-settings', this.faceSettings); | |
} | |
getFaceSettings(filename) { | |
return this.faceSettings[filename] || { angle: 0, flip: false }; | |
} | |
setFaceSettings(filename, settings) { | |
this.faceSettings[filename] = settings; | |
this.saveFaceSettings(); | |
} | |
removeImage(type, filename) { | |
let storage; | |
let storageKey; | |
switch (type) { | |
case 'face': storage = this.faceImages; storageKey = 'face-images'; break; | |
case 'strip': storage = this.stripImages; storageKey = 'strip-images'; break; | |
case 'dirty-strip': storage = this.stripImages; storageKey = 'strip-images'; break; | |
default: return; | |
} | |
if (storage[filename]) { | |
delete storage[filename]; | |
this.saveToStorage(storageKey, storage); | |
} | |
// Remove face settings if face | |
if (type === 'face' && this.faceSettings[filename]) { | |
delete this.faceSettings[filename]; | |
this.saveFaceSettings(); | |
} | |
// Remove current selection if it was this image | |
if (this.currentSelections[type] === filename) { | |
delete this.currentSelections[type]; | |
this.saveToStorage('current-selections', this.currentSelections); | |
} | |
} | |
} | |
const imageMemory = new ImageMemory(); | |
// Dropdown management | |
class DropdownManager { | |
constructor(type, dropdownId, menuId) { | |
this.type = type; | |
this.dropdown = document.getElementById(dropdownId); | |
this.menu = document.getElementById(menuId); | |
this.arrow = this.dropdown.querySelector('.dropdown-arrow'); | |
this.isOpen = false; | |
this.setupEventListeners(); | |
this.updateDropdown(); | |
} | |
setupEventListeners() { | |
this.dropdown.addEventListener('click', (e) => { | |
e.stopPropagation(); | |
this.toggleDropdown(); | |
}); | |
// Close dropdown when clicking outside | |
document.addEventListener('click', () => { | |
if (this.isOpen) { | |
this.closeDropdown(); | |
} | |
}); | |
} | |
toggleDropdown() { | |
if (this.isOpen) { | |
this.closeDropdown(); | |
} else { | |
this.openDropdown(); | |
} | |
} | |
openDropdown() { | |
this.isOpen = true; | |
this.menu.classList.add('open'); | |
this.updateDropdown(); | |
} | |
closeDropdown() { | |
this.isOpen = false; | |
this.menu.classList.remove('open'); | |
this.updateDropdown(); | |
} | |
updateDropdown() { | |
const images = imageMemory.getImages(this.type); | |
const currentSelection = imageMemory.getCurrentSelection(this.type); | |
// Update dropdown button text and preview, but keep arrow as a persistent child | |
let labelHtml = ''; | |
if (currentSelection && images[currentSelection]) { | |
const img = images[currentSelection]; | |
let selectedStyle = ''; | |
if (this.type === 'strip' || this.type === 'dirty-strip') { | |
selectedStyle = 'width: 48px; height: 6px; border-radius: 3px; object-fit: cover; border: 1px solid rgba(255,255,255,0.5); margin-right: 8px;'; | |
} else { | |
selectedStyle = 'width: 24px; height: 24px; border-radius: 3px; object-fit: cover; border: 1px solid rgba(255,255,255,0.5); margin-right: 8px;'; | |
} | |
labelHtml = ` | |
<div style=\"display: flex; align-items: center;\">\n <img src=\"${img.dataUrl}\" class=\"selected-preview\" style=\"${selectedStyle}\" />\n <span>${currentSelection}</span>\n </div>\n `; | |
} else { | |
labelHtml = `\n <span>Select ${this.type.replace('-', ' ')} texture...</span>\n `; | |
} | |
// Only update the label, not the arrow | |
this.dropdown.innerHTML = labelHtml; | |
// If arrow doesn't exist, add it | |
if (!this.arrow || !this.dropdown.querySelector('.dropdown-arrow')) { | |
this.arrow = document.createElement('span'); | |
this.arrow.className = 'dropdown-arrow'; | |
this.arrow.textContent = '▼'; | |
this.dropdown.appendChild(this.arrow); | |
} else { | |
this.arrow = this.dropdown.querySelector('.dropdown-arrow'); | |
} | |
// Set arrow direction | |
if (this.isOpen) { | |
this.arrow.classList.add('open'); | |
} else { | |
this.arrow.classList.remove('open'); | |
} | |
// Update dropdown menu | |
this.menu.innerHTML = ''; | |
const imageEntries = Object.entries(images); | |
if (imageEntries.length === 0) { | |
this.menu.innerHTML = '<div class="dropdown-item" style="opacity: 0.6;">No saved images</div>'; | |
return; | |
} | |
imageEntries.forEach(([filename, imageData]) => { | |
const item = document.createElement('div'); | |
item.className = 'dropdown-item'; | |
// Set preview style for strip/dirty-strip | |
let previewStyle = ''; | |
if (this.type === 'strip' || this.type === 'dirty-strip') { | |
previewStyle = 'width: 64px; height: 8px; border-radius: 4px; object-fit: cover; border: 1px solid rgba(255,255,255,0.3); margin-right: 10px;'; | |
} else { | |
previewStyle = 'width: 32px; height: 32px; border-radius: 4px; object-fit: cover; border: 1px solid rgba(255,255,255,0.3); margin-right: 10px;'; | |
} | |
item.innerHTML = ` | |
<img src="${imageData.dataUrl}" class="dropdown-preview" style="${previewStyle}" /> | |
<span class="dropdown-text">${filename}</span> | |
<button class="dropdown-remove" title="Remove image" tabindex="-1">×</button> | |
`; | |
// Remove button logic | |
const removeBtn = item.querySelector('.dropdown-remove'); | |
removeBtn.addEventListener('click', (e) => { | |
e.stopPropagation(); | |
imageMemory.removeImage(this.type, filename); | |
this.updateDropdown(); | |
// If this was the selected image, clear the texture | |
if (this.type === 'face' && faceTexture && imageMemory.getCurrentSelection('face') !== filename) { | |
faceTexture = null; | |
updateChipMaterials(); | |
} | |
if (this.type === 'strip' && stripTexture && imageMemory.getCurrentSelection('strip') !== filename) { | |
stripTexture = null; | |
updateChipMaterials(); | |
} | |
if (this.type === 'dirty-strip' && dirtyStripTexture && imageMemory.getCurrentSelection('dirty-strip') !== filename) { | |
dirtyStripTexture = null; | |
showDirtyChip = false; | |
updateDirtyChipButton(); | |
updateChipMaterials(); | |
} | |
}); | |
// Image selection logic | |
item.addEventListener('click', (e) => { | |
// Only select if not clicking the remove button | |
if (e.target.classList.contains('dropdown-remove')) return; | |
this.selectImage(filename); | |
this.closeDropdown(); | |
}); | |
this.menu.appendChild(item); | |
}); | |
} | |
selectImage(filename) { | |
const imageData = imageMemory.getImageData(this.type, filename); | |
if (imageData) { | |
imageMemory.setCurrentSelection(this.type, filename); | |
this.updateDropdown(); | |
// Load the texture | |
loadTextureFromDataUrl(imageData, (texture) => { | |
switch (this.type) { | |
case 'face': | |
faceTexture = texture; | |
applyFaceSettings(filename); | |
break; | |
case 'strip': | |
stripTexture = texture; | |
break; | |
case 'dirty-strip': | |
dirtyStripTexture = texture; | |
showDirtyChip = true; | |
saveDirtyChipState(); | |
updateDirtyChipButton(); | |
break; | |
} | |
updateChipMaterials(); | |
}); | |
} | |
} | |
} | |
// Initialize dropdowns | |
const faceDropdown = new DropdownManager('face', 'face-dropdown', 'face-dropdown-menu'); | |
const stripDropdown = new DropdownManager('strip', 'strip-dropdown', 'strip-dropdown-menu'); | |
const dirtyDropdown = new DropdownManager('dirty-strip', 'dirty-dropdown', 'dirty-dropdown-menu'); | |
// Scene setup | |
const scene = new THREE.Scene(); | |
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); | |
renderer.setPixelRatio(window.devicePixelRatio); // High DPI support | |
renderer.setClearColor(0x000000, 0); | |
renderer.shadowMap.enabled = true; | |
renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
document.getElementById('canvas-container').appendChild(renderer.domElement); | |
// Lighting | |
const ambientLight = new THREE.AmbientLight(0x404040, 0.6); | |
scene.add(ambientLight); | |
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); | |
directionalLight.position.set(5, 5, 5); | |
directionalLight.castShadow = true; | |
directionalLight.shadow.mapSize.width = 2048; | |
directionalLight.shadow.mapSize.height = 2048; | |
scene.add(directionalLight); | |
const pointLight = new THREE.PointLight(0xffffff, 0.5); | |
pointLight.position.set(-5, 5, 5); | |
scene.add(pointLight); | |
// Poker chip dimensions (39mm diameter = 19.5mm radius, 3mm height) | |
const chipRadius = 19.5; | |
const chipHeight = 3; | |
let chipSegments = 64; // High resolution for smooth edges | |
const numChips = 20; | |
// Face texture rotation and zoom variables (consolidated) | |
let faceRotation = 0; | |
let stripRotation = 0; | |
let zoomLevel = 0.8; // Default 80% (20% reduced) | |
const baseDistance = 50; // Base camera distance | |
const minZoom = 0.3; // Minimum zoom (30%) | |
const maxZoom = 3.0; // Maximum zoom (300%) | |
// Default textures | |
const textureLoader = new THREE.TextureLoader(); | |
let faceTexture = null; | |
let stripTexture = null; | |
let dirtyStripTexture = null; | |
let showDirtyChip = false; | |
// Create default materials | |
const defaultFaceMaterial = new THREE.MeshPhongMaterial({ | |
color: 0x8b0000, | |
shininess: 30 | |
}); | |
const defaultStripMaterial = new THREE.MeshPhongMaterial({ | |
color: 0x444444, | |
shininess: 30 | |
}); | |
// Create chip geometry | |
let chipGeometry = new THREE.CylinderGeometry(chipRadius, chipRadius, chipHeight, chipSegments); | |
// Create materials array for different faces | |
let chipMaterials = [ | |
defaultStripMaterial, // Side | |
defaultFaceMaterial, // Top | |
defaultFaceMaterial // Bottom | |
]; | |
// Create chip stack - 20 cylinders with random rotations | |
const chipStack = new THREE.Group(); | |
const chips = []; | |
const dirtyChipIndex = Math.floor(numChips / 2); // Middle of the stack | |
// --- PHYSICS SETUP --- | |
// Create a Cannon.js physics world | |
const world = new CANNON.World(); | |
world.gravity.set(0, -39.28, 0); // 4x Earth gravity | |
world.broadphase = new CANNON.NaiveBroadphase(); | |
world.solver.iterations = 30; | |
// Create a visible platform (box) below the bottom chip | |
const platformWidth = chipRadius * 30; // Much bigger table | |
const platformDepth = chipRadius * 30; | |
const platformHeight = chipHeight * 1.5; | |
const platformY = -chipHeight * 2; | |
// Calculate bottom chip Y so its bottom is just above the platform | |
const bottomChipY = platformY + platformHeight / 2 + chipHeight / 2; | |
const chipSpacing = chipHeight + 1e-3; // Tiny gap to avoid overlap | |
// Three.js mesh for platform | |
const platformGeometry = new THREE.BoxGeometry(platformWidth, platformHeight, platformDepth); | |
const platformMaterial = new THREE.MeshPhongMaterial({ color: 0x222222, shininess: 10 }); | |
const platformMesh = new THREE.Mesh(platformGeometry, platformMaterial); | |
platformMesh.position.set(0, platformY - platformHeight / 2, 0); | |
platformMesh.receiveShadow = true; | |
// --- Group chips and platform together --- | |
const tableGroup = new THREE.Group(); | |
// Cannon.js static body for platform | |
const platformBody = new CANNON.Body({ mass: 0 }); | |
const platformShape = new CANNON.Box(new CANNON.Vec3(platformWidth / 2, platformHeight / 2, platformDepth / 2)); | |
platformBody.addShape(platformShape); | |
platformBody.position.set(0, platformY - platformHeight / 2, 0); | |
platformBody.material = new CANNON.Material({ friction: 0.4, restitution: 0 }); | |
world.addBody(platformBody); | |
// Create physics bodies for each chip | |
const chipBodies = []; | |
for (let i = 0; i < numChips; i++) { | |
const chip = new THREE.Mesh(chipGeometry, chipMaterials); | |
chip.position.y = bottomChipY + i * chipSpacing; | |
// Random rotation 0-360 degrees | |
const randomY = Math.random() * Math.PI * 2; | |
chip.rotation.y = randomY; | |
chip.castShadow = true; | |
chip.receiveShadow = true; | |
chip.userData.isDirtyChip = (i === dirtyChipIndex); // Mark the middle chip as dirty | |
chips.push(chip); | |
chipStack.add(chip); | |
// --- FIX: Correct Cannon.js cylinder orientation to match Three.js (Y axis) --- | |
const chipBody = new CANNON.Body({ | |
mass: 1, | |
position: new CANNON.Vec3(0, chip.position.y, 0), | |
material: new CANNON.Material({ friction: 0.4, restitution: 0 }) | |
}); | |
// Create the cylinder shape (default axis is X in Cannon.js) | |
const cannonCylinder = new CANNON.Cylinder(chipRadius, chipRadius, chipHeight, chipSegments); | |
// Rotate the shape so its axis is Y (not X) | |
const q = new CANNON.Quaternion(); | |
q.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), Math.PI / 2); | |
cannonCylinder.transformAllPoints(new CANNON.Vec3(), q); | |
chipBody.addShape(cannonCylinder); | |
// Randomize chipBody rotation around Y axis to match Three.js mesh | |
chipBody.quaternion.setFromAxisAngle(new CANNON.Vec3(0, 1, 0), randomY); | |
chipBody.velocity.set(0, 0, 0); | |
chipBody.angularVelocity.set(0, 0, 0); | |
world.addBody(chipBody); | |
chipBodies.push(chipBody); | |
} | |
tableGroup.add(chipStack); | |
tableGroup.add(platformMesh); | |
scene.add(tableGroup); | |
// Position camera to see the full stack | |
camera.position.set(0, 30, baseDistance / zoomLevel); | |
camera.lookAt(0, 15, 0); | |
// Mouse interaction variables | |
let isDragging = false; | |
let previousMousePosition = { x: 0, y: 0 }; | |
let rotation = { x: 0, y: 0 }; | |
// Mouse event handlers (SWAPPED: left = pick/throw, right = rotate) | |
// Right-click drag: rotate chip stack | |
renderer.domElement.addEventListener('mousedown', (e) => { | |
if (e.button !== 2) return; // Only right click | |
isDragging = true; | |
previousMousePosition = { x: e.clientX, y: e.clientY }; | |
}); | |
renderer.domElement.addEventListener('mousemove', (e) => { | |
if (isDragging) { | |
const deltaMove = { | |
x: e.clientX - previousMousePosition.x, | |
y: e.clientY - previousMousePosition.y | |
}; | |
rotation.y += deltaMove.x * 0.01; | |
rotation.x += deltaMove.y * 0.01; | |
// Limit vertical rotation to prevent flipping | |
rotation.x = Math.max(-Math.PI/3, Math.min(Math.PI/3, rotation.x)); | |
tableGroup.rotation.y = rotation.y; | |
tableGroup.rotation.x = rotation.x; | |
previousMousePosition = { x: e.clientX, y: e.clientY }; | |
} | |
}); | |
renderer.domElement.addEventListener('mouseup', (e) => { | |
if (e.button !== 2) return; // Only right click | |
isDragging = false; | |
}); | |
renderer.domElement.addEventListener('mouseleave', () => { | |
isDragging = false; | |
}); | |
// Scroll to zoom functionality | |
renderer.domElement.addEventListener('wheel', (e) => { | |
e.preventDefault(); | |
// Adjust zoom based on scroll direction | |
const zoomSpeed = 0.003; | |
const zoomDirection = e.deltaY > 0 ? -1 : 1; // Reverse for natural scrolling | |
zoomLevel = Math.max(minZoom, Math.min(maxZoom, zoomLevel + (zoomDirection * zoomSpeed))); | |
// Update camera position based on zoom | |
camera.position.z = baseDistance / zoomLevel; | |
}); | |
// Touch events for mobile | |
renderer.domElement.addEventListener('touchstart', (e) => { | |
e.preventDefault(); | |
const touch = e.touches[0]; | |
isDragging = true; | |
previousMousePosition = { x: touch.clientX, y: touch.clientY }; | |
}); | |
renderer.domElement.addEventListener('touchmove', (e) => { | |
e.preventDefault(); | |
if (isDragging) { | |
const touch = e.touches[0]; | |
const deltaMove = { | |
x: touch.clientX - previousMousePosition.x, | |
y: touch.clientY - previousMousePosition.y | |
}; | |
rotation.y += deltaMove.x * 0.01; | |
rotation.x += deltaMove.y * 0.01; | |
rotation.x = Math.max(-Math.PI/3, Math.min(Math.PI/3, rotation.x)); | |
tableGroup.rotation.y = rotation.y; | |
tableGroup.rotation.x = rotation.x; | |
previousMousePosition = { x: touch.clientX, y: touch.clientY }; | |
} | |
}); | |
renderer.domElement.addEventListener('touchend', (e) => { | |
e.preventDefault(); | |
isDragging = false; | |
}); | |
// File input handlers | |
function loadTexture(file, callback) { | |
const reader = new FileReader(); | |
reader.onload = function(e) { | |
const texture = textureLoader.load(e.target.result); | |
texture.wrapS = THREE.RepeatWrapping; | |
texture.wrapT = THREE.RepeatWrapping; | |
callback(texture); | |
}; | |
reader.readAsDataURL(file); | |
} | |
function loadTextureFromDataUrl(dataUrl, callback) { | |
const texture = textureLoader.load(dataUrl); | |
texture.wrapS = THREE.RepeatWrapping; | |
texture.wrapT = THREE.RepeatWrapping; | |
callback(texture); | |
} | |
function updateChipMaterials() { | |
const faceMaterial = faceTexture ? | |
new THREE.MeshPhongMaterial({ map: faceTexture, shininess: 30 }) : | |
defaultFaceMaterial; | |
// Apply rotation to face texture | |
if (faceTexture) { | |
faceTexture.center.set(0.5, 0.5); // Set rotation center to middle | |
faceTexture.rotation = faceRotation; | |
} | |
const stripMaterial = stripTexture ? | |
new THREE.MeshPhongMaterial({ map: stripTexture, shininess: 30 }) : | |
defaultStripMaterial; | |
// Configure strip texture wrapping - stretch once around the full circumference | |
if (stripTexture) { | |
stripTexture.repeat.set(1, 1); // Single wrap around the circumference | |
stripTexture.wrapS = THREE.ClampToEdgeWrapping; // Prevent repeating | |
stripTexture.center.set(0.5, 0.5); // Set rotation center | |
stripTexture.rotation = stripRotation; // Apply rotation | |
} | |
// Create dirty strip material | |
const dirtyStripMaterial = (dirtyStripTexture && showDirtyChip) ? | |
new THREE.MeshPhongMaterial({ map: dirtyStripTexture, shininess: 30 }) : | |
stripMaterial; | |
// Configure dirty strip texture if it exists | |
if (dirtyStripTexture && showDirtyChip) { | |
dirtyStripTexture.repeat.set(1, 1); | |
dirtyStripTexture.wrapS = THREE.ClampToEdgeWrapping; | |
dirtyStripTexture.center.set(0.5, 0.5); | |
dirtyStripTexture.rotation = stripRotation; // Use same rotation as main strip | |
} | |
const normalChipMaterials = [ | |
stripMaterial, // Side | |
faceMaterial, // Top | |
faceMaterial // Bottom | |
]; | |
const dirtyChipMaterials = [ | |
dirtyStripMaterial, // Side | |
faceMaterial, // Top | |
faceMaterial // Bottom | |
]; | |
// Update all chips in the stack | |
chips.forEach(chip => { | |
if (chip.userData.isDirtyChip && showDirtyChip) { | |
chip.material = dirtyChipMaterials; | |
} else { | |
chip.material = normalChipMaterials; | |
} | |
}); | |
} | |
// Function to update dirty chip button visibility and state | |
function updateDirtyChipButton() { | |
const button = document.getElementById('toggle-dirty-chip'); | |
if (dirtyStripTexture) { | |
// Show button and set appropriate text/state | |
button.style.display = 'block'; | |
if (showDirtyChip) { | |
button.textContent = 'Hide Dirty Chip'; | |
button.classList.add('active'); | |
} else { | |
button.textContent = 'Show Dirty Chip'; | |
button.classList.remove('active'); | |
} | |
} else { | |
// Hide button when no dirty texture is loaded | |
button.style.display = 'none'; | |
} | |
} | |
// --- Canvas loading overlay helpers --- | |
function showCanvasLoadingOverlay() { | |
document.getElementById('canvas-loading-overlay').style.display = 'flex'; | |
} | |
function hideCanvasLoadingOverlay() { | |
document.getElementById('canvas-loading-overlay').style.display = 'none'; | |
} | |
// --- Auto-align reusable function --- | |
async function autoAlignFaceAndStrip(showSpinner = true) { | |
const autoAlignBtn = document.getElementById('auto-align'); | |
let originalText; | |
if (showSpinner) { | |
showCanvasLoadingOverlay(); // Show overlay | |
originalText = autoAlignBtn.textContent; | |
autoAlignBtn.textContent = 'Aligning...'; | |
autoAlignBtn.disabled = true; | |
// Yield to UI so overlay shows | |
await new Promise(resolve => setTimeout(resolve, 0)); | |
} | |
try { | |
const ALIGNMENT_OFFSET_DEGREES = 0; | |
// Get current selections | |
const faceSelection = imageMemory.getCurrentSelection('face'); | |
const stripSelection = imageMemory.getCurrentSelection('strip'); | |
if (!faceSelection || !stripSelection) { | |
return false; | |
} | |
const faceDataUrl = imageMemory.getImageData('face', faceSelection); | |
const stripDataUrl = imageMemory.getImageData('strip', stripSelection); | |
if (!faceDataUrl || !stripDataUrl) { | |
return false; | |
} | |
// Helper to load image as canvas | |
function loadImageToCanvas(dataUrl) { | |
return new Promise((resolve) => { | |
const img = new window.Image(); | |
img.onload = function() { | |
const canvas = document.createElement('canvas'); | |
canvas.width = img.width; | |
canvas.height = img.height; | |
const ctx = canvas.getContext('2d'); | |
ctx.drawImage(img, 0, 0); | |
resolve({canvas, ctx, width: img.width, height: img.height}); | |
}; | |
img.src = dataUrl; | |
}); | |
} | |
// Load both images as canvases | |
const faceImg = await loadImageToCanvas(faceDataUrl); | |
const stripImg = await loadImageToCanvas(stripDataUrl); | |
// Parameters for sampling | |
const numSamples = 90; | |
const radius = Math.min(faceImg.width / 2, faceImg.height / 2); | |
const edgeInset = (radius - 3) / radius; // 3 pixels in from the edge | |
const faceCx = faceImg.width / 2; | |
const faceCy = faceImg.height / 2; | |
const faceR = Math.min(faceCx, faceCy) * edgeInset; | |
// Helper to get pixel RGBA from canvas | |
function getPixel(ctx, x, y) { | |
const d = ctx.getImageData(Math.round(x), Math.round(y), 1, 1).data; | |
return [d[0], d[1], d[2]]; | |
} | |
// Helper to compute color distance | |
function colorDist(a, b) { | |
return Math.sqrt((a[0]-b[0])**2 + (a[1]-b[1])**2 + (a[2]-b[2])**2); | |
} | |
// Try both flip states | |
let bestScore = Infinity; | |
let bestAngle = 0; | |
let bestFlip = false; | |
for (let flip of [false, true]) { | |
for (let angleDeg = 0; angleDeg < 360; angleDeg += 1) { | |
let score = 0; | |
for (let i = 0; i < numSamples; i++) { | |
const theta = ((i / numSamples) * 2 * Math.PI) + (angleDeg * Math.PI / 180); | |
// Face edge sample point | |
const fx = faceCx + faceR * Math.cos(theta); | |
const fy = faceCy + faceR * Math.sin(theta); | |
const faceColor = getPixel(faceImg.ctx, fx, fy); | |
// Corresponding strip x (wraps around) | |
let stripX; | |
if (flip) { | |
stripX = ((numSamples - i) / numSamples) * stripImg.width; | |
} else { | |
stripX = (i / numSamples) * stripImg.width; | |
} | |
const stripColor = getPixel(stripImg.ctx, stripX, 0); // top row | |
score += colorDist(faceColor, stripColor); | |
} | |
if (score < bestScore) { | |
bestScore = score; | |
bestAngle = angleDeg; | |
bestFlip = flip; | |
} | |
// Yield to UI every 30 steps | |
if (angleDeg % 30 === 0) { | |
await new Promise(resolve => setTimeout(resolve, 0)); | |
} | |
} | |
} | |
// --- Fine adjustment step: test small offsets around bestAngle --- | |
const fineOffsets = Array.from({length: 31}, (_, i) => -3.75 + i * 0.25); | |
const numFineSamples = numSamples * 8; | |
let bestFineScore = Infinity; | |
let bestFineAngle = bestAngle; | |
for (let offset of fineOffsets) { | |
let score = 0; | |
for (let i = 0; i < numFineSamples; i++) { | |
const theta = ((i / numFineSamples) * 2 * Math.PI) + ((bestAngle + offset) * Math.PI / 180); | |
// Face edge sample point | |
const fx = faceCx + faceR * Math.cos(theta); | |
const fy = faceCy + faceR * Math.sin(theta); | |
const faceColor = getPixel(faceImg.ctx, fx, fy); | |
// Corresponding strip x (wraps around) | |
let stripX; | |
if (bestFlip) { | |
stripX = ((numFineSamples - i) / numFineSamples) * stripImg.width; | |
} else { | |
stripX = (i / numFineSamples) * stripImg.width; | |
} | |
const stripColor = getPixel(stripImg.ctx, stripX, 0); // top row | |
score += colorDist(faceColor, stripColor); | |
} | |
if (score < bestFineScore) { | |
bestFineScore = score; | |
bestFineAngle = bestAngle + offset; | |
} | |
} | |
// --- Use fine-adjusted angle for final alignment --- | |
updateRotationDisplay(((bestFineAngle + ALIGNMENT_OFFSET_DEGREES) % 360 + 360) % 360); | |
stripRotation = !bestFlip ? Math.PI : 0; | |
if (stripTexture) { | |
stripTexture.center.set(0.5, 0.5); | |
stripTexture.rotation = stripRotation; | |
stripTexture.needsUpdate = true; | |
} | |
if (dirtyStripTexture) { | |
dirtyStripTexture.center.set(0.5, 0.5); | |
dirtyStripTexture.rotation = stripRotation; | |
dirtyStripTexture.needsUpdate = true; | |
} | |
saveCurrentFaceSettings(); | |
updateChipMaterials(); | |
return true; | |
} finally { | |
if (showSpinner) { | |
hideCanvasLoadingOverlay(); // Hide overlay | |
autoAlignBtn.textContent = originalText; | |
autoAlignBtn.disabled = false; | |
} | |
} | |
} | |
// Enhanced file input handlers with memory | |
function handleFileUpload(file, type, dropdown) { | |
if (file) { | |
const reader = new FileReader(); | |
reader.onload = function(e) { | |
const dataUrl = e.target.result; | |
// Save to memory | |
imageMemory.addImage(type, file.name, dataUrl); | |
imageMemory.setCurrentSelection(type, file.name); | |
// If face, reset settings | |
if (type === 'face') { | |
imageMemory.setFaceSettings(file.name, { angle: 0, flip: false }); | |
} | |
// Update dropdown | |
dropdown.updateDropdown(); | |
// Load texture | |
loadTextureFromDataUrl(dataUrl, (texture) => { | |
switch (type) { | |
case 'face': | |
faceTexture = texture; | |
applyFaceSettings(file.name); | |
break; | |
case 'strip': | |
stripTexture = texture; | |
break; | |
case 'dirty-strip': | |
dirtyStripTexture = texture; | |
showDirtyChip = true; | |
saveDirtyChipState(); | |
updateDirtyChipButton(); | |
break; | |
} | |
updateChipMaterials(); | |
// --- Auto-align after upload if both face and strip are present --- | |
if ((type === 'face' || type === 'strip')) { | |
// Only auto-align if both are present | |
const faceSelection = imageMemory.getCurrentSelection('face'); | |
const stripSelection = imageMemory.getCurrentSelection('strip'); | |
if (faceSelection && stripSelection) { | |
autoAlignFaceAndStrip(true); // show spinner | |
} | |
} | |
}); | |
}; | |
reader.readAsDataURL(file); | |
} | |
} | |
document.getElementById('face-texture').addEventListener('change', (e) => { | |
const file = e.target.files[0]; | |
handleFileUpload(file, 'face', faceDropdown); | |
}); | |
document.getElementById('strip-texture').addEventListener('change', (e) => { | |
const file = e.target.files[0]; | |
handleFileUpload(file, 'strip', stripDropdown); | |
}); | |
document.getElementById('dirty-strip-texture').addEventListener('change', (e) => { | |
const file = e.target.files[0]; | |
handleFileUpload(file, 'dirty-strip', dirtyDropdown); | |
}); | |
// Face rotation controls | |
let currentRotationDegrees = 0; | |
function updateRotationDisplay(degrees) { | |
// Normalize degrees to 0-360 range | |
degrees = ((degrees % 360) + 360) % 360; | |
currentRotationDegrees = degrees; | |
// Update display | |
document.getElementById('rotation-value').textContent = degrees.toFixed(2) + '°'; | |
document.getElementById('face-rotation').value = Math.round(degrees); | |
// Update texture rotation | |
faceRotation = (degrees * Math.PI) / 180; | |
if (faceTexture) { | |
faceTexture.center.set(0.5, 0.5); | |
faceTexture.rotation = faceRotation; | |
faceTexture.needsUpdate = true; | |
} | |
} | |
// Face rotation slider | |
document.getElementById('face-rotation').addEventListener('input', (e) => { | |
const degrees = parseInt(e.target.value); | |
updateRotationDisplay(degrees); | |
saveCurrentFaceSettings(); | |
}); | |
// Fine control buttons | |
document.getElementById('rotation-plus').addEventListener('click', () => { | |
updateRotationDisplay(currentRotationDegrees + 0.25); | |
saveCurrentFaceSettings(); | |
}); | |
document.getElementById('rotation-minus').addEventListener('click', () => { | |
updateRotationDisplay(currentRotationDegrees - 0.25); | |
saveCurrentFaceSettings(); | |
}); | |
// Strip flip button | |
document.getElementById('flip-strip').addEventListener('click', () => { | |
stripRotation = stripRotation === 0 ? Math.PI : 0; // Toggle between 0 and 180 degrees | |
if (stripTexture) { | |
stripTexture.center.set(0.5, 0.5); | |
stripTexture.rotation = stripRotation; | |
stripTexture.needsUpdate = true; // Force texture update | |
} | |
if (dirtyStripTexture) { | |
dirtyStripTexture.center.set(0.5, 0.5); | |
dirtyStripTexture.rotation = stripRotation; | |
dirtyStripTexture.needsUpdate = true; | |
} | |
saveCurrentFaceSettings(); | |
}); | |
// Auto align button | |
document.getElementById('auto-align').addEventListener('click', async () => { | |
await autoAlignFaceAndStrip(true); | |
}); | |
// Toggle dirty chip visibility | |
document.getElementById('toggle-dirty-chip').addEventListener('click', (e) => { | |
showDirtyChip = !showDirtyChip; | |
saveDirtyChipState(); | |
updateDirtyChipButton(); | |
updateChipMaterials(); | |
}); | |
// Load saved selections on page load | |
function loadSavedSelections() { | |
const faceSelection = imageMemory.getCurrentSelection('face'); | |
const stripSelection = imageMemory.getCurrentSelection('strip'); | |
const dirtyStripSelection = imageMemory.getCurrentSelection('dirty-strip'); | |
// Restore dirty chip toggle state | |
showDirtyChip = loadDirtyChipState(); | |
updateDirtyChipButton(); | |
if (faceSelection) { | |
const faceData = imageMemory.getImageData('face', faceSelection); | |
if (faceData) { | |
loadTextureFromDataUrl(faceData, (texture) => { | |
faceTexture = texture; | |
applyFaceSettings(faceSelection); | |
updateChipMaterials(); | |
}); | |
} | |
} | |
if (stripSelection) { | |
const stripData = imageMemory.getImageData('strip', stripSelection); | |
if (stripData) { | |
loadTextureFromDataUrl(stripData, (texture) => { | |
stripTexture = texture; | |
updateChipMaterials(); | |
}); | |
} | |
} | |
if (dirtyStripSelection) { | |
const dirtyStripData = imageMemory.getImageData('dirty-strip', dirtyStripSelection); | |
if (dirtyStripData) { | |
loadTextureFromDataUrl(dirtyStripData, (texture) => { | |
dirtyStripTexture = texture; | |
// showDirtyChip is already set from localStorage | |
updateDirtyChipButton(); | |
updateChipMaterials(); | |
}); | |
} | |
} | |
} | |
// --- PHYSICS ENABLE FLAG --- | |
let physicsStarted = false; | |
// --- FPS-based geometry downgrade --- | |
let lowFpsFrameCount = 0; | |
let segmentsReduced = false; | |
// Animation loop | |
let lastFpsUpdate = 0; | |
let frames = 0; | |
let fps = 0; | |
function animate() { | |
requestAnimationFrame(animate); | |
// FPS calculation | |
frames++; | |
const now = performance.now(); | |
if (now - lastFpsUpdate > 500) { // update every 0.5s | |
fps = Math.round((frames * 1000) / (now - lastFpsUpdate)); | |
const fpsElem = document.getElementById('fps-display'); | |
if (fpsElem) fpsElem.textContent = `FPS: ${fps}`; | |
lastFpsUpdate = now; | |
frames = 0; | |
} | |
// Track low FPS and downgrade geometry if needed | |
if (!segmentsReduced) { | |
if (fps > 0 && fps < 45) { | |
lowFpsFrameCount++; | |
if (lowFpsFrameCount >= 100) { | |
downgradeChipSegmentsIfNeeded(); | |
} | |
} else { | |
lowFpsFrameCount = 0; | |
} | |
} | |
if (physicsStarted) { | |
// Step physics world | |
world.step(1/60); | |
// Sync chip meshes to physics bodies | |
for (let i = 0; i < chips.length; i++) { | |
chips[i].position.copy(chipBodies[i].position); | |
chips[i].quaternion.copy(chipBodies[i].quaternion); | |
} | |
} | |
renderer.render(scene, camera); | |
} | |
// Handle window resize | |
function updateCanvasSize() { | |
const sidebar = document.querySelector('.sidebar'); | |
const container = document.getElementById('canvas-container'); | |
// Calculate available space | |
let availableWidth, availableHeight; | |
if (window.innerWidth <= 768) { | |
// Mobile layout | |
availableWidth = window.innerWidth - 40; | |
availableHeight = window.innerHeight * 0.6 - 80; | |
} else { | |
// Desktop layout - use full height minus container padding | |
availableWidth = window.innerWidth - sidebar.offsetWidth - 80; | |
availableHeight = window.innerHeight - 40; // Full height minus container padding | |
} | |
// Use full available space | |
let canvasWidth = availableWidth; | |
let canvasHeight = availableHeight; | |
camera.aspect = canvasWidth / canvasHeight; | |
camera.updateProjectionMatrix(); | |
renderer.setSize(canvasWidth, canvasHeight); | |
renderer.setPixelRatio(window.devicePixelRatio); | |
} | |
window.addEventListener('resize', updateCanvasSize); | |
// Start animation | |
animate(); | |
// Initial setup | |
setTimeout(() => { | |
updateCanvasSize(); | |
updateDirtyChipButton(); // Initialize button visibility | |
loadSavedSelections(); // Load previously selected textures | |
}, 100); | |
// --- Per-face-image settings helpers --- | |
function applyFaceSettings(filename) { | |
const settings = imageMemory.getFaceSettings(filename); | |
// Set face alignment | |
updateRotationDisplay(settings.angle); | |
// Set flip strip | |
stripRotation = settings.flip ? Math.PI : 0; | |
if (stripTexture) { | |
stripTexture.center.set(0.5, 0.5); | |
stripTexture.rotation = stripRotation; | |
stripTexture.needsUpdate = true; | |
} | |
if (dirtyStripTexture) { | |
dirtyStripTexture.center.set(0.5, 0.5); | |
dirtyStripTexture.rotation = stripRotation; | |
dirtyStripTexture.needsUpdate = true; | |
} | |
// Update flip button UI | |
// (no visual toggle, but state is set) | |
} | |
function saveCurrentFaceSettings() { | |
const faceSelection = imageMemory.getCurrentSelection('face'); | |
if (faceSelection) { | |
imageMemory.setFaceSettings(faceSelection, { | |
angle: currentRotationDegrees, | |
flip: stripRotation === Math.PI | |
}); | |
} | |
} | |
// --- GLOBAL DIRTY CHIP STATE PERSISTENCE HELPERS --- | |
function saveDirtyChipState() { | |
try { | |
localStorage.setItem('poker-chip-show-dirty-chip', JSON.stringify(showDirtyChip)); | |
} catch (e) { | |
console.warn('Failed to save dirty chip state:', e); | |
} | |
} | |
function loadDirtyChipState() { | |
try { | |
const data = localStorage.getItem('poker-chip-show-dirty-chip'); | |
return data ? JSON.parse(data) : false; | |
} catch (e) { | |
console.warn('Failed to load dirty chip state:', e); | |
return false; | |
} | |
} | |
// --- PHYSICS INTERACTION (RIGHT-CLICK DRAG/THROW) --- | |
let draggingChipIndex = null; | |
let dragStart = null; | |
let dragLast = null; | |
let dragStartTime = null; | |
let dragPlane = null; | |
let dragOffset = null; | |
let raycaster = new THREE.Raycaster(); | |
let mouse = new THREE.Vector2(); | |
let rightDragPrevMouseY = null; | |
// Helper: get mouse position in normalized device coordinates | |
function getMouseNDC(event) { | |
const rect = renderer.domElement.getBoundingClientRect(); | |
return { | |
x: ((event.clientX - rect.left) / rect.width) * 2 - 1, | |
y: -((event.clientY - rect.top) / rect.height) * 2 + 1 | |
}; | |
} | |
// Helper: project mouse to a plane in world space | |
function getWorldPointOnPlane(ndc, plane) { | |
raycaster.setFromCamera(ndc, camera); | |
const intersection = new THREE.Vector3(); | |
if (raycaster.ray.intersectPlane(plane, intersection)) { | |
return intersection; | |
} | |
return null; | |
} | |
// Left mouse down: try to pick a chip (SWAPPED) | |
renderer.domElement.addEventListener('mousedown', (event) => { | |
if (event.button !== 0) return; // Only left-click | |
event.preventDefault(); | |
// --- START PHYSICS ON FIRST LEFT-CLICK --- | |
if (!physicsStarted) { | |
physicsStarted = true; | |
} | |
const ndc = getMouseNDC(event); | |
raycaster.setFromCamera(ndc, camera); | |
const intersects = raycaster.intersectObjects(chips); | |
if (intersects.length > 0) { | |
draggingChipIndex = chips.indexOf(intersects[0].object); | |
dragStartTime = performance.now(); | |
// Drag plane: parallel to ground, through chip | |
const chipPos = chips[draggingChipIndex].position.clone(); | |
dragPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -chipPos.y); | |
dragStart = getWorldPointOnPlane(ndc, dragPlane); | |
dragLast = dragStart.clone(); | |
// Offset between chip center and mouse point | |
dragOffset = chipPos.clone().sub(dragStart); | |
// Make chip kinematic (disable physics for dragging) | |
chipBodies[draggingChipIndex].type = CANNON.Body.KINEMATIC; | |
chipBodies[draggingChipIndex].velocity.set(0,0,0); | |
chipBodies[draggingChipIndex].angularVelocity.set(0,0,0); | |
} | |
}); | |
// Mouse move: drag chip if selected (SWAPPED) | |
renderer.domElement.addEventListener('mousemove', (event) => { | |
if ( | |
draggingChipIndex === null || | |
!dragStart || | |
!dragLast || | |
!dragPlane || | |
!dragOffset | |
) return; | |
// Move chip in X and Y only, based on mouse movement | |
const chipBody = chipBodies[draggingChipIndex]; | |
const currentPos = chipBody.position; | |
if (!window._leftDragPrev) { | |
window._leftDragPrev = { x: event.clientX, y: event.clientY }; | |
} | |
const deltaX = event.clientX - window._leftDragPrev.x; | |
const deltaY = event.clientY - window._leftDragPrev.y; | |
const sensitivityX = 0.07; // Adjust for feel | |
const sensitivityY = 0.07; | |
const newX = currentPos.x + deltaX * sensitivityX; | |
const newY = currentPos.y - deltaY * sensitivityY; | |
chipBody.position.set(newX, newY, currentPos.z); | |
chipBody.velocity.set(0,0,0); | |
chipBody.angularVelocity.set(0,0,0); | |
window._leftDragPrev = { x: event.clientX, y: event.clientY }; | |
}); | |
// Mouse up: release chip and throw (SWAPPED) | |
renderer.domElement.addEventListener('mouseup', (event) => { | |
if ( | |
event.button !== 0 || | |
draggingChipIndex === null || | |
!dragStart || | |
!dragLast | |
) return; | |
const now = performance.now(); | |
const dt = Math.max((now - dragStartTime) / 1000, 0.016); | |
// Compute throw velocity (dragLast - dragStart) / dt | |
const throwVec = dragLast.clone().sub(dragStart).divideScalar(dt); | |
// Limit max velocity | |
const maxVel = 20; | |
throwVec.clampLength(0, maxVel); | |
// Set chip to dynamic and apply velocity | |
chipBodies[draggingChipIndex].type = CANNON.Body.DYNAMIC; | |
chipBodies[draggingChipIndex].velocity.set(throwVec.x, 5, throwVec.z); // Add upward force | |
chipBodies[draggingChipIndex].angularVelocity.set(Math.random()-0.5, Math.random()-0.5, Math.random()-0.5); | |
// Reset drag state | |
draggingChipIndex = null; | |
dragStart = null; | |
dragLast = null; | |
dragPlane = null; | |
dragOffset = null; | |
dragStartTime = null; | |
window._leftDragPrev = null; | |
}); | |
// Prevent context menu on right-click | |
renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault()); | |
function downgradeChipSegmentsIfNeeded() { | |
if (segmentsReduced) return; | |
// Replace chip geometry with lower segment count | |
chipSegments = 32; | |
chipGeometry.dispose(); | |
chipGeometry = new THREE.CylinderGeometry(chipRadius, chipRadius, chipHeight, chipSegments); | |
// Update all chips to use new geometry | |
chips.forEach(chip => { | |
chip.geometry.dispose(); | |
chip.geometry = chipGeometry; | |
}); | |
segmentsReduced = true; | |
} | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment