Last active
November 2, 2025 03:38
-
-
Save dylan-chong/dc0ae6a5464df642a4272680074bb3ef to your computer and use it in GitHub Desktop.
3d Poker Chip Renderer https://pokerchiprendererv2.playcode.io
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-visibility { | |
| 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; | |
| box-sizing: border-box; | |
| } | |
| #toggle-dirty-chip-visibility:hover { | |
| background: #F57C00; | |
| } | |
| #toggle-dirty-chip-visibility.active { | |
| background: #4CAF50; | |
| } | |
| #toggle-dirty-chip-visibility.active:hover { | |
| background: #45a049; | |
| } | |
| #cycle-dirty-chip-count { | |
| margin-top: 8px; | |
| padding: 8px 12px; | |
| background: #2196F3; | |
| color: white; | |
| border: none; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| width: 100%; | |
| font-size: 12px; | |
| transition: background 0.3s; | |
| display: none; | |
| box-sizing: border-box; | |
| } | |
| #cycle-dirty-chip-count:hover { | |
| background: #1976D2; | |
| } | |
| #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; | |
| } | |
| #make-messy:hover { | |
| background: #F57C00 !important; | |
| } | |
| .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-visibility">Show Dirty Chips</button> | |
| <button id="cycle-dirty-chip-count">Dirty Chips: Single</button> | |
| </div> | |
| <div class="file-input-container"> | |
| <label>Lighting Controls</label> | |
| <div style="margin-top: 12px;"> | |
| <label for="lighting-intensity" style="font-size: 12px; display: block; margin-bottom: 4px;">Intensity</label> | |
| <input type="range" id="lighting-intensity" min="0" max="6" step="1" value="3" style="width: 100%; margin: 0;" /> | |
| <div id="intensity-value" style="font-size: 12px; color: #ccc; text-align: center; margin-top: 4px;">3</div> | |
| </div> | |
| <div style="margin-top: 16px;"> | |
| <label for="lighting-color" style="font-size: 12px; display: block; margin-bottom: 4px;">Colour</label> | |
| <input type="range" id="lighting-color" min="0" max="6" step="1" value="3" style="width: 100%; margin: 0;" /> | |
| <div id="color-value" style="font-size: 12px; color: #ccc; text-align: center; margin-top: 4px;">3</div> | |
| </div> | |
| </div> | |
| <div class="file-input-container"> | |
| <label>Physics Simulation</label> | |
| <button id="start-physics" class="action-btn">Start Physics</button> | |
| <button id="make-messy" class="action-btn" style="background: #FF9800; margin-top: 8px;">Make Stack Messy</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>Click "Start Physics" to enable physics simulation</b></li> | |
| <li><b>Right-click and drag a chip to pick up and throw it</b></li> | |
| <li>Left-click and drag to rotate the chip stack</li> | |
| <li>Scroll to zoom in/out</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; | |
| dirtyChipConfigIndex = 0; | |
| 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; | |
| bottomFaceTexture = null; | |
| applyFaceSettings(filename); | |
| break; | |
| case 'strip': | |
| stripTexture = texture; | |
| break; | |
| case 'dirty-strip': | |
| dirtyStripTexture = texture; | |
| 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.3); | |
| 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); | |
| const INTENSITY_OPTIONS = [ | |
| { name: 'Very Dark', value: 0.46 }, | |
| { name: 'Dark', value: 0.64 }, | |
| { name: 'Dim', value: 0.82 }, | |
| { name: 'Medium', value: 1.0 }, | |
| { name: 'Bright', value: 1.18 }, | |
| { name: 'Very Bright', value: 1.36 }, | |
| { name: 'Maximum', value: 1.54 } | |
| ]; | |
| const COLOR_OPTIONS = [ | |
| { name: 'Very Warm', ambient: 0x543420, directional: 0xffddbb, point: 0xffddbb }, | |
| { name: 'Warm', ambient: 0x4c3828, directional: 0xffe9dd, point: 0xffe9dd }, | |
| { name: 'Slightly Warm', ambient: 0x443c30, directional: 0xfff5ef, point: 0xfff5ef }, | |
| { name: 'Neutral', ambient: 0x404040, directional: 0xffffff, point: 0xffffff }, | |
| { name: 'Cool', ambient: 0x3c4044, directional: 0xf5f7ff, point: 0xf5f7ff }, | |
| { name: 'Very Cool', ambient: 0x384048, directional: 0xe9efff, point: 0xe9efff }, | |
| { name: 'Cold', ambient: 0x304450, directional: 0xdde7ff, point: 0xdde7ff } | |
| ]; | |
| let lightingIntensityIndex = 3; | |
| let lightingColorIndex = 3; | |
| // Poker chip dimensions (39mm diameter = 19.5mm radius, 3mm height) | |
| const chipRadius = 19.5; | |
| const chipHeight = 3; | |
| let chipSegments = 64; | |
| 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%) | |
| const textureLoader = new THREE.TextureLoader(); | |
| let faceTexture = null; | |
| let bottomFaceTexture = null; | |
| let stripTexture = null; | |
| let dirtyStripTexture = null; | |
| const DIRTY_CHIP_CONFIGS = [ | |
| { | |
| id: 'single', | |
| displayName: 'Single', | |
| isDirtyChip: (index) => index === Math.floor(numChips / 2) - 1 | |
| }, | |
| { | |
| id: 'fourth', | |
| displayName: 'Every 4th', | |
| isDirtyChip: (index) => index % 4 === 1 | |
| }, | |
| { | |
| id: 'second', | |
| displayName: 'Every 2nd', | |
| isDirtyChip: (index) => index % 2 === 1 | |
| } | |
| ]; | |
| let dirtyChipConfigIndex = 0; | |
| let dirtyChipVisible = 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); | |
| // --- PHYSICS SETUP --- | |
| const world = new CANNON.World(); | |
| world.gravity.set(0, -39.28, 0); | |
| world.broadphase = new CANNON.NaiveBroadphase(); | |
| world.solver.iterations = 30; | |
| const platformWidth = chipRadius * 30; | |
| const platformDepth = chipRadius * 30; | |
| const platformHeight = chipHeight * 1.5; | |
| const platformY = -chipHeight * 2; | |
| const bottomChipY = platformY + platformHeight / 2 + chipHeight / 2; | |
| const chipSpacing = chipHeight + 1e-3; | |
| const platformGeometry = new THREE.PlaneGeometry(platformWidth, platformDepth); | |
| const platformMaterial = new THREE.MeshPhongMaterial({ | |
| color: 0x222222, | |
| shininess: 10, | |
| side: THREE.FrontSide | |
| }); | |
| const platformMesh = new THREE.Mesh(platformGeometry, platformMaterial); | |
| platformMesh.position.set(0, platformY, 0); | |
| platformMesh.rotation.x = -Math.PI / 2; | |
| platformMesh.receiveShadow = true; | |
| const tableGroup = new THREE.Group(); | |
| 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); | |
| const chipBodies = []; | |
| for (let i = 0; i < numChips; i++) { | |
| const chip = new THREE.Mesh(chipGeometry, chipMaterials); | |
| chip.position.y = bottomChipY + i * chipSpacing; | |
| const randomY = Math.random() * Math.PI * 2; | |
| chip.rotation.y = randomY; | |
| chip.castShadow = true; | |
| chip.receiveShadow = true; | |
| chip.userData.chipIndex = i; | |
| chips.push(chip); | |
| chipStack.add(chip); | |
| 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 }) | |
| }); | |
| const cannonCylinder = new CANNON.Cylinder(chipRadius, chipRadius, chipHeight, chipSegments); | |
| 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); | |
| 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 }; | |
| // Physics interaction variables | |
| 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(); | |
| 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 | |
| }; | |
| } | |
| function getWorldPointOnPlane(ndc, plane) { | |
| raycaster.setFromCamera(ndc, camera); | |
| const intersection = new THREE.Vector3(); | |
| if (raycaster.ray.intersectPlane(plane, intersection)) { | |
| return intersection; | |
| } | |
| return null; | |
| } | |
| // Mouse event handlers (combined left/right click) | |
| renderer.domElement.addEventListener('mousedown', (e) => { | |
| if (e.button === 0) { | |
| isDragging = true; | |
| previousMousePosition = { x: e.clientX, y: e.clientY }; | |
| } else if (e.button === 2) { | |
| e.preventDefault(); | |
| const ndc = getMouseNDC(e); | |
| raycaster.setFromCamera(ndc, camera); | |
| const intersects = raycaster.intersectObjects(chips); | |
| if (intersects.length > 0) { | |
| draggingChipIndex = chips.indexOf(intersects[0].object); | |
| dragStartTime = performance.now(); | |
| 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(); | |
| dragOffset = chipPos.clone().sub(dragStart); | |
| chipBodies[draggingChipIndex].type = CANNON.Body.KINEMATIC; | |
| chipBodies[draggingChipIndex].velocity.set(0,0,0); | |
| chipBodies[draggingChipIndex].angularVelocity.set(0,0,0); | |
| } | |
| } | |
| }); | |
| 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; | |
| 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 }; | |
| } | |
| if ( | |
| draggingChipIndex !== null && | |
| dragStart && | |
| dragLast && | |
| dragPlane && | |
| dragOffset | |
| ) { | |
| const chipBody = chipBodies[draggingChipIndex]; | |
| const currentPos = chipBody.position; | |
| if (!window._leftDragPrev) { | |
| window._leftDragPrev = { x: e.clientX, y: e.clientY }; | |
| } | |
| const deltaX = e.clientX - window._leftDragPrev.x; | |
| const deltaY = e.clientY - window._leftDragPrev.y; | |
| const sensitivityX = 0.07; | |
| 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: e.clientX, y: e.clientY }; | |
| } | |
| }); | |
| renderer.domElement.addEventListener('mouseup', (e) => { | |
| if (e.button === 0) { | |
| isDragging = false; | |
| } else if (e.button === 2) { | |
| if ( | |
| draggingChipIndex !== null && | |
| dragStart && | |
| dragLast | |
| ) { | |
| const now = performance.now(); | |
| const dt = Math.max((now - dragStartTime) / 1000, 0.016); | |
| const throwVec = dragLast.clone().sub(dragStart).divideScalar(dt); | |
| const maxVel = 20; | |
| throwVec.clampLength(0, maxVel); | |
| chipBodies[draggingChipIndex].type = CANNON.Body.DYNAMIC; | |
| chipBodies[draggingChipIndex].velocity.set(throwVec.x, 5, throwVec.z); | |
| chipBodies[draggingChipIndex].angularVelocity.set(Math.random()-0.5, Math.random()-0.5, Math.random()-0.5); | |
| draggingChipIndex = null; | |
| dragStart = null; | |
| dragLast = null; | |
| dragPlane = null; | |
| dragOffset = null; | |
| dragStartTime = null; | |
| window._leftDragPrev = null; | |
| } | |
| } | |
| }); | |
| renderer.domElement.addEventListener('mouseleave', () => { | |
| isDragging = false; | |
| }); | |
| renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault()); | |
| // Scroll to zoom functionality | |
| renderer.domElement.addEventListener('wheel', (e) => { | |
| e.preventDefault(); | |
| const zoomSpeed = 0.007; | |
| const zoomDirection = e.deltaY > 0 ? -1 : 1; | |
| zoomLevel = Math.max(minZoom, Math.min(maxZoom, zoomLevel + (zoomDirection * zoomSpeed))); | |
| 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 loadFaceTexturesFromDataUrl(dataUrl, callback) { | |
| const topTexture = textureLoader.load(dataUrl); | |
| topTexture.wrapS = THREE.RepeatWrapping; | |
| topTexture.wrapT = THREE.RepeatWrapping; | |
| const bottomTexture = textureLoader.load(dataUrl); | |
| bottomTexture.wrapS = THREE.RepeatWrapping; | |
| bottomTexture.wrapT = THREE.RepeatWrapping; | |
| callback(topTexture, bottomTexture); | |
| } | |
| function updateChipMaterials() { | |
| if (faceTexture && !bottomFaceTexture) { | |
| const currentSelection = imageMemory.getCurrentSelection('face'); | |
| if (currentSelection) { | |
| const faceData = imageMemory.getImageData('face', currentSelection); | |
| if (faceData) { | |
| const bottomTexture = textureLoader.load(faceData); | |
| bottomTexture.wrapS = THREE.RepeatWrapping; | |
| bottomTexture.wrapT = THREE.RepeatWrapping; | |
| bottomFaceTexture = bottomTexture; | |
| } | |
| } | |
| } | |
| const topFaceMaterial = faceTexture ? | |
| new THREE.MeshPhongMaterial({ map: faceTexture, shininess: 30 }) : | |
| defaultFaceMaterial; | |
| // Apply rotation to top face texture | |
| if (faceTexture) { | |
| faceTexture.center.set(0.5, 0.5); | |
| faceTexture.rotation = faceRotation; | |
| } | |
| const bottomFaceMaterial = bottomFaceTexture ? | |
| new THREE.MeshPhongMaterial({ map: bottomFaceTexture, shininess: 30 }) : | |
| defaultFaceMaterial; | |
| if (bottomFaceTexture) { | |
| bottomFaceTexture.center.set(0.5, 0.5); | |
| bottomFaceTexture.rotation = -faceRotation | |
| bottomFaceTexture.repeat.y = -1; | |
| bottomFaceTexture.offset.y = 1; | |
| } | |
| 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 | |
| } | |
| const currentConfig = DIRTY_CHIP_CONFIGS[dirtyChipConfigIndex]; | |
| const dirtyStripMaterial = (dirtyStripTexture && dirtyChipVisible) ? | |
| new THREE.MeshPhongMaterial({ map: dirtyStripTexture, shininess: 30 }) : | |
| stripMaterial; | |
| if (dirtyStripTexture && dirtyChipVisible) { | |
| dirtyStripTexture.repeat.set(1, 1); | |
| dirtyStripTexture.wrapS = THREE.ClampToEdgeWrapping; | |
| dirtyStripTexture.center.set(0.5, 0.5); | |
| dirtyStripTexture.rotation = stripRotation; | |
| } | |
| const normalChipMaterials = [ | |
| stripMaterial, | |
| topFaceMaterial, | |
| bottomFaceMaterial | |
| ]; | |
| const dirtyChipMaterials = [ | |
| dirtyStripMaterial, | |
| topFaceMaterial, | |
| bottomFaceMaterial | |
| ]; | |
| chips.forEach(chip => { | |
| if (dirtyChipVisible && currentConfig.isDirtyChip(chip.userData.chipIndex)) { | |
| chip.material = dirtyChipMaterials; | |
| } else { | |
| chip.material = normalChipMaterials; | |
| } | |
| }); | |
| } | |
| function updateDirtyChipButton() { | |
| const visibilityButton = document.getElementById('toggle-dirty-chip-visibility'); | |
| const countButton = document.getElementById('cycle-dirty-chip-count'); | |
| if (dirtyStripTexture) { | |
| visibilityButton.style.display = 'block'; | |
| countButton.style.display = 'block'; | |
| const currentConfig = DIRTY_CHIP_CONFIGS[dirtyChipConfigIndex]; | |
| if (!dirtyChipVisible) { | |
| visibilityButton.textContent = 'Show Dirty Chips'; | |
| visibilityButton.classList.remove('active'); | |
| countButton.style.opacity = '0.5'; | |
| countButton.style.pointerEvents = 'none'; | |
| } else { | |
| visibilityButton.textContent = 'Hide Dirty Chips'; | |
| visibilityButton.classList.add('active'); | |
| countButton.style.opacity = '1'; | |
| countButton.style.pointerEvents = 'auto'; | |
| countButton.textContent = `Dirty Chips: ${currentConfig.displayName}`; | |
| } | |
| } else { | |
| visibilityButton.style.display = 'none'; | |
| countButton.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; | |
| bottomFaceTexture = null; | |
| applyFaceSettings(file.name); | |
| break; | |
| case 'strip': | |
| stripTexture = texture; | |
| break; | |
| case 'dirty-strip': | |
| dirtyStripTexture = texture; | |
| saveDirtyChipState(); | |
| updateDirtyChipButton(); | |
| break; | |
| } | |
| updateChipMaterials(); | |
| // --- Auto-align after upload if both face and strip are present --- | |
| if ((type === 'face' || type === 'strip')) { | |
| const faceSelection = imageMemory.getCurrentSelection('face'); | |
| const stripSelection = imageMemory.getCurrentSelection('strip'); | |
| if (faceSelection && stripSelection) { | |
| autoAlignFaceAndStrip(true); | |
| } | |
| } | |
| }); | |
| }; | |
| 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.rotation = faceRotation; | |
| faceTexture.needsUpdate = true; | |
| } | |
| if (bottomFaceTexture) { | |
| bottomFaceTexture.rotation = -faceRotation; | |
| bottomFaceTexture.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); | |
| }); | |
| document.getElementById('toggle-dirty-chip-visibility').addEventListener('click', (e) => { | |
| dirtyChipVisible = !dirtyChipVisible; | |
| saveDirtyChipState(); | |
| updateDirtyChipButton(); | |
| updateChipMaterials(); | |
| }); | |
| document.getElementById('cycle-dirty-chip-count').addEventListener('click', (e) => { | |
| dirtyChipConfigIndex = (dirtyChipConfigIndex + 1) % DIRTY_CHIP_CONFIGS.length; | |
| saveDirtyChipState(); | |
| updateDirtyChipButton(); | |
| updateChipMaterials(); | |
| }); | |
| document.getElementById('start-physics').addEventListener('click', (e) => { | |
| const btn = e.target; | |
| physicsStarted = !physicsStarted; | |
| if (physicsStarted) { | |
| btn.textContent = 'Stop Physics'; | |
| } else { | |
| btn.textContent = 'Start Physics'; | |
| } | |
| }); | |
| document.getElementById('make-messy').addEventListener('click', () => { | |
| const maxOffset = chipRadius * 0.15; | |
| for (let i = 0; i < chips.length; i++) { | |
| const randomAngle = Math.random() * Math.PI * 2; | |
| const randomDistance = Math.random() * maxOffset; | |
| const offsetX = Math.cos(randomAngle) * randomDistance; | |
| const offsetZ = Math.sin(randomAngle) * randomDistance; | |
| chipBodies[i].position.x += offsetX; | |
| chipBodies[i].position.z += offsetZ; | |
| chips[i].position.x += offsetX; | |
| chips[i].position.z += offsetZ; | |
| } | |
| }); | |
| function updateLighting() { | |
| const intensityOption = INTENSITY_OPTIONS[lightingIntensityIndex]; | |
| const colorOption = COLOR_OPTIONS[lightingColorIndex]; | |
| ambientLight.color.setHex(colorOption.ambient); | |
| ambientLight.intensity = 0.3 * intensityOption.value; | |
| directionalLight.color.setHex(colorOption.directional); | |
| directionalLight.intensity = 0.8 * intensityOption.value; | |
| pointLight.color.setHex(colorOption.point); | |
| pointLight.intensity = 0.5 * intensityOption.value; | |
| saveLightingSettings(); | |
| } | |
| document.getElementById('lighting-intensity').addEventListener('input', (e) => { | |
| lightingIntensityIndex = parseInt(e.target.value); | |
| document.getElementById('intensity-value').textContent = lightingIntensityIndex; | |
| updateLighting(); | |
| }); | |
| document.getElementById('lighting-color').addEventListener('input', (e) => { | |
| lightingColorIndex = parseInt(e.target.value); | |
| document.getElementById('color-value').textContent = lightingColorIndex; | |
| updateLighting(); | |
| }); | |
| function saveLightingSettings() { | |
| try { | |
| localStorage.setItem('poker-chip-lighting-intensity-index', JSON.stringify(lightingIntensityIndex)); | |
| localStorage.setItem('poker-chip-lighting-color-index', JSON.stringify(lightingColorIndex)); | |
| } catch (e) { | |
| console.warn('Failed to save lighting settings:', e); | |
| } | |
| } | |
| function loadLightingSettings() { | |
| try { | |
| const intensityData = localStorage.getItem('poker-chip-lighting-intensity-index'); | |
| const colorData = localStorage.getItem('poker-chip-lighting-color-index'); | |
| if (intensityData !== null) { | |
| lightingIntensityIndex = JSON.parse(intensityData); | |
| document.getElementById('lighting-intensity').value = lightingIntensityIndex; | |
| document.getElementById('intensity-value').textContent = lightingIntensityIndex; | |
| } | |
| if (colorData !== null) { | |
| lightingColorIndex = JSON.parse(colorData); | |
| document.getElementById('lighting-color').value = lightingColorIndex; | |
| document.getElementById('color-value').textContent = lightingColorIndex; | |
| } | |
| updateLighting(); | |
| } catch (e) { | |
| console.warn('Failed to load lighting settings:', e); | |
| } | |
| } | |
| // Load saved selections on page load | |
| function loadSavedSelections() { | |
| const faceSelection = imageMemory.getCurrentSelection('face'); | |
| const stripSelection = imageMemory.getCurrentSelection('strip'); | |
| const dirtyStripSelection = imageMemory.getCurrentSelection('dirty-strip'); | |
| loadDirtyChipState(); | |
| updateDirtyChipButton(); | |
| if (faceSelection) { | |
| const faceData = imageMemory.getImageData('face', faceSelection); | |
| if (faceData) { | |
| loadTextureFromDataUrl(faceData, (texture) => { | |
| faceTexture = texture; | |
| bottomFaceTexture = null; | |
| 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; | |
| updateDirtyChipButton(); | |
| updateChipMaterials(); | |
| }); | |
| } | |
| } | |
| } | |
| // --- PHYSICS ENABLE FLAG --- | |
| let physicsStarted = false; | |
| // --- FPS-based geometry downgrade --- | |
| let lowFpsFrameCount = 0; | |
| let segmentsReduced = false; | |
| // Animation loop | |
| let lastFpsUpdate = 0; | |
| let lastPhysicsTime = performance.now() / 1000; | |
| let frames = 0; | |
| let fps = 0; | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| frames++; | |
| const now = performance.now(); | |
| if (now - lastFpsUpdate > 500) { | |
| fps = Math.round((frames * 1000) / (now - lastFpsUpdate)); | |
| const fpsElem = document.getElementById('fps-display'); | |
| if (fpsElem) fpsElem.textContent = `FPS: ${fps}`; | |
| lastFpsUpdate = now; | |
| frames = 0; | |
| } | |
| if (!segmentsReduced) { | |
| if (fps > 0 && fps < 45) { | |
| lowFpsFrameCount++; | |
| if (lowFpsFrameCount >= 100) { | |
| downgradeChipSegmentsIfNeeded(); | |
| } | |
| } else { | |
| lowFpsFrameCount = 0; | |
| } | |
| } | |
| if (physicsStarted) { | |
| const currentTime = performance.now() / 1000; | |
| const deltaTime = Math.min(currentTime - lastPhysicsTime, 0.1); | |
| lastPhysicsTime = currentTime; | |
| world.step(deltaTime); | |
| 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); | |
| } | |
| function downgradeChipSegmentsIfNeeded() { | |
| if (segmentsReduced) return; | |
| chipSegments = 32; | |
| chipGeometry.dispose(); | |
| chipGeometry = new THREE.CylinderGeometry(chipRadius, chipRadius, chipHeight, chipSegments); | |
| chips.forEach(chip => { | |
| chip.geometry.dispose(); | |
| chip.geometry = chipGeometry; | |
| }); | |
| segmentsReduced = true; | |
| } | |
| // 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(); | |
| loadSavedSelections(); | |
| loadLightingSettings(); | |
| }, 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 | |
| }); | |
| } | |
| } | |
| function saveDirtyChipState() { | |
| try { | |
| localStorage.setItem('poker-chip-dirty-chip-config-index', JSON.stringify(dirtyChipConfigIndex)); | |
| localStorage.setItem('poker-chip-dirty-chip-visible', JSON.stringify(dirtyChipVisible)); | |
| } catch (e) { | |
| console.warn('Failed to save dirty chip state:', e); | |
| } | |
| } | |
| function loadDirtyChipState() { | |
| try { | |
| const indexData = localStorage.getItem('poker-chip-dirty-chip-config-index'); | |
| const visibleData = localStorage.getItem('poker-chip-dirty-chip-visible'); | |
| dirtyChipConfigIndex = indexData ? JSON.parse(indexData) : 0; | |
| dirtyChipVisible = visibleData ? JSON.parse(visibleData) : false; | |
| } catch (e) { | |
| console.warn('Failed to load dirty chip state:', e); | |
| dirtyChipConfigIndex = 0; | |
| dirtyChipVisible = false; | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment