Last active
July 1, 2025 11:51
-
-
Save dylan-chong/dc0ae6a5464df642a4272680074bb3ef to your computer and use it in GitHub Desktop.
3d Poker Chip Renderer https://playcode.io/pokerchiprenderer3d
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> | |
<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 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>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; | |
const chipSegments = 256; // 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 | |
const 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 | |
for (let i = 0; i < numChips; i++) { | |
const chip = new THREE.Mesh(chipGeometry, chipMaterials); | |
chip.position.y = (-i + 14) * chipHeight; | |
chip.rotation.y = Math.random() * Math.PI * 2; // Random rotation 0-360 degrees | |
chip.castShadow = true; | |
chip.receiveShadow = true; | |
chip.userData.isDirtyChip = (i % 5 === 1); | |
chips.push(chip); | |
chipStack.add(chip); | |
} | |
scene.add(chipStack); | |
// 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 | |
renderer.domElement.addEventListener('mousedown', (e) => { | |
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)); | |
chipStack.rotation.y = rotation.y; | |
chipStack.rotation.x = rotation.x; | |
previousMousePosition = { x: e.clientX, y: e.clientY }; | |
} | |
}); | |
renderer.domElement.addEventListener('mouseup', () => { | |
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)); | |
chipStack.rotation.y = rotation.y; | |
chipStack.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(); | |
}); | |
} | |
} | |
} | |
// Animation loop | |
function animate() { | |
requestAnimationFrame(animate); | |
// Gentle floating animation when not being dragged | |
if (!isDragging) { | |
chipStack.position.y = Math.sin(Date.now() * 0.001) * 0.5; | |
} | |
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; | |
} | |
} | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment