Skip to content

Instantly share code, notes, and snippets.

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