Skip to content

Instantly share code, notes, and snippets.

@dylan-chong
Last active July 1, 2025 11:51
Show Gist options
  • Save dylan-chong/dc0ae6a5464df642a4272680074bb3ef to your computer and use it in GitHub Desktop.
Save dylan-chong/dc0ae6a5464df642a4272680074bb3ef to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>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;"
>
</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