Add existing to tracked

This commit is contained in:
Jay
2026-08-11 09:53:42 -04:00
parent afe07f3055
commit ffd6e3d73c
8531 changed files with 4396230 additions and 0 deletions
@@ -0,0 +1,8 @@
# WaveFront *.mtl file (generated by Autodesk ATF)
newmtl Paint_-_Enamel_Glossy_(White)
Kd 0.964706 0.964706 0.952941
newmtl Plastic_-_Glossy_(Red)
Kd 0.768627 0.207843 0.152941
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,398 @@
import * as THREE from "three";
import * as CANNON from "cannon-es";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import dieModelBytes from "./assets/dice/die.glb";
const LIMIT = 252;
const TAU = Math.PI * 2;
const FACE_NORMALS = {
1: new CANNON.Vec3(0, 0, -1),
2: new CANNON.Vec3(0, -1, 0),
3: new CANNON.Vec3(-1, 0, 0),
4: new CANNON.Vec3(1, 0, 0),
5: new CANNON.Vec3(0, 1, 0),
6: new CANNON.Vec3(0, 0, 1),
};
function rollDie() {
const bytes = new Uint8Array(1);
for (;;) {
crypto.getRandomValues(bytes);
if (bytes[0] < LIMIT) return (bytes[0] % 6) + 1;
}
}
function randomBetween(min, max) {
return min + Math.random() * (max - min);
}
function randomSigned(min, max) {
return (Math.random() < 0.5 ? -1 : 1) * randomBetween(min, max);
}
function randomQuaternion(target) {
const u1 = Math.random();
const u2 = Math.random();
const u3 = Math.random();
const a = Math.sqrt(1 - u1);
const b = Math.sqrt(u1);
target.set(
a * Math.sin(TAU * u2),
a * Math.cos(TAU * u2),
b * Math.sin(TAU * u3),
b * Math.cos(TAU * u3),
);
return target;
}
function restingQuaternion(value, yaw = 0) {
const rotations = {
1: [Math.PI / 2, 0, 0],
2: [Math.PI, 0, 0],
3: [0, 0, -Math.PI / 2],
4: [0, 0, Math.PI / 2],
5: [0, 0, 0],
6: [-Math.PI / 2, 0, 0],
};
const face = new THREE.Quaternion().setFromEuler(
new THREE.Euler(...rotations[value], "XYZ"),
);
const aroundUp = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 1, 0),
yaw,
);
return aroundUp.multiply(face);
}
function upwardFace(body) {
let result = 1;
let highest = -Infinity;
for (const [value, localNormal] of Object.entries(FACE_NORMALS)) {
const worldNormal = body.quaternion.vmult(localNormal);
if (worldNormal.y > highest) {
highest = worldNormal.y;
result = Number(value);
}
}
return result;
}
function modelBuffer() {
return dieModelBytes.buffer.slice(
dieModelBytes.byteOffset,
dieModelBytes.byteOffset + dieModelBytes.byteLength,
);
}
function makeMaterial(source) {
const isPip = /white|enamel/i.test(source?.name || "");
return new THREE.MeshPhysicalMaterial({
name: isPip ? "Ivory pips" : "Red resin",
color: isPip ? 0xf8f5ec : 0xc91f2b,
roughness: isPip ? 0.38 : 0.27,
metalness: 0,
clearcoat: isPip ? 0.14 : 0.34,
clearcoatRoughness: isPip ? 0.42 : 0.32,
});
}
function makeShadowTexture() {
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
const gradient = context.createRadialGradient(64, 64, 3, 64, 64, 58);
gradient.addColorStop(0, "rgba(0,0,0,.44)");
gradient.addColorStop(0.42, "rgba(0,0,0,.27)");
gradient.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = gradient;
context.fillRect(0, 0, 128, 128);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
async function loadTemplate() {
const gltf = await new GLTFLoader().parseAsync(modelBuffer(), "");
gltf.scene.traverse((object) => {
if (!object.isMesh) return;
object.material = Array.isArray(object.material)
? object.material.map(makeMaterial)
: makeMaterial(object.material);
object.geometry.computeBoundingSphere();
});
const bounds = new THREE.Box3().setFromObject(gltf.scene);
const center = bounds.getCenter(new THREE.Vector3());
const size = bounds.getSize(new THREE.Vector3());
const centered = new THREE.Group();
gltf.scene.position.sub(center);
centered.add(gltf.scene);
centered.scale.setScalar(2 / Math.max(size.x, size.y, size.z));
return centered;
}
/* A slightly inset box is the stable collision proxy. The visible mesh keeps
Fusion's exact fillets; the 5% inset keeps the proxy inside that silhouette
while avoiding stacked contacts that can inject energy into small bodies. */
function addDieCollider(body) {
body.addShape(new CANNON.Box(new CANNON.Vec3(0.95, 0.95, 0.95)));
}
function makePhysicsWorld() {
const world = new CANNON.World({ gravity: new CANNON.Vec3(0, -22, 0) });
world.allowSleep = true;
world.broadphase = new CANNON.SAPBroadphase(world);
world.solver.iterations = 18;
const dieMaterial = new CANNON.Material("die");
const tableMaterial = new CANNON.Material("table");
world.addContactMaterial(new CANNON.ContactMaterial(dieMaterial, tableMaterial, {
friction: 0.6,
restitution: 0.2,
contactEquationStiffness: 1e8,
contactEquationRelaxation: 3,
}));
world.addContactMaterial(new CANNON.ContactMaterial(dieMaterial, dieMaterial, {
friction: 0.3,
restitution: 0.3,
contactEquationStiffness: 1e8,
contactEquationRelaxation: 3,
}));
const table = new CANNON.Body({ mass: 0, material: tableMaterial });
table.addShape(new CANNON.Plane());
table.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
world.addBody(table);
return { world, dieMaterial };
}
function makeThrowPlan(count) {
if (count !== 2) {
return Array.from({ length: count }, (_, index) => {
const side = index % 2 === 0 ? -1 : 1;
return {
position: [side * 1.8, randomBetween(2.45, 2.8), randomBetween(-0.75, -0.45)],
velocity: [-side * randomBetween(1.7, 2.3), randomBetween(0.3, 0.8), randomBetween(0.2, 0.65)],
};
});
}
const chance = Math.random();
const mode = chance < 0.55 ? "cross" : chance < 0.9 ? "collision" : "overtake";
return [0, 1].map((index) => {
const side = index === 0 ? -1 : 1;
if (mode === "cross") {
return {
position: [side * 1.8, randomBetween(2.45, 2.8), index === 0 ? 1.65 : -1.65],
velocity: [-side * randomBetween(2.6, 3.2), randomBetween(0.3, 0.8), randomBetween(-0.1, 0.1)],
};
}
if (mode === "overtake") {
return {
position: [index === 0 ? -0.8 : 0.8, randomBetween(2.45, 2.8), index === 0 ? -2.15 : 1.25],
velocity: [randomBetween(-0.25, 0.25), randomBetween(0.3, 0.8), index === 0 ? randomBetween(2.1, 2.7) : randomBetween(0.35, 0.8)],
};
}
return {
position: [side * 1.8, randomBetween(2.45, 2.8), randomBetween(-0.75, -0.45)],
velocity: [-side * randomBetween(1.7, 2.3), randomBetween(0.3, 0.8), randomBetween(0.2, 0.65)],
};
});
}
function resetBodyForThrow(body, launch) {
body.wakeUp();
body.position.set(...launch.position);
randomQuaternion(body.quaternion);
body.velocity.set(...launch.velocity);
body.angularVelocity.set(
randomSigned(9, 14),
randomSigned(10, 16),
randomSigned(9, 14),
);
body.force.setZero();
body.torque.setZero();
body.previousPosition.copy(body.position);
body.interpolatedPosition.copy(body.position);
body.previousQuaternion.copy(body.quaternion);
body.interpolatedQuaternion.copy(body.quaternion);
body.aabbNeedsUpdate = true;
}
async function mount(canvas, count = 2) {
if (!(canvas instanceof HTMLCanvasElement)) {
throw new TypeError("Dice3D.mount requires a canvas element");
}
const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
renderer.setClearColor(0x000000, 0);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.08;
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-6.85, 6.85, 4.35, -4.35, 0.1, 40);
camera.position.set(0, 9.1, 13.8);
camera.lookAt(0, 0.72, 0);
scene.add(new THREE.HemisphereLight(0xffffff, 0x6b2426, 2.05));
const key = new THREE.DirectionalLight(0xffffff, 2.8);
key.position.set(-4, 8, 7);
scene.add(key);
const fill = new THREE.DirectionalLight(0xffc8c4, 0.9);
fill.position.set(5, 3, 7);
scene.add(fill);
const template = await loadTemplate();
const shadowTexture = makeShadowTexture();
const { world, dieMaterial } = makePhysicsWorld();
const dice = [];
const spacing = 2.75;
for (let index = 0; index < count; index++) {
const holder = new THREE.Group();
holder.add(template.clone(true));
const x = (index - (count - 1) / 2) * spacing;
const initialValue = rollDie();
const initialRotation = restingQuaternion(initialValue, randomBetween(0, TAU));
holder.position.set(x, 1, 0);
holder.quaternion.copy(initialRotation);
scene.add(holder);
const body = new CANNON.Body({
mass: 1,
material: dieMaterial,
linearDamping: 0.24,
angularDamping: 0.22,
allowSleep: true,
sleepSpeedLimit: 0.16,
sleepTimeLimit: 0.38,
});
addDieCollider(body);
body.position.set(x, 1, 0);
body.quaternion.set(
initialRotation.x,
initialRotation.y,
initialRotation.z,
initialRotation.w,
);
world.addBody(body);
const shadowMaterial = new THREE.MeshBasicMaterial({
map: shadowTexture,
transparent: true,
opacity: 0.44,
depthWrite: false,
toneMapped: false,
side: THREE.DoubleSide,
});
const shadow = new THREE.Mesh(new THREE.PlaneGeometry(2.2, 1.55), shadowMaterial);
shadow.rotation.x = -Math.PI / 2;
shadow.position.set(x, 0.01, 0);
scene.add(shadow);
dice.push({ holder, body, shadow, value: initialValue });
}
function syncVisuals() {
for (const die of dice) {
const { position, quaternion } = die.body;
die.holder.position.set(position.x, position.y, position.z);
die.holder.quaternion.set(quaternion.x, quaternion.y, quaternion.z, quaternion.w);
die.shadow.position.set(position.x, 0.01, position.z);
const clearance = Math.max(0, position.y - 1);
const shadowScale = 1 - Math.min(0.36, clearance * 0.18);
die.shadow.scale.set(shadowScale, shadowScale, 1);
die.shadow.material.opacity = 0.44 - Math.min(0.31, clearance * 0.16);
}
}
function resize() {
const width = Math.max(1, canvas.clientWidth);
const height = Math.max(1, canvas.clientHeight);
const aspect = width / height;
const halfHeight = 4.35;
camera.left = -halfHeight * aspect;
camera.right = halfHeight * aspect;
camera.top = halfHeight;
camera.bottom = -halfHeight;
camera.updateProjectionMatrix();
renderer.setSize(width, height, false);
renderer.render(scene, camera);
}
const observer = new ResizeObserver(resize);
observer.observe(canvas);
resize();
let busy = false;
let frameRequest = 0;
let destroyed = false;
function finishRoll(resolve) {
syncVisuals();
renderer.render(scene, camera);
const values = dice.map((die) => {
die.value = upwardFace(die.body);
return die.value;
});
busy = false;
resolve(values);
}
function roll() {
if (busy) return Promise.resolve(null);
busy = true;
const plan = makeThrowPlan(dice.length);
dice.forEach((die, index) => resetBodyForThrow(die.body, plan[index]));
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reducedMotion) {
for (let step = 0; step < 720; step++) world.step(1 / 120);
dice.forEach((die) => die.body.sleep());
return new Promise((resolve) => finishRoll(resolve));
}
const startedAt = performance.now();
let previous = startedAt;
return new Promise((resolve) => {
function frame(now) {
if (destroyed) return;
const elapsedSeconds = (now - startedAt) / 1000;
const frameSeconds = Math.min(0.05, (now - previous) / 1000);
previous = now;
/* The rigid-body path remains physical; playing simulated time at
1.55x removes the slow-motion feel of a tiny object on screen. */
world.step(1 / 120, frameSeconds * 1.55, 10);
syncVisuals();
renderer.render(scene, camera);
const asleep = dice.every((die) => die.body.sleepState === CANNON.Body.SLEEPING);
if ((elapsedSeconds > 0.8 && asleep) || elapsedSeconds > 6) {
if (!asleep) dice.forEach((die) => die.body.sleep());
finishRoll(resolve);
} else {
frameRequest = requestAnimationFrame(frame);
}
}
frameRequest = requestAnimationFrame(frame);
});
}
return {
roll,
dice,
isBusy: () => busy,
destroy() {
destroyed = true;
cancelAnimationFrame(frameRequest);
observer.disconnect();
renderer.dispose();
shadowTexture.dispose();
},
};
}
window.Dice3D = { mount, rollDie };
@@ -0,0 +1,167 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Side Cards — Dice</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 36px;
padding: 32px;
color: #fff;
background: radial-gradient(ellipse at center, #0b6b3a 0%, #074d28 100%);
font-family: Georgia, "Times New Roman", serif;
}
h1 {
font-size: 26px;
letter-spacing: 1px;
text-shadow: 0 2px 6px rgba(0,0,0,.5);
}
.dice-stage {
position: relative;
width: 440px;
height: 280px;
}
#dice-canvas {
display: block;
width: 100%;
height: 100%;
outline: none;
}
.loading {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: rgba(255,255,255,.65);
font-size: 11px;
letter-spacing: 2px;
text-transform: uppercase;
pointer-events: none;
transition: opacity .18s;
}
.dice-stage.ready .loading { opacity: 0; }
.readout {
min-height: 62px;
display: flex;
flex-direction: column;
gap: 8px;
text-align: center;
}
.total { font-size: 34px; letter-spacing: 2px; }
.detail { font-size: 13px; letter-spacing: 3px; text-transform: uppercase; opacity: .7; }
.tally { font-size: 11px; letter-spacing: 1.5px; opacity: .55; font-variant-numeric: tabular-nums; }
.controls { display: flex; gap: 12px; }
.controls button {
padding: 11px 24px;
border: 1px solid rgba(255,255,255,.35);
border-radius: 8px;
color: #fff;
background: rgba(255,255,255,.12);
font: inherit;
font-size: 14px;
letter-spacing: 1px;
cursor: pointer;
transition: background .15s, transform .1s;
}
.controls button:hover { background: rgba(255,255,255,.22); }
.controls button:active { transform: translateY(1px); }
.controls button[disabled] { opacity: .45; cursor: default; transform: none; }
</style>
</head>
<body>
<h1>Dice</h1>
<div class="dice-stage" id="stage">
<canvas id="dice-canvas" aria-label="Two red dice"></canvas>
<div class="loading" id="loading">loading dice</div>
</div>
<div class="readout">
<div class="total" id="total">&mdash;</div>
<div class="detail" id="detail">ready</div>
<div class="tally" id="tally"></div>
</div>
<div class="controls">
<button id="throw" disabled>Throw</button>
<button id="reset">Reset Tally</button>
</div>
<script src="dice-renderer.bundle.js"></script>
<script>
const stage = document.getElementById("stage");
const canvas = document.getElementById("dice-canvas");
const total = document.getElementById("total");
const detail = document.getElementById("detail");
const tallyText = document.getElementById("tally");
const throwButton = document.getElementById("throw");
const tally = new Array(7).fill(0);
let rolls = 0;
let tray;
function updateTally(values) {
values.forEach((value) => tally[value]++);
rolls += values.length;
const parts = [];
for (let face = 1; face <= 6; face++) {
parts.push(`${face}: ${(100 * tally[face] / rolls).toFixed(1)}%`);
}
tallyText.textContent = `${rolls} faces — ${parts.join(" ")}`;
}
async function throwDice() {
if (!tray || tray.isBusy()) return;
throwButton.disabled = true;
detail.textContent = "rolling";
total.textContent = "··";
const values = await tray.roll();
if (values) {
total.textContent = values[0] + values[1];
detail.textContent = `${values[0]} + ${values[1]}`;
updateTally(values);
}
throwButton.disabled = false;
}
Dice3D.mount(canvas, 2).then((mounted) => {
tray = mounted;
stage.classList.add("ready");
throwButton.disabled = false;
}).catch((error) => {
console.error(error);
document.getElementById("loading").textContent = "3D dice unavailable";
detail.textContent = "renderer error";
});
throwButton.addEventListener("click", throwDice);
document.getElementById("reset").addEventListener("click", () => {
tally.fill(0);
rolls = 0;
tallyText.textContent = "";
});
window.addEventListener("keydown", (event) => {
if (event.code === "Space" || event.code === "Enter") {
event.preventDefault();
throwDice();
}
});
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
{
"name": "side-cards-assets",
"private": true,
"scripts": {
"convert:die": "obj2gltf -i assets/dice/source/DieBody1.obj -o assets/dice/die.glb --binary",
"build:dice": "esbuild dice-renderer.js --bundle --format=iife --platform=browser --target=es2020 --minify --legal-comments=none --loader:.glb=binary --outfile=dice-renderer.bundle.js"
},
"dependencies": {
"cannon-es": "0.20.0",
"three": "0.184.0"
},
"devDependencies": {
"esbuild": "0.25.9",
"obj2gltf": "3.2.0"
}
}