feat: replace real API key with default placeholder, update setup instructions, and polish renderer
This commit is contained in:
parent
4519999535
commit
a0e1e7f258
10 changed files with 804 additions and 138 deletions
|
|
@ -1,2 +1,2 @@
|
||||||
PORT=3000
|
PORT=3000
|
||||||
OPENROUTER_API_KEY=your_openrouter_api_key_here
|
OPENROUTER_API_KEY=your-openrouter-api-key
|
||||||
|
|
|
||||||
33
README.md
33
README.md
|
|
@ -27,24 +27,31 @@ Runs by default on `http://localhost:3000`.
|
||||||
|
|
||||||
## configuration
|
## configuration
|
||||||
|
|
||||||
The repo intentionally bundles an OpenRouter key (`REPO_API_KEY` in `server.js`, `worker.mjs` and
|
jev8ball connects to OpenRouter to query the `typesafe/jev-1.13` decision oracle.
|
||||||
`functions/api/ask.js`) so a fresh clone or a deploy works with nothing configured. Export
|
|
||||||
`OPENROUTER_API_KEY` to override it — `GET /api/status` reports which key is live via `keySource`.
|
|
||||||
|
|
||||||
```env
|
1. Obtain an API key from [OpenRouter](https://openrouter.ai/keys).
|
||||||
PORT=3000
|
2. Configure your key via environment variables:
|
||||||
OPENROUTER_API_KEY=sk-or-v1-...
|
|
||||||
```
|
|
||||||
|
|
||||||
Delete the `REPO_API_KEY` constants to require a configured key instead. For Cloudflare, store the
|
|
||||||
override as a secret rather than a plain variable:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wrangler secret put OPENROUTER_API_KEY
|
# Local development: copy example env and set your key
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env and set:
|
||||||
|
OPENROUTER_API_KEY=sk-or-v1-your-key-here
|
||||||
```
|
```
|
||||||
|
|
||||||
> The bundled key is readable by anyone who can read this repository, so treat it as public and
|
```bash
|
||||||
> rotate or remove it before the repo is shared.
|
# Or export directly in shell
|
||||||
|
export OPENROUTER_API_KEY="sk-or-v1-your-key-here"
|
||||||
|
```
|
||||||
|
|
||||||
|
For Cloudflare Pages / Workers deployment:
|
||||||
|
- **Cloudflare Pages**: Add `OPENROUTER_API_KEY` under **Settings > Environment Variables** in the Pages dashboard.
|
||||||
|
- **Cloudflare Workers**: Store it as an encrypted secret using Wrangler:
|
||||||
|
```bash
|
||||||
|
npx wrangler secret put OPENROUTER_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
> If no key is set or the default placeholder `your-openrouter-api-key` is left unchanged, jev8ball gracefully falls back to an offline deterministic oracle mode.
|
||||||
|
|
||||||
## endpoints
|
## endpoints
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
const JEV_DECISIONS_URL = "https://openrouter.ai/api/alpha/decisions";
|
const JEV_DECISIONS_URL = "https://openrouter.ai/api/alpha/decisions";
|
||||||
const MODEL = "typesafe/jev-1.13";
|
const MODEL = "typesafe/jev-1.13";
|
||||||
|
|
||||||
// Mirrors worker.mjs: the repo carries a key so a deploy works with no secret set.
|
// Default placeholder API key. Set OPENROUTER_API_KEY in environment or Cloudflare secrets.
|
||||||
// Delete this constant and set OPENROUTER_API_KEY to require configuration.
|
const REPO_API_KEY = "your-openrouter-api-key";
|
||||||
const REPO_API_KEY = "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";
|
|
||||||
|
|
||||||
function resolveApiKey(env) {
|
function resolveApiKey(env) {
|
||||||
return env.OPENROUTER_API_KEY || REPO_API_KEY || null;
|
return env.OPENROUTER_API_KEY || REPO_API_KEY || null;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
// Mirrors worker.mjs: the repo carries a key so a deploy works with no secret set.
|
// Default placeholder API key. Set OPENROUTER_API_KEY in environment or Cloudflare secrets.
|
||||||
// Delete this constant and set OPENROUTER_API_KEY to require configuration.
|
const REPO_API_KEY = "your-openrouter-api-key";
|
||||||
const REPO_API_KEY = "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";
|
|
||||||
|
|
||||||
export async function onRequestGet(context) {
|
export async function onRequestGet(context) {
|
||||||
const { env } = context;
|
const { env } = context;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
dot,
|
dot,
|
||||||
hexToRgb,
|
hexToRgb,
|
||||||
lerp,
|
lerp,
|
||||||
|
mat4FromQuat,
|
||||||
mat4LookAt,
|
mat4LookAt,
|
||||||
mat4Mul,
|
mat4Mul,
|
||||||
mat4Perspective,
|
mat4Perspective,
|
||||||
|
|
@ -25,7 +26,14 @@ import {
|
||||||
mat4Translate,
|
mat4Translate,
|
||||||
mixRgb,
|
mixRgb,
|
||||||
normalize,
|
normalize,
|
||||||
|
oscillatorStep,
|
||||||
projectToScreen,
|
projectToScreen,
|
||||||
|
quatError,
|
||||||
|
quatFromAxisAngle,
|
||||||
|
quatIdentity,
|
||||||
|
quatIntegrate,
|
||||||
|
quatMul,
|
||||||
|
quatRotate,
|
||||||
rgbCss,
|
rgbCss,
|
||||||
smoothstep,
|
smoothstep,
|
||||||
transformDir,
|
transformDir,
|
||||||
|
|
@ -35,13 +43,36 @@ import { createPlate, createVessel } from './meshes.js';
|
||||||
|
|
||||||
const TAU = Math.PI * 2;
|
const TAU = Math.PI * 2;
|
||||||
const LIGHT = normalize([-0.42, 0.7, 0.86]);
|
const LIGHT = normalize([-0.42, 0.7, 0.86]);
|
||||||
const VIEW = [0, 0, 1];
|
|
||||||
|
/**
|
||||||
|
* Three-quarter camera. A dead-on view of a shallow prism is a flat silhouette; looking
|
||||||
|
* in from the side and slightly above keeps the bevelled rim, the tapered walls and the
|
||||||
|
* recessed chamber legible as depth.
|
||||||
|
*/
|
||||||
|
export const CAMERA = {
|
||||||
|
eye: [1.02, 0.66, 3.92],
|
||||||
|
target: [0, 0.02, 0],
|
||||||
|
fov: 36,
|
||||||
|
near: 0.1,
|
||||||
|
far: 40
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Direction from the scene toward the camera: facing tests, rim light and specular. */
|
||||||
|
const VIEW = normalize([
|
||||||
|
CAMERA.eye[0] - CAMERA.target[0],
|
||||||
|
CAMERA.eye[1] - CAMERA.target[1],
|
||||||
|
CAMERA.eye[2] - CAMERA.target[2]
|
||||||
|
]);
|
||||||
|
|
||||||
const BODY_BASE = hexToRgb('#191919');
|
const BODY_BASE = hexToRgb('#191919');
|
||||||
const BODY_EDGE = hexToRgb('#2b2b30');
|
const BODY_EDGE = hexToRgb('#2b2b30');
|
||||||
const CHAMBER_RGB = hexToRgb('#07070b');
|
const CHAMBER_RGB = hexToRgb('#07070b');
|
||||||
const PLATE_BASE = hexToRgb('#1b1b1f');
|
const PLATE_BASE = hexToRgb('#1b1b1f');
|
||||||
const SPECULAR = [236, 245, 255];
|
const SPECULAR = [236, 245, 255];
|
||||||
|
const FOG_RGB = hexToRgb('#0b0b0b');
|
||||||
|
|
||||||
|
/** Depth haze: the back of the shell must sink into the page, not sit on top of it. */
|
||||||
|
const FOG = { near: 3.55, far: 4.62, max: 0.3 };
|
||||||
|
|
||||||
const TONES = {
|
const TONES = {
|
||||||
affirmative: hexToRgb('#10b981'),
|
affirmative: hexToRgb('#10b981'),
|
||||||
|
|
@ -57,6 +88,74 @@ const LIGHT_NEAR = normalize([LIGHT[0] - 0.35, LIGHT[1] + 0.32, LIGHT[2]]);
|
||||||
const LIGHT_FAR = normalize([LIGHT[0] + 0.32, LIGHT[1] - 0.36, LIGHT[2]]);
|
const LIGHT_FAR = normalize([LIGHT[0] + 0.32, LIGHT[1] - 0.36, LIGHT[2]]);
|
||||||
const FONT_STACK = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
|
const FONT_STACK = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die spin while the vessel is being shaken: a constant-speed tumble would look
|
||||||
|
* mechanical, so the angular velocity starts fast, decays with drag, and its axis slowly
|
||||||
|
* precesses — a body tumbling with angular momentum rather than an Euler loop.
|
||||||
|
*/
|
||||||
|
const SPIN = {
|
||||||
|
speed: 9.5,
|
||||||
|
spread: 3.6,
|
||||||
|
drag: 1.35,
|
||||||
|
precess: 0.85,
|
||||||
|
precessAxis: [0.25, 0.15, 1]
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die spring that unwinds the tumble onto the answer face. The error is expressed as a
|
||||||
|
* single angle about the shortest-path axis, so the die sweeps forward into the answer
|
||||||
|
* and bounces once (ζ < 1) — it can never rewind like the old linear unwind did.
|
||||||
|
*/
|
||||||
|
const SETTLE = {
|
||||||
|
omega: 10.5,
|
||||||
|
zeta: 0.68,
|
||||||
|
minRate: 2.5,
|
||||||
|
maxRate: 6.5,
|
||||||
|
landAngle: 0.16,
|
||||||
|
restAngle: 0.02
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Hull rattle. Two incommensurate frequencies per channel stop it looking metronomic. */
|
||||||
|
const SHAKE = {
|
||||||
|
attack: 0.055,
|
||||||
|
decay: 5.5,
|
||||||
|
x: [{ hz: 6.7, amp: 0.03 }, { hz: 10.9, amp: 0.013 }],
|
||||||
|
y: [{ hz: 5.3, amp: 0.042 }, { hz: 8.9, amp: 0.017 }],
|
||||||
|
yaw: [{ hz: 4.1, amp: 0.04 }, { hz: 7.7, amp: 0.015 }],
|
||||||
|
pitch: [{ hz: 4.9, amp: 0.048 }, { hz: 8.3, amp: 0.018 }],
|
||||||
|
roll: [{ hz: 3.7, amp: 0.055 }, { hz: 6.1, amp: 0.021 }]
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLATE_SPRING = { omega: 24, zeta: 0.5 };
|
||||||
|
const SURFACE_SPRING = { omega: 12, zeta: 0.2 };
|
||||||
|
const THUMP_SPRING = { omega: 17, zeta: 0.18 };
|
||||||
|
/** How hard the vessel's vertical motion throws the liquid surface around. */
|
||||||
|
const SURFACE_COUPLING = 14;
|
||||||
|
|
||||||
|
/** Deterministic [0,1) noise so every roll tumbles differently but reproducibly. */
|
||||||
|
function seededUnit(seed) {
|
||||||
|
const x = Math.sin(seed * 12.9898) * 43758.5453;
|
||||||
|
return x - Math.floor(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drives a position/velocity pair toward a moving target with a spring-damper. */
|
||||||
|
export function springToward(state, target, dt, { omega, zeta }) {
|
||||||
|
const step = oscillatorStep({ x: state.x - target, v: state.v }, dt, omega, zeta);
|
||||||
|
return { x: target + step.x, v: step.v };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sum of incommensurate sines — a rattle that never repeats on a short cycle. The caller
|
||||||
|
* scales it by a continuously varying envelope, so it stays smooth through every phase
|
||||||
|
* change.
|
||||||
|
*/
|
||||||
|
function rattle(clock, channels) {
|
||||||
|
let sum = 0;
|
||||||
|
for (const { hz, amp } of channels) sum += Math.sin(TAU * hz * clock + hz) * amp;
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Phases: rolling -> (answer arrives) -> revealing -> ready -> idle. */
|
/** Phases: rolling -> (answer arrives) -> revealing -> ready -> idle. */
|
||||||
export class OracleBall {
|
export class OracleBall {
|
||||||
constructor(canvas, { reducedMotion = false } = {}) {
|
constructor(canvas, { reducedMotion = false } = {}) {
|
||||||
|
|
@ -81,17 +180,32 @@ export class OracleBall {
|
||||||
this.revealStart = 0;
|
this.revealStart = 0;
|
||||||
this.holdTime = 0;
|
this.holdTime = 0;
|
||||||
this.rollDuration = 1.25;
|
this.rollDuration = 1.25;
|
||||||
this.revealDuration = 0.9;
|
this.revealDuration = 1.1;
|
||||||
|
this.rollProgress = 0;
|
||||||
|
this.rollCount = 0;
|
||||||
|
|
||||||
this.answerLines = ['CONCENTRATE', '& ASK'];
|
this.answerLines = ['CONCENTRATE', '& ASK'];
|
||||||
this.tone = 'idle';
|
this.tone = 'idle';
|
||||||
this.pendingAnswer = null;
|
this.pendingAnswer = null;
|
||||||
|
this.answerAlpha = 1;
|
||||||
|
|
||||||
this.pointer = { x: 0, y: 0 };
|
this.pointer = { x: 0, y: 0 };
|
||||||
this.tilt = { x: 0, y: 0 };
|
this.tilt = { x: 0, y: 0 };
|
||||||
this.shake = 0;
|
|
||||||
this.glow = 0;
|
this.glow = 0;
|
||||||
this.resolveRoll = null;
|
this.resolveRoll = null;
|
||||||
|
|
||||||
|
// Die: a free tumble that a spring later unwinds onto the answer face.
|
||||||
|
this.plateQuat = quatIdentity();
|
||||||
|
this.spin = { q: quatIdentity(), w: [0, 0, 0] };
|
||||||
|
this.settle = null;
|
||||||
|
this.landed = true;
|
||||||
|
this.plateY = { x: 0.02, v: 0 };
|
||||||
|
|
||||||
|
// Hull rattle, impact bounce and the chamber liquid, all spring-driven.
|
||||||
|
this.shakeEnergy = 0;
|
||||||
|
this.hull = { x: 0, y: 0, yaw: 0, pitch: 0, roll: 0 };
|
||||||
|
this.thump = { x: 0, v: 0 };
|
||||||
|
this.surface = { x: 0, v: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── public API ─── */
|
/* ─── public API ─── */
|
||||||
|
|
@ -127,8 +241,26 @@ export class OracleBall {
|
||||||
this.phase = 'rolling';
|
this.phase = 'rolling';
|
||||||
this.rollStart = this.clock;
|
this.rollStart = this.clock;
|
||||||
this.holdTime = 0;
|
this.holdTime = 0;
|
||||||
|
this.rollProgress = 0;
|
||||||
this.glow = 0;
|
this.glow = 0;
|
||||||
this.answerLines = null;
|
this.answerLines = null;
|
||||||
|
this.answerAlpha = 0;
|
||||||
|
this.settle = null;
|
||||||
|
this.landed = false;
|
||||||
|
this.rollCount += 1;
|
||||||
|
|
||||||
|
// Kick the die into a fresh tumble: fast, off-axis, and never quite the same twice.
|
||||||
|
const seed = this.rollCount * 7.13;
|
||||||
|
const axis = normalize([
|
||||||
|
0.7 + seededUnit(seed) * 0.6,
|
||||||
|
0.4 + seededUnit(seed + 1.7) * 0.7,
|
||||||
|
0.15 + seededUnit(seed + 3.1) * 0.45
|
||||||
|
]);
|
||||||
|
const speed = SPIN.speed + seededUnit(seed + 5.3) * SPIN.spread;
|
||||||
|
this.spin.q = this.plateQuat;
|
||||||
|
this.spin.w = [axis[0] * speed, axis[1] * speed, axis[2] * speed];
|
||||||
|
this.shakeEnergy = 0;
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
// Backstop so a paused render loop (hidden tab, off-screen canvas) can never
|
// Backstop so a paused render loop (hidden tab, off-screen canvas) can never
|
||||||
// leave the caller waiting forever.
|
// leave the caller waiting forever.
|
||||||
|
|
@ -149,8 +281,9 @@ export class OracleBall {
|
||||||
this.answerLines = lines;
|
this.answerLines = lines;
|
||||||
this.tone = tone;
|
this.tone = tone;
|
||||||
this.revealStart = this.clock;
|
this.revealStart = this.clock;
|
||||||
this.tumbleAtReveal = this.platePose ? { ...this.platePose } : { rx: 0, ry: 0, rz: 0, y: -0.5 };
|
this.landed = false;
|
||||||
this.phase = 'revealing';
|
this.phase = 'revealing';
|
||||||
|
this._beginSettle();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the vessel to the resting state and clears the verdict. */
|
/** Returns the vessel to the resting state and clears the verdict. */
|
||||||
|
|
@ -160,6 +293,11 @@ export class OracleBall {
|
||||||
this.phase = 'idle';
|
this.phase = 'idle';
|
||||||
this.glow = 0;
|
this.glow = 0;
|
||||||
this.pendingAnswer = null;
|
this.pendingAnswer = null;
|
||||||
|
this.settle = null;
|
||||||
|
this.landed = true;
|
||||||
|
this.answerAlpha = 1;
|
||||||
|
this.plateQuat = quatIdentity();
|
||||||
|
this.spin.w = [0, 0, 0];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── wiring ─── */
|
/* ─── wiring ─── */
|
||||||
|
|
@ -221,18 +359,49 @@ export class OracleBall {
|
||||||
};
|
};
|
||||||
|
|
||||||
_update(dt) {
|
_update(dt) {
|
||||||
|
const rm = this.reducedMotion;
|
||||||
const ease = Math.min(1, dt * 6);
|
const ease = Math.min(1, dt * 6);
|
||||||
this.tilt.x += ((this.reducedMotion ? 0 : this.pointer.y * 0.2) - this.tilt.x) * ease;
|
this.tilt.x += ((rm ? 0 : this.pointer.y * 0.24) - this.tilt.x) * ease;
|
||||||
this.tilt.y += ((this.reducedMotion ? 0 : this.pointer.x * 0.3) - this.tilt.y) * ease;
|
this.tilt.y += ((rm ? 0 : this.pointer.x * 0.34) - this.tilt.y) * ease;
|
||||||
|
|
||||||
const elapsed = this.clock - this.rollStart;
|
const elapsed = this.clock - this.rollStart;
|
||||||
|
|
||||||
|
// ─── hull rattle ───
|
||||||
|
// One continuously varying envelope drives every channel, so the shake can start and
|
||||||
|
// stop with the phase machine without ever teleporting the vessel.
|
||||||
|
if (this.phase === 'rolling' && !rm) {
|
||||||
|
this.shakeEnergy = Math.min(1, this.shakeEnergy + dt / SHAKE.attack);
|
||||||
|
} else {
|
||||||
|
this.shakeEnergy *= Math.exp(-dt * SHAKE.decay);
|
||||||
|
if (this.shakeEnergy < 1e-4) this.shakeEnergy = 0;
|
||||||
|
}
|
||||||
|
const energy = this.shakeEnergy;
|
||||||
|
this.hull = {
|
||||||
|
x: rattle(this.clock, SHAKE.x) * energy,
|
||||||
|
y: rattle(this.clock, SHAKE.y) * energy,
|
||||||
|
yaw: rattle(this.clock, SHAKE.yaw) * energy,
|
||||||
|
pitch: rattle(this.clock, SHAKE.pitch) * energy,
|
||||||
|
roll: rattle(this.clock, SHAKE.roll) * energy
|
||||||
|
};
|
||||||
|
this.thump = oscillatorStep(this.thump, dt, THUMP_SPRING.omega, THUMP_SPRING.zeta);
|
||||||
|
|
||||||
|
let targetY = 0.02;
|
||||||
|
|
||||||
if (this.phase === 'rolling') {
|
if (this.phase === 'rolling') {
|
||||||
this.rollProgress = this.reducedMotion ? 1 : clamp(elapsed / this.rollDuration, 0, 1);
|
this.rollProgress = rm ? 1 : clamp(elapsed / this.rollDuration, 0, 1);
|
||||||
this.shake = this.reducedMotion
|
if (rm) {
|
||||||
? 0
|
this.plateQuat = quatFromAxisAngle([0.6, 0.75, 0.25], 0.5);
|
||||||
: (1 - this.rollProgress * 0.6) * Math.sin(elapsed * 24) * 0.42;
|
} else {
|
||||||
this.liquidSlosh = this.reducedMotion ? 0 : 1;
|
// Angular momentum: the spin decays with drag while its axis precesses, which is
|
||||||
|
// what separates a tumbling body from a constant-speed Euler loop.
|
||||||
|
const drag = Math.exp(-SPIN.drag * dt);
|
||||||
|
this.spin.w = quatRotate(
|
||||||
|
quatFromAxisAngle(SPIN.precessAxis, SPIN.precess * dt),
|
||||||
|
[this.spin.w[0] * drag, this.spin.w[1] * drag, this.spin.w[2] * drag]
|
||||||
|
);
|
||||||
|
this.spin.q = quatIntegrate(this.spin.q, this.spin.w, dt);
|
||||||
|
this.plateQuat = this.spin.q;
|
||||||
|
}
|
||||||
if (elapsed >= this.rollDuration) {
|
if (elapsed >= this.rollDuration) {
|
||||||
this.holdTime += dt;
|
this.holdTime += dt;
|
||||||
if (this.resolveRoll) {
|
if (this.resolveRoll) {
|
||||||
|
|
@ -241,51 +410,87 @@ export class OracleBall {
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.platePose = this._tumblePose(elapsed);
|
targetY = -0.3 + 0.07 * energy * Math.sin(TAU * 5.1 * this.clock);
|
||||||
this.answerAlpha = 0;
|
this.answerAlpha = 0;
|
||||||
this.glow = this.reducedMotion ? 0 : 0.12 + Math.sin(elapsed * 9) * 0.06;
|
this.glow = rm ? 0.1 : 0.1 + energy * 0.07;
|
||||||
} else if (this.phase === 'revealing') {
|
} else if (this.phase === 'revealing') {
|
||||||
const t = this.reducedMotion ? 1 : clamp((this.clock - this.revealStart) / this.revealDuration, 0, 1);
|
if (!this.settle) this._beginSettle();
|
||||||
const settle = smoothstep(0, 0.72, t);
|
if (rm) this.settle.angle = { x: 0, v: 0 };
|
||||||
const from = this.tumbleAtReveal || { rx: 0, ry: 0, rz: 0, y: -0.5 };
|
this.settle.angle = oscillatorStep(this.settle.angle, dt, SETTLE.omega, SETTLE.zeta);
|
||||||
// Unwind the tumble to zero so the plate always lands face-on to the camera.
|
this.plateQuat = quatMul(
|
||||||
this.platePose = {
|
quatFromAxisAngle(this.settle.axis, this.settle.angle.x),
|
||||||
rx: from.rx * (1 - settle),
|
this.settle.target
|
||||||
ry: from.ry * (1 - settle),
|
);
|
||||||
rz: from.rz * (1 - settle),
|
|
||||||
y: lerp(from.y, 0.06, settle)
|
// The spring unwinds the whole error along the shortest path, so the die sweeps
|
||||||
};
|
// *into* the answer and rocks once past it — never rewinding the tumble.
|
||||||
this.shake = this.reducedMotion ? 0 : (1 - t) * Math.sin(t * 18) * 0.12;
|
const remaining = Math.abs(this.settle.angle.x);
|
||||||
this.liquidSlosh = this.reducedMotion ? 0 : 1 - t;
|
if (!this.landed && remaining <= SETTLE.landAngle) {
|
||||||
this.answerAlpha = smoothstep(0.5, 1, t);
|
this.landed = true;
|
||||||
this.glow = Math.sin(t * Math.PI) * 1.1 + 0.16;
|
this.thump.v -= rm ? 2 : 7.5; // the hull takes the impact…
|
||||||
if (t >= 1) this.phase = 'ready';
|
this.plateY.v -= 1; // …and the die sinks into the liquid
|
||||||
|
this.surface.v += 2.4;
|
||||||
|
this.glow = Math.max(this.glow, 0.9);
|
||||||
|
}
|
||||||
|
// The ink fades in as the face turns toward the camera, and is latched so the small
|
||||||
|
// overshoot of the settle can never make the verdict flicker.
|
||||||
|
this.answerAlpha = Math.max(
|
||||||
|
this.answerAlpha,
|
||||||
|
smoothstep(SETTLE.landAngle * 3, SETTLE.restAngle * 3.5, remaining)
|
||||||
|
);
|
||||||
|
targetY = 0.06;
|
||||||
|
const settled =
|
||||||
|
remaining <= SETTLE.restAngle && Math.abs(this.settle.angle.v) <= 0.25;
|
||||||
|
if (settled || this.clock - this.revealStart > this.revealDuration * 2) {
|
||||||
|
this.phase = 'ready';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const bob = this.reducedMotion ? 0 : Math.sin(this.clock * 1.15) * 0.022;
|
// ready / idle: square to the camera with only a whisper of movement, so the verdict
|
||||||
this.platePose = {
|
// stays readable instead of rocking under the reader's eye.
|
||||||
rx: this.reducedMotion ? 0 : Math.sin(this.clock * 0.42) * 0.06,
|
const wobble = rm ? 0 : 1;
|
||||||
ry: this.reducedMotion ? 0 : Math.sin(this.clock * 0.55) * 0.24,
|
this.plateQuat = quatMul(
|
||||||
rz: this.reducedMotion ? 0 : Math.sin(this.clock * 0.37) * 0.05,
|
quatFromAxisAngle([0, 1, 0], Math.sin(this.clock * 0.62) * 0.03 * wobble),
|
||||||
y: 0.02 + bob
|
quatFromAxisAngle([1, 0, 0], Math.sin(this.clock * 0.47) * 0.024 * wobble)
|
||||||
};
|
);
|
||||||
this.liquidSlosh = this.reducedMotion ? 0 : 0.25;
|
const bob = rm ? 0 : Math.sin(this.clock * 1.15) * 0.022;
|
||||||
|
targetY = 0.02 + bob;
|
||||||
this.answerAlpha = this.answerLines ? 1 : 0;
|
this.answerAlpha = this.answerLines ? 1 : 0;
|
||||||
this.glow = this.phase === 'ready' ? 0.16 + Math.sin(this.clock * 1.6) * 0.04 : 0.06;
|
this.glow = this.phase === 'ready' ? 0.16 + Math.sin(this.clock * 1.6) * 0.04 : 0.06;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.plateY = springToward(this.plateY, targetY, dt, PLATE_SPRING);
|
||||||
|
|
||||||
|
// ─── chamber liquid ───
|
||||||
|
// The surface is an oscillator kicked by the vessel's own vertical motion, so it
|
||||||
|
// sloshes while the oracle is shaken, splashes when the die lands and settles still —
|
||||||
|
// instead of the old sine that wobbled forever.
|
||||||
|
const hullY = this.hull.y + this.thump.x;
|
||||||
|
const hullFall = this.prevHullY === undefined ? 0 : hullY - this.prevHullY;
|
||||||
|
this.prevHullY = hullY;
|
||||||
|
this.surface.v -= hullFall * SURFACE_COUPLING;
|
||||||
|
this.surface = oscillatorStep(this.surface, dt, SURFACE_SPRING.omega, SURFACE_SPRING.zeta);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Churning pose used while the vessel is being shaken. */
|
/**
|
||||||
_tumblePose(elapsed) {
|
* Captures the tumble's remaining error as one angle about its shortest-path axis, and
|
||||||
const dive = smoothstep(0, this.rollDuration * 0.85, elapsed);
|
* hands the spring the die's current spin so the settle starts without a jolt.
|
||||||
if (this.reducedMotion) {
|
*/
|
||||||
return { rx: 0.5, ry: 0.6, rz: 0.2, y: lerp(0.02, -0.24, dive) };
|
_beginSettle(from = this.spin.q) {
|
||||||
}
|
const error = quatError(from, quatIdentity());
|
||||||
return {
|
// Only the spin component *about the settle axis* carries into the unwind; the rest of
|
||||||
rx: elapsed * 5.2,
|
// the tumble's momentum is orthogonal and must not be added as fake approach speed. The
|
||||||
ry: elapsed * 3.05,
|
// ceiling keeps a fast tumble from snapping the die round in a couple of frames.
|
||||||
rz: elapsed * 2.15,
|
const rate = clamp(
|
||||||
y: lerp(0.02, -0.5, dive)
|
dot(this.spin.w, error.axis),
|
||||||
|
SETTLE.minRate,
|
||||||
|
SETTLE.maxRate
|
||||||
|
);
|
||||||
|
this.settle = {
|
||||||
|
axis: error.axis,
|
||||||
|
angle: { x: error.angle, v: -rate },
|
||||||
|
target: quatIdentity()
|
||||||
};
|
};
|
||||||
|
this.plateQuat = from;
|
||||||
}
|
}
|
||||||
|
|
||||||
get toneRgb() {
|
get toneRgb() {
|
||||||
|
|
@ -303,24 +508,29 @@ export class OracleBall {
|
||||||
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
||||||
|
|
||||||
// Camera sits far enough that the vessel fills ~72% of the canvas height,
|
// The camera frames the vessel at ~72% of the canvas height, leaving room for the
|
||||||
// leaving room for the shake, the rim glow, and the cast shadow.
|
// rattle, the rim glow and the cast shadow.
|
||||||
const view = mat4LookAt([0, 0, 4.3], [0, 0, 0], [0, 1, 0]);
|
const view = mat4LookAt(CAMERA.eye, CAMERA.target, [0, 1, 0]);
|
||||||
const proj = mat4Perspective((36 * Math.PI) / 180, 1, 0.1, 40);
|
const proj = mat4Perspective((CAMERA.fov * Math.PI) / 180, 1, CAMERA.near, CAMERA.far);
|
||||||
this.viewProj = mat4Mul(proj, view);
|
this.viewProj = mat4Mul(proj, view);
|
||||||
|
|
||||||
const pose = this.platePose || { rx: 0, ry: 0, rz: 0, y: 0.02 };
|
// Hull: pointer parallax plus the rattle, with the impact bounce folded into y.
|
||||||
const shakeZ = this.shake * 0.5;
|
const hullRot = mat4Mul(
|
||||||
const vesselModel = mat4Mul(
|
mat4RotateZ(this.hull.roll),
|
||||||
mat4RotateZ(shakeZ),
|
|
||||||
mat4Mul(
|
mat4Mul(
|
||||||
mat4RotateY(this.tilt.y + this.shake * 0.28),
|
mat4RotateY(this.tilt.y + this.hull.yaw),
|
||||||
mat4RotateX(this.tilt.x + Math.cos(this.clock * 9) * this.shake * 0.22)
|
mat4RotateX(this.tilt.x + this.hull.pitch)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const vesselModel = mat4Mul(
|
||||||
|
mat4Translate(this.hull.x, this.hull.y + this.thump.x, 0),
|
||||||
|
hullRot
|
||||||
|
);
|
||||||
|
// The die rides inside the hull, so parallax and shake carry it along instead of
|
||||||
|
// letting it look pasted onto the screen.
|
||||||
const plateModel = mat4Mul(
|
const plateModel = mat4Mul(
|
||||||
mat4Translate(0, pose.y, 0),
|
hullRot,
|
||||||
mat4Mul(mat4RotateZ(pose.rz), mat4Mul(mat4RotateY(pose.ry), mat4RotateX(pose.rx)))
|
mat4Mul(mat4Translate(0, this.plateY.x, 0), mat4FromQuat(this.plateQuat))
|
||||||
);
|
);
|
||||||
|
|
||||||
this._drawGroundGlow(w, h);
|
this._drawGroundGlow(w, h);
|
||||||
|
|
@ -335,9 +545,9 @@ export class OracleBall {
|
||||||
|
|
||||||
const tone = this.toneRgb;
|
const tone = this.toneRgb;
|
||||||
|
|
||||||
// 1. shell behind the chamber: back cap first, then the side facets
|
// 1. shell behind the chamber: the back cap, then the tapered side walls
|
||||||
for (const face of vessel.faces) {
|
for (const face of vessel.faces) {
|
||||||
if (face.kind === 'ring') continue;
|
if (face.kind === 'ring' || face.kind === 'bevel') continue;
|
||||||
const base = face.kind === 'back' ? CHAMBER_RGB : BODY_BASE;
|
const base = face.kind === 'back' ? CHAMBER_RGB : BODY_BASE;
|
||||||
this._fillFace(face, this._faceStyle(face, base, tone, face.kind === 'back' ? 0.05 : 0.55));
|
this._fillFace(face, this._faceStyle(face, base, tone, face.kind === 'back' ? 0.05 : 0.55));
|
||||||
}
|
}
|
||||||
|
|
@ -352,16 +562,18 @@ export class OracleBall {
|
||||||
this._fillFace(face, this._faceStyle(face, base, tone, face.kind === 'answer' ? 0.35 : 0.8));
|
this._fillFace(face, this._faceStyle(face, base, tone, face.kind === 'answer' ? 0.35 : 0.8));
|
||||||
}
|
}
|
||||||
if (this.answerLines && this.answerAlpha > 0.01) {
|
if (this.answerLines && this.answerAlpha > 0.01) {
|
||||||
this._drawAnswerText(plate, windowPoly);
|
this._drawAnswerText(plate);
|
||||||
}
|
}
|
||||||
this._overlayLiquid(liquid, tone);
|
this._overlayLiquid(liquid, tone);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. the front frame, nearest to the camera, drawn last
|
// 3. the front of the shell, drawn last: the forward-facing bevel carries the key
|
||||||
|
// light and the flat collar sits just behind it.
|
||||||
for (const face of vessel.faces) {
|
for (const face of vessel.faces) {
|
||||||
if (face.kind !== 'ring') continue;
|
if (face.kind !== 'ring' && face.kind !== 'bevel') continue;
|
||||||
this._fillFace(face, this._faceStyle(face, BODY_EDGE, tone, 0.5));
|
const base = face.kind === 'bevel' ? BODY_EDGE : BODY_BASE;
|
||||||
|
this._fillFace(face, this._faceStyle(face, base, tone, face.kind === 'bevel' ? 0.3 : 0.5));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (windowPoly) this._strokeWindow(windowPoly, tone);
|
if (windowPoly) this._strokeWindow(windowPoly, tone);
|
||||||
|
|
@ -408,9 +620,10 @@ export class OracleBall {
|
||||||
_liquidGeometry(vesselModel, windowPoly) {
|
_liquidGeometry(vesselModel, windowPoly) {
|
||||||
const mvp = mat4Mul(this.viewProj, vesselModel);
|
const mvp = mat4Mul(this.viewProj, vesselModel);
|
||||||
const { radius, z } = this.vessel.window;
|
const { radius, z } = this.vessel.window;
|
||||||
const slosh = this.liquidSlosh || 0;
|
// Level and tilt come from the slosh oscillator, so the surface rings down to a still
|
||||||
const level = -0.09 + Math.sin(this.clock * 8.5) * 0.05 * slosh;
|
// mirror once the oracle settles instead of waving forever.
|
||||||
const tilt = Math.sin(this.clock * 6.3) * 0.16 * slosh;
|
const level = -0.075 + this.surface.x;
|
||||||
|
const tilt = clamp(this.surface.v * 0.03, -0.2, 0.2);
|
||||||
const left = projectToScreen(
|
const left = projectToScreen(
|
||||||
transformPoint(mvp, [-radius * 1.35, level + tilt, z * 0.2]),
|
transformPoint(mvp, [-radius * 1.35, level + tilt, z * 0.2]),
|
||||||
this.width,
|
this.width,
|
||||||
|
|
@ -441,13 +654,16 @@ export class OracleBall {
|
||||||
|
|
||||||
_drawContactShadow(w, h) {
|
_drawContactShadow(w, h) {
|
||||||
const { ctx } = this;
|
const { ctx } = this;
|
||||||
const lift = (this.platePose ? 0 : 0) + 0.02;
|
// The shadow stays anchored while the hull rattles above it, which is what sells the
|
||||||
|
// vessel as a solid body in front of a surface rather than a flat image being nudged.
|
||||||
|
const spread = 1 + this.shakeEnergy * 0.22;
|
||||||
|
const opacity = 0.6 - this.shakeEnergy * 0.16;
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(w / 2, h * 0.5 + h * 0.4 + lift);
|
ctx.translate(w / 2, h * 0.5 + h * 0.4);
|
||||||
ctx.scale(1, 0.2);
|
ctx.scale(1, 0.2);
|
||||||
const radius = w * 0.31;
|
const radius = w * 0.31 * spread;
|
||||||
const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, radius);
|
const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, radius);
|
||||||
gradient.addColorStop(0, 'rgba(0,0,0,0.6)');
|
gradient.addColorStop(0, `rgba(0,0,0,${opacity})`);
|
||||||
gradient.addColorStop(1, 'rgba(0,0,0,0)');
|
gradient.addColorStop(1, 'rgba(0,0,0,0)');
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(0, 0, radius, 0, TAU);
|
ctx.arc(0, 0, radius, 0, TAU);
|
||||||
|
|
@ -458,28 +674,30 @@ export class OracleBall {
|
||||||
|
|
||||||
/* ─── shading & paint helpers ─── */
|
/* ─── shading & paint helpers ─── */
|
||||||
|
|
||||||
/** Blinn-Phong-ish shade: ambient + Lambert, specular, and a verdict-tinted rim. */
|
/** Blinn-Phong-ish shade: ambient + Lambert, specular, a verdict rim and depth haze. */
|
||||||
_shade(base, normal, light, tone, rimStrength) {
|
_shade(base, normal, light, tone, rimStrength, fog = 0) {
|
||||||
const diffuse = Math.max(0, dot(normal, light));
|
const diffuse = Math.max(0, dot(normal, light));
|
||||||
const half = normalize([light[0], light[1], light[2] + 1]);
|
const half = normalize([light[0] + VIEW[0], light[1] + VIEW[1], light[2] + VIEW[2]]);
|
||||||
const spec = Math.pow(Math.max(0, dot(normal, half)), 40) * 0.5;
|
const spec = Math.pow(Math.max(0, dot(normal, half)), 42) * 0.55;
|
||||||
const rim = Math.pow(1 - clamp(dot(normal, VIEW), 0, 1), 3) * rimStrength;
|
const rim = Math.pow(1 - clamp(dot(normal, VIEW), 0, 1), 3) * rimStrength;
|
||||||
const lit = 0.13 + diffuse * 0.95;
|
const lit = 0.12 + diffuse * 0.95;
|
||||||
const rgb = [
|
const rgb = [
|
||||||
base[0] * lit + SPECULAR[0] * spec,
|
base[0] * lit + SPECULAR[0] * spec,
|
||||||
base[1] * lit + SPECULAR[1] * spec,
|
base[1] * lit + SPECULAR[1] * spec,
|
||||||
base[2] * lit + SPECULAR[2] * spec
|
base[2] * lit + SPECULAR[2] * spec
|
||||||
];
|
];
|
||||||
return mixRgb(rgb, tone, clamp(rim * 0.55, 0, 1));
|
const tinted = mixRgb(rgb, tone, clamp(rim * 0.55, 0, 1));
|
||||||
|
return fog > 0 ? mixRgb(tinted, FOG_RGB, fog) : tinted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Solid fill for faceted faces, soft gradient for the large curved ones. */
|
/** Solid fill for faceted faces, soft gradient for the large curved ones. */
|
||||||
_faceStyle(face, base, tone, rimStrength) {
|
_faceStyle(face, base, tone, rimStrength) {
|
||||||
|
const fog = smoothstep(FOG.near, FOG.far, face.depth) * FOG.max;
|
||||||
if (!face.smooth) {
|
if (!face.smooth) {
|
||||||
return rgbCss(this._shade(base, face.normal, LIGHT, tone, rimStrength));
|
return rgbCss(this._shade(base, face.normal, LIGHT, tone, rimStrength, fog));
|
||||||
}
|
}
|
||||||
const near = this._shade(base, face.normal, LIGHT_NEAR, tone, rimStrength);
|
const near = this._shade(base, face.normal, LIGHT_NEAR, tone, rimStrength, fog);
|
||||||
const far = this._shade(base, face.normal, LIGHT_FAR, tone, rimStrength);
|
const far = this._shade(base, face.normal, LIGHT_FAR, tone, rimStrength, fog);
|
||||||
const bounds = boundsOf(face.points);
|
const bounds = boundsOf(face.points);
|
||||||
const gradient = this.ctx.createLinearGradient(bounds.minX, bounds.minY, bounds.maxX, bounds.maxY);
|
const gradient = this.ctx.createLinearGradient(bounds.minX, bounds.minY, bounds.maxX, bounds.maxY);
|
||||||
gradient.addColorStop(0, rgbCss(mixRgb(near, SPECULAR, 0.05)));
|
gradient.addColorStop(0, rgbCss(mixRgb(near, SPECULAR, 0.05)));
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,146 @@ export function affineFromThreePoints(p1, p2, p3) {
|
||||||
return [a, b, c, d, s1[0] - a * x1 - c * y1, s1[1] - b * x1 - d * y1];
|
return [a, b, c, d, s1[0] - a * x1 - c * y1, s1[1] - b * x1 - d * y1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── quaternions ([x, y, z, w], same right-handed convention as the matrices) ─── */
|
||||||
|
|
||||||
|
export function quatIdentity() {
|
||||||
|
return [0, 0, 0, 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quatFromAxisAngle(axis, rad) {
|
||||||
|
const n = normalize(axis);
|
||||||
|
const half = rad / 2;
|
||||||
|
const s = Math.sin(half);
|
||||||
|
return [n[0] * s, n[1] * s, n[2] * s, Math.cos(half)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a ∘ b (b applied first). */
|
||||||
|
export function quatMul(a, b) {
|
||||||
|
const [ax, ay, az, aw] = a;
|
||||||
|
const [bx, by, bz, bw] = b;
|
||||||
|
return [
|
||||||
|
aw * bx + ax * bw + ay * bz - az * by,
|
||||||
|
aw * by - ax * bz + ay * bw + az * bx,
|
||||||
|
aw * bz + ax * by - ay * bx + az * bw,
|
||||||
|
aw * bw - ax * bx - ay * by - az * bz
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quatConjugate(q) {
|
||||||
|
return [-q[0], -q[1], -q[2], q[3]];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quatDot(a, b) {
|
||||||
|
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quatNormalize(q) {
|
||||||
|
const len = Math.hypot(q[0], q[1], q[2], q[3]);
|
||||||
|
return len === 0 ? quatIdentity() : [q[0] / len, q[1] / len, q[2] / len, q[3] / len];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shortest-path spherical interpolation; falls back to a lerp when nearly parallel. */
|
||||||
|
export function quatSlerp(a, b, t) {
|
||||||
|
let d = quatDot(a, b);
|
||||||
|
let target = b;
|
||||||
|
if (d < 0) {
|
||||||
|
target = [-b[0], -b[1], -b[2], -b[3]];
|
||||||
|
d = -d;
|
||||||
|
}
|
||||||
|
if (d > 0.9995) {
|
||||||
|
return quatNormalize([
|
||||||
|
a[0] + (target[0] - a[0]) * t,
|
||||||
|
a[1] + (target[1] - a[1]) * t,
|
||||||
|
a[2] + (target[2] - a[2]) * t,
|
||||||
|
a[3] + (target[3] - a[3]) * t
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
const theta = Math.acos(clamp(d, -1, 1));
|
||||||
|
const sinTheta = Math.sin(theta);
|
||||||
|
const wa = Math.sin((1 - t) * theta) / sinTheta;
|
||||||
|
const wb = Math.sin(t * theta) / sinTheta;
|
||||||
|
return quatNormalize([
|
||||||
|
a[0] * wa + target[0] * wb,
|
||||||
|
a[1] * wa + target[1] * wb,
|
||||||
|
a[2] * wa + target[2] * wb,
|
||||||
|
a[3] * wa + target[3] * wb
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shortest-path rotation taking `from` onto `to`: the axis (unit, world space) and the
|
||||||
|
* angle in [0, π]. `quatMul(quatFromAxisAngle(axis, angle), to)` reproduces `from`,
|
||||||
|
* which is what lets a spring unwind the remaining error without ever rewinding.
|
||||||
|
*/
|
||||||
|
export function quatError(from, to) {
|
||||||
|
// The rotation takes `to` onto `from`, so it can be *unwound*: pre-multiplying `to` by
|
||||||
|
// the returned axis/angle reproduces `from`, and driving the angle to zero lands on `to`.
|
||||||
|
let d = quatMul(from, quatConjugate(to));
|
||||||
|
if (d[3] < 0) d = [-d[0], -d[1], -d[2], -d[3]];
|
||||||
|
const v = [d[0], d[1], d[2]];
|
||||||
|
const sin = Math.hypot(v[0], v[1], v[2]);
|
||||||
|
return {
|
||||||
|
axis: sin < 1e-9 ? [0, 1, 0] : [v[0] / sin, v[1] / sin, v[2] / sin],
|
||||||
|
angle: 2 * Math.atan2(sin, d[3])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Integrates a freely spinning body one step: q' = normalize(q + ½ ω q dt). */
|
||||||
|
export function quatIntegrate(q, omega, dt) {
|
||||||
|
const spin = [omega[0] * 0.5 * dt, omega[1] * 0.5 * dt, omega[2] * 0.5 * dt, 0];
|
||||||
|
const dq = quatMul(spin, q);
|
||||||
|
return quatNormalize([q[0] + dq[0], q[1] + dq[1], q[2] + dq[2], q[3] + dq[3]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rotates a direction by a unit quaternion. */
|
||||||
|
export function quatRotate(q, v) {
|
||||||
|
const qv = [q[0], q[1], q[2]];
|
||||||
|
const t = mulScalar(cross(qv, v), 2);
|
||||||
|
return add(add(v, mulScalar(t, q[3])), cross(qv, t));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Column-major Float32Array(16) for a unit quaternion. */
|
||||||
|
export function mat4FromQuat(q) {
|
||||||
|
const [x, y, z, w] = q;
|
||||||
|
const m = mat4Identity();
|
||||||
|
m[0] = 1 - 2 * (y * y + z * z);
|
||||||
|
m[1] = 2 * (x * y + z * w);
|
||||||
|
m[2] = 2 * (x * z - y * w);
|
||||||
|
m[4] = 2 * (x * y - z * w);
|
||||||
|
m[5] = 1 - 2 * (x * x + z * z);
|
||||||
|
m[6] = 2 * (y * z + x * w);
|
||||||
|
m[8] = 2 * (x * z + y * w);
|
||||||
|
m[9] = 2 * (y * z - x * w);
|
||||||
|
m[10] = 1 - 2 * (x * x + y * y);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Integration quantum for `oscillatorStep`: exact for frame times that are multiples of it. */
|
||||||
|
export const OSCILLATOR_SUBSTEP = 1 / 240;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One step of a damped harmonic oscillator, integrated with fixed sub-steps so the result
|
||||||
|
* does not depend on the frame time and cannot blow up on a slow frame (the explicit
|
||||||
|
* scheme is only stable while ω·h stays well below 2).
|
||||||
|
*
|
||||||
|
* @param {{x: number, v: number}} state position and velocity
|
||||||
|
* @param {number} dt frame time in seconds
|
||||||
|
* @param {number} omega undamped angular frequency (rad/s)
|
||||||
|
* @param {number} zeta damping ratio (1 = critically damped, < 1 overshoots)
|
||||||
|
*/
|
||||||
|
export function oscillatorStep(state, dt, omega, zeta, substep = OSCILLATOR_SUBSTEP) {
|
||||||
|
let { x, v } = state;
|
||||||
|
let remaining = dt;
|
||||||
|
while (remaining > 1e-9) {
|
||||||
|
const h = Math.min(substep, remaining);
|
||||||
|
remaining -= h;
|
||||||
|
const accel = -omega * omega * x - 2 * zeta * omega * v;
|
||||||
|
v += accel * h;
|
||||||
|
x += v * h;
|
||||||
|
}
|
||||||
|
return { x, v };
|
||||||
|
}
|
||||||
|
|
||||||
/** Mixes two [r, g, b] colours (0..255) by t. */
|
/** Mixes two [r, g, b] colours (0..255) by t. */
|
||||||
export function mixRgb(from, to, t) {
|
export function mixRgb(from, to, t) {
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,14 @@
|
||||||
* Mesh builders for the oracle vessel. Pure data in, pure data out: every mesh is
|
* Mesh builders for the oracle vessel. Pure data in, pure data out: every mesh is
|
||||||
* `{ vertices: [[x, y, z]], faces: [{ indices, normal, kind, smooth }] }`, which the
|
* `{ vertices: [[x, y, z]], faces: [{ indices, normal, kind, smooth }] }`, which the
|
||||||
* renderer walks generically.
|
* renderer walks generically.
|
||||||
|
*
|
||||||
|
* Both solids are stacks of hexagonal rings, which lets a bevel sit wherever an edge
|
||||||
|
* should catch the key light. At the camera's three-quarter angle those bevels are what
|
||||||
|
* make the shapes read as solid rather than as flat silhouettes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { normalize } from './math3d.js';
|
||||||
|
|
||||||
/** Point-up hexagon vertices (matches the CSS clip-path used elsewhere). */
|
/** Point-up hexagon vertices (matches the CSS clip-path used elsewhere). */
|
||||||
export function hexagon(radius, rotation = Math.PI / 2) {
|
export function hexagon(radius, rotation = Math.PI / 2) {
|
||||||
return Array.from({ length: 6 }, (_, i) => {
|
return Array.from({ length: 6 }, (_, i) => {
|
||||||
|
|
@ -25,19 +31,57 @@ function centroid(vertices, indices) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The outer shell: a hexagonal prism whose front cap has a hexagonal opening so the
|
* Outward normal of a wall, solved in the (radius, z) profile plane. Both solids are
|
||||||
* liquid chamber inside is visible through the frame.
|
* surfaces of revolution about the z axis, so "outward" is simply the sign that points
|
||||||
|
* away from the interior at (0, 0) — no hand-picked normals to get wrong when a ring
|
||||||
|
* moves.
|
||||||
*/
|
*/
|
||||||
export function createVessel({ outerRadius = 1, depth = 0.52, windowRadius = 0.63 } = {}) {
|
function profileNormal(u1, z1, u2, z2) {
|
||||||
const half = depth / 2;
|
let nu = z2 - z1;
|
||||||
const inner = hexagon(windowRadius);
|
let nz = -(u2 - u1);
|
||||||
|
if (-u1 * nu - z1 * nz > 0) {
|
||||||
|
nu = -nu;
|
||||||
|
nz = -nz;
|
||||||
|
}
|
||||||
|
const len = Math.hypot(nu, nz) || 1;
|
||||||
|
return [nu / len, nz / len];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lifts a profile normal into a 3D face normal for the wall facing `angle`. */
|
||||||
|
function wallNormal(angle, profile) {
|
||||||
|
return [Math.cos(angle) * profile[0], Math.sin(angle) * profile[0], profile[1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mid-angle of the hexagonal wall between vertex i and i + 1. */
|
||||||
|
const wallAngle = (i) => Math.PI / 2 + ((i + 0.5) * Math.PI) / 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The outer shell: a chamfered hexagonal puck.
|
||||||
|
*
|
||||||
|
* The widest ring sits *behind* the front plane, so a bevel slopes forward and outward
|
||||||
|
* from it to the flat front cap — that bevel faces the camera and the key light, which is
|
||||||
|
* what gives the body its solid read. The front cap carries the recessed window.
|
||||||
|
*/
|
||||||
|
export function createVessel({
|
||||||
|
radius = 1,
|
||||||
|
rimZ = 0.24,
|
||||||
|
frontRadius = 0.88,
|
||||||
|
frontZ = 0.37,
|
||||||
|
backRadius = 0.8,
|
||||||
|
backZ = -0.37,
|
||||||
|
windowRadius = 0.63
|
||||||
|
} = {}) {
|
||||||
const vertices = [];
|
const vertices = [];
|
||||||
const push = (x, y, z) => vertices.push([x, y, z]) - 1;
|
const push = (x, y, z) => vertices.push([x, y, z]) - 1;
|
||||||
const points = hexagon(outerRadius);
|
const ring = (r, z) => hexagon(r).map(([x, y]) => push(x, y, z));
|
||||||
|
|
||||||
const outerBack = points.map(([x, y]) => push(x, y, -half));
|
const outerBack = ring(backRadius, backZ);
|
||||||
const outerFront = points.map(([x, y]) => push(x, y, half));
|
const rim = ring(radius, rimZ);
|
||||||
const innerFront = inner.map(([x, y]) => push(x, y, half));
|
const outerFront = ring(frontRadius, frontZ);
|
||||||
|
const innerFront = ring(windowRadius, frontZ);
|
||||||
|
|
||||||
|
const side = profileNormal(backRadius, backZ, radius, rimZ);
|
||||||
|
const bevel = profileNormal(radius, rimZ, frontRadius, frontZ);
|
||||||
|
|
||||||
const faces = [
|
const faces = [
|
||||||
{ indices: [...outerBack].reverse(), normal: [0, 0, -1], kind: 'back', smooth: true }
|
{ indices: [...outerBack].reverse(), normal: [0, 0, -1], kind: 'back', smooth: true }
|
||||||
|
|
@ -45,13 +89,19 @@ export function createVessel({ outerRadius = 1, depth = 0.52, windowRadius = 0.6
|
||||||
|
|
||||||
for (let i = 0; i < 6; i += 1) {
|
for (let i = 0; i < 6; i += 1) {
|
||||||
const j = (i + 1) % 6;
|
const j = (i + 1) % 6;
|
||||||
const mid = Math.PI / 2 + ((i + 0.5) * Math.PI) / 3;
|
const angle = wallAngle(i);
|
||||||
faces.push({
|
faces.push({
|
||||||
indices: [outerBack[i], outerBack[j], outerFront[j], outerFront[i]],
|
indices: [outerBack[i], outerBack[j], rim[j], rim[i]],
|
||||||
normal: [Math.cos(mid), Math.sin(mid), 0],
|
normal: wallNormal(angle, side),
|
||||||
kind: 'side',
|
kind: 'side',
|
||||||
smooth: true
|
smooth: true
|
||||||
});
|
});
|
||||||
|
faces.push({
|
||||||
|
indices: [rim[i], rim[j], outerFront[j], outerFront[i]],
|
||||||
|
normal: wallNormal(angle, bevel),
|
||||||
|
kind: 'bevel',
|
||||||
|
smooth: false
|
||||||
|
});
|
||||||
faces.push({
|
faces.push({
|
||||||
indices: [outerFront[i], outerFront[j], innerFront[j], innerFront[i]],
|
indices: [outerFront[i], outerFront[j], innerFront[j], innerFront[i]],
|
||||||
normal: [0, 0, 1],
|
normal: [0, 0, 1],
|
||||||
|
|
@ -62,20 +112,33 @@ export function createVessel({ outerRadius = 1, depth = 0.52, windowRadius = 0.6
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mesh: { vertices, faces },
|
mesh: { vertices, faces },
|
||||||
window: { radius: windowRadius, z: half, points: inner },
|
window: { radius: windowRadius, z: frontZ, points: hexagon(windowRadius) },
|
||||||
extents: { outerRadius, depth, windowRadius }
|
extents: { radius, rimZ, frontRadius, frontZ, backRadius, backZ, windowRadius }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The floating die: a flat hexagonal plate that can settle face-on to the answer. */
|
/**
|
||||||
export function createPlate({ radius = 0.44, thickness = 0.16 } = {}) {
|
* The floating die: a chamfered hexagonal token. The front face stays flat (the verdict is
|
||||||
|
* inked onto it), while the bevelled shoulder and rim give the tumble a moving highlight
|
||||||
|
* instead of a flat edge-on line.
|
||||||
|
*/
|
||||||
|
export function createPlate({ radius = 0.44, thickness = 0.2, chamfer = 0.05 } = {}) {
|
||||||
const half = thickness / 2;
|
const half = thickness / 2;
|
||||||
const points = hexagon(radius);
|
const lipZ = half - chamfer * 1.6;
|
||||||
|
const faceRadius = radius - chamfer;
|
||||||
|
|
||||||
const vertices = [];
|
const vertices = [];
|
||||||
const push = (x, y, z) => vertices.push([x, y, z]) - 1;
|
const push = (x, y, z) => vertices.push([x, y, z]) - 1;
|
||||||
|
const ring = (r, z) => hexagon(r).map(([x, y]) => push(x, y, z));
|
||||||
|
|
||||||
const front = points.map(([x, y]) => push(x, y, half));
|
const front = ring(faceRadius, half);
|
||||||
const back = points.map(([x, y]) => push(x, y, -half));
|
const frontLip = ring(radius, lipZ);
|
||||||
|
const backLip = ring(radius, -lipZ);
|
||||||
|
const back = ring(faceRadius, -half);
|
||||||
|
|
||||||
|
const shoulder = profileNormal(radius, lipZ, faceRadius, half);
|
||||||
|
const rim = profileNormal(radius, -lipZ, radius, lipZ);
|
||||||
|
const heel = profileNormal(faceRadius, -half, radius, -lipZ);
|
||||||
|
|
||||||
const faces = [
|
const faces = [
|
||||||
{ indices: front, normal: [0, 0, 1], kind: 'answer', smooth: false },
|
{ indices: front, normal: [0, 0, 1], kind: 'answer', smooth: false },
|
||||||
|
|
@ -84,18 +147,31 @@ export function createPlate({ radius = 0.44, thickness = 0.16 } = {}) {
|
||||||
|
|
||||||
for (let i = 0; i < 6; i += 1) {
|
for (let i = 0; i < 6; i += 1) {
|
||||||
const j = (i + 1) % 6;
|
const j = (i + 1) % 6;
|
||||||
const mid = Math.PI / 2 + ((i + 0.5) * Math.PI) / 3;
|
const angle = wallAngle(i);
|
||||||
faces.push({
|
faces.push({
|
||||||
indices: [back[i], back[j], front[j], front[i]],
|
indices: [frontLip[i], frontLip[j], front[j], front[i]],
|
||||||
normal: [Math.cos(mid), Math.sin(mid), 0],
|
normal: wallNormal(angle, shoulder),
|
||||||
|
kind: 'plate-shoulder',
|
||||||
|
smooth: false
|
||||||
|
});
|
||||||
|
faces.push({
|
||||||
|
indices: [backLip[i], backLip[j], frontLip[j], frontLip[i]],
|
||||||
|
normal: wallNormal(angle, rim),
|
||||||
kind: 'plate-rim',
|
kind: 'plate-rim',
|
||||||
smooth: false
|
smooth: false
|
||||||
});
|
});
|
||||||
|
faces.push({
|
||||||
|
indices: [back[i], back[j], backLip[j], backLip[i]],
|
||||||
|
normal: wallNormal(angle, heel),
|
||||||
|
kind: 'plate-heel',
|
||||||
|
smooth: false
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { mesh: { vertices, faces }, points, radius, thickness };
|
return { mesh: { vertices, faces }, points: hexagon(faceRadius), radius: faceRadius, thickness };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function faceCentroid(vertices, indices) {
|
export function faceCentroid(vertices, indices) {
|
||||||
return centroid(vertices, indices);
|
return centroid(vertices, indices);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,15 @@ app.disable('x-powered-by');
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT, 10) || 3000;
|
const PORT = parseInt(process.env.PORT, 10) || 3000;
|
||||||
|
|
||||||
// The repo intentionally carries a key so a fresh clone (or deploy) works with no
|
// Default placeholder API key. Set OPENROUTER_API_KEY in your environment or .env file.
|
||||||
// secret configured. Delete this constant and set OPENROUTER_API_KEY to require one.
|
const REPO_API_KEY = 'your-openrouter-api-key';
|
||||||
const REPO_API_KEY = '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';
|
|
||||||
|
|
||||||
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY || REPO_API_KEY;
|
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY || REPO_API_KEY;
|
||||||
const JEV_DECISIONS_URL = 'https://openrouter.ai/api/alpha/decisions';
|
const JEV_DECISIONS_URL = 'https://openrouter.ai/api/alpha/decisions';
|
||||||
const MODEL = 'typesafe/jev-1.13';
|
const MODEL = 'typesafe/jev-1.13';
|
||||||
|
|
||||||
if (!process.env.OPENROUTER_API_KEY) {
|
if (!process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_API_KEY === REPO_API_KEY) {
|
||||||
console.warn('[jev-8ball] OPENROUTER_API_KEY is not set — using the in-repo key.');
|
console.warn('[jev-8ball] OPENROUTER_API_KEY is not set or using default placeholder. Please configure your OpenRouter API key.');
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
|
|
|
||||||
|
|
@ -5,21 +5,32 @@
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { clipPolygonBelow, inkTransform, wrapAnswer } from '../public/js/ball3d.js';
|
import { CAMERA, OracleBall, clipPolygonBelow, inkTransform, springToward, wrapAnswer } from '../public/js/ball3d.js';
|
||||||
import {
|
import {
|
||||||
affineFromThreePoints,
|
affineFromThreePoints,
|
||||||
clamp,
|
clamp,
|
||||||
cross,
|
cross,
|
||||||
dot,
|
dot,
|
||||||
hexToRgb,
|
hexToRgb,
|
||||||
|
mat4FromQuat,
|
||||||
mat4LookAt,
|
mat4LookAt,
|
||||||
mat4Mul,
|
mat4Mul,
|
||||||
mat4Perspective,
|
mat4Perspective,
|
||||||
|
mat4RotateX,
|
||||||
|
mat4RotateY,
|
||||||
mat4RotateZ,
|
mat4RotateZ,
|
||||||
mat4Translate,
|
mat4Translate,
|
||||||
mixRgb,
|
mixRgb,
|
||||||
normalize,
|
normalize,
|
||||||
|
oscillatorStep,
|
||||||
projectToScreen,
|
projectToScreen,
|
||||||
|
quatError,
|
||||||
|
quatFromAxisAngle,
|
||||||
|
quatIdentity,
|
||||||
|
quatIntegrate,
|
||||||
|
quatMul,
|
||||||
|
quatRotate,
|
||||||
|
quatSlerp,
|
||||||
rgbCss,
|
rgbCss,
|
||||||
smoothstep,
|
smoothstep,
|
||||||
transformDir,
|
transformDir,
|
||||||
|
|
@ -110,9 +121,9 @@ test('hexagon vertices are point-up and evenly spaced', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('vessel mesh is closed, outward-facing, and unit-normalled', () => {
|
test('vessel mesh is closed, outward-facing, and unit-normalled', () => {
|
||||||
const { mesh, window: opening } = createVessel();
|
const { mesh, window: opening, extents } = createVessel();
|
||||||
assert.equal(mesh.vertices.length, 18);
|
assert.equal(mesh.vertices.length, 24); // four hexagonal rings
|
||||||
assert.equal(mesh.faces.length, 13); // back cap + 6 sides + 6 frame pieces
|
assert.equal(mesh.faces.length, 19); // back cap + 6 sides + 6 bevels + 6 front frame pieces
|
||||||
|
|
||||||
for (const face of mesh.faces) {
|
for (const face of mesh.faces) {
|
||||||
assert.ok(face.indices.length >= 3 && face.indices.length <= 6);
|
assert.ok(face.indices.length >= 3 && face.indices.length <= 6);
|
||||||
|
|
@ -134,6 +145,17 @@ test('vessel mesh is closed, outward-facing, and unit-normalled', () => {
|
||||||
if (face.kind === 'ring') assert.deepEqual(face.normal, [0, 0, 1]);
|
if (face.kind === 'ring') assert.deepEqual(face.normal, [0, 0, 1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The widest ring sits behind the front plane, so every bevel slopes forward and
|
||||||
|
// outward: that is the band that faces the camera and the key light.
|
||||||
|
assert.ok(extents.rimZ < extents.frontZ, 'the bevel must slope toward the camera');
|
||||||
|
assert.ok(extents.frontRadius < extents.radius, 'the front cap must be narrower than the rim');
|
||||||
|
for (const face of mesh.faces) {
|
||||||
|
if (face.kind !== 'bevel') continue;
|
||||||
|
const radial = Math.hypot(face.normal[0], face.normal[1]);
|
||||||
|
assert.ok(face.normal[2] > 0, 'bevel must face the camera');
|
||||||
|
assert.ok(radial > 0, 'bevel must face outward');
|
||||||
|
}
|
||||||
|
|
||||||
// The opening is smaller than the body and sits on the front plane.
|
// The opening is smaller than the body and sits on the front plane.
|
||||||
assert.ok(opening.radius > 0 && opening.radius < 1);
|
assert.ok(opening.radius > 0 && opening.radius < 1);
|
||||||
assert.ok(opening.z > 0);
|
assert.ok(opening.z > 0);
|
||||||
|
|
@ -142,8 +164,8 @@ test('vessel mesh is closed, outward-facing, and unit-normalled', () => {
|
||||||
|
|
||||||
test('plate mesh exposes an ordered answer face for text projection', () => {
|
test('plate mesh exposes an ordered answer face for text projection', () => {
|
||||||
const plate = createPlate();
|
const plate = createPlate();
|
||||||
assert.equal(plate.mesh.vertices.length, 12);
|
assert.equal(plate.mesh.vertices.length, 24); // front face, two lips, back face
|
||||||
assert.equal(plate.mesh.faces.length, 8); // 8 faces, like the eight-ball
|
assert.equal(plate.mesh.faces.length, 20); // 2 caps + shoulder, rim and heel rings
|
||||||
|
|
||||||
const answer = plate.mesh.faces.find((face) => face.kind === 'answer');
|
const answer = plate.mesh.faces.find((face) => face.kind === 'answer');
|
||||||
assert.deepEqual(answer.normal, [0, 0, 1]);
|
assert.deepEqual(answer.normal, [0, 0, 1]);
|
||||||
|
|
@ -156,7 +178,16 @@ test('plate mesh exposes an ordered answer face for text projection', () => {
|
||||||
assert.ok(near(vertex[0], point[0]), `x mismatch at ${position}`);
|
assert.ok(near(vertex[0], point[0]), `x mismatch at ${position}`);
|
||||||
assert.ok(near(vertex[1], point[1]), `y mismatch at ${position}`);
|
assert.ok(near(vertex[1], point[1]), `y mismatch at ${position}`);
|
||||||
assert.ok(near(vertex[2], plate.thickness / 2));
|
assert.ok(near(vertex[2], plate.thickness / 2));
|
||||||
|
assert.ok(near(Math.hypot(point[0], point[1]), plate.radius));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A bevelled shoulder keeps a highlight sweeping across the die as it tumbles.
|
||||||
|
const shoulder = plate.mesh.faces.filter((face) => face.kind === 'plate-shoulder');
|
||||||
|
assert.equal(shoulder.length, 6);
|
||||||
|
for (const face of shoulder) {
|
||||||
|
assert.ok(face.normal[2] > 0, 'shoulder must face the camera');
|
||||||
|
assert.ok(Math.hypot(face.normal[0], face.normal[1]) > 0, 'shoulder must face outward');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('inkTransform keeps verdict glyphs upright on a face-on plate', () => {
|
test('inkTransform keeps verdict glyphs upright on a face-on plate', () => {
|
||||||
|
|
@ -238,3 +269,201 @@ test('wrapAnswer breaks verdicts into plate-sized lines', () => {
|
||||||
assert.ok(line.length <= 20, `line too long for the plate: ${line}`);
|
assert.ok(line.length <= 20, `line too long for the plate: ${line}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ─── animation layer ─── */
|
||||||
|
|
||||||
|
test('quaternions agree with the matrix rotations', () => {
|
||||||
|
const angle = 0.7;
|
||||||
|
for (const [axis, rotate] of [
|
||||||
|
[[1, 0, 0], mat4RotateX],
|
||||||
|
[[0, 1, 0], mat4RotateY],
|
||||||
|
[[0, 0, 1], mat4RotateZ]
|
||||||
|
]) {
|
||||||
|
const fromQuat = mat4FromQuat(quatFromAxisAngle(axis, angle));
|
||||||
|
const fromMatrix = rotate(angle);
|
||||||
|
for (let i = 0; i < 16; i += 1) {
|
||||||
|
assert.ok(near(fromQuat[i], fromMatrix[i], 1e-6), `element ${i} for axis ${axis}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composing quaternions must match composing matrices, and rotating a direction through
|
||||||
|
// the quaternion path must land where the matrix path does.
|
||||||
|
const composed = quatMul(quatFromAxisAngle([0, 0, 1], 0.4), quatFromAxisAngle([1, 0, 0], -1.1));
|
||||||
|
const composedMatrix = mat4Mul(mat4RotateZ(0.4), mat4RotateX(-1.1));
|
||||||
|
const direction = normalize([0.3, -0.4, 0.8]);
|
||||||
|
assert.ok(nearVec(quatRotate(composed, direction), transformDir(composedMatrix, direction), 1e-5));
|
||||||
|
assert.ok(near(Math.hypot(...composed), 1, 1e-9), 'quatMul must preserve unit length');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quatSlerp takes the shortest path and is exact at both ends', () => {
|
||||||
|
const a = quatFromAxisAngle([0, 1, 0], 0.3);
|
||||||
|
const b = quatFromAxisAngle([1, 0, 0], 2.4);
|
||||||
|
assert.ok(nearVec(quatSlerp(a, b, 0), a, 1e-9));
|
||||||
|
assert.ok(nearVec(quatSlerp(a, b, 1), b, 1e-9));
|
||||||
|
|
||||||
|
const mid = quatSlerp(a, b, 0.5);
|
||||||
|
assert.ok(near(quatError(mid, a).angle, quatError(mid, b).angle, 1e-6), 'midpoint must be equidistant');
|
||||||
|
|
||||||
|
// 350° about an axis is a 10° rotation, not a 350° one.
|
||||||
|
const nearlyFull = quatFromAxisAngle([0, 0, 1], Math.PI * 2 - 0.2);
|
||||||
|
assert.ok(quatError(nearlyFull, quatIdentity()).angle < 0.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quatError yields an error that unwinds onto the target', () => {
|
||||||
|
const target = quatFromAxisAngle([0.2, 1, 0.4], 0.9);
|
||||||
|
const spin = { axis: normalize([0.3, -0.5, 0.8]), angle: 2.2 };
|
||||||
|
const from = quatMul(quatFromAxisAngle(spin.axis, spin.angle), target);
|
||||||
|
|
||||||
|
const error = quatError(from, target);
|
||||||
|
assert.ok(near(error.angle, spin.angle, 1e-6), `recovered ${error.angle}`);
|
||||||
|
assert.ok(nearVec(error.axis, spin.axis, 1e-6));
|
||||||
|
|
||||||
|
// The identity the settle relies on: winding the angle down to zero lands exactly on the
|
||||||
|
// target, while the full angle reproduces where the die actually was.
|
||||||
|
assert.ok(nearVec(quatMul(quatFromAxisAngle(error.axis, error.angle), target), from, 1e-9));
|
||||||
|
assert.ok(nearVec(quatMul(quatFromAxisAngle(error.axis, 0), target), target, 1e-12));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quatIntegrate spins a body about its axis', () => {
|
||||||
|
let q = quatIdentity();
|
||||||
|
// Stay under half a turn: past that the shortest-path angle wraps, which is the
|
||||||
|
// behaviour quatError is *supposed* to have.
|
||||||
|
const steps = 24;
|
||||||
|
for (let i = 0; i < steps; i += 1) q = quatIntegrate(q, [0, 0, 2.5], 1 / 60);
|
||||||
|
const expected = (steps * 2.5) / 60;
|
||||||
|
assert.ok(near(Math.hypot(...q), 1, 1e-9), 'integration must stay normalized');
|
||||||
|
assert.ok(near(quatError(q, quatIdentity()).angle, expected, 1e-3));
|
||||||
|
assert.ok(nearVec(quatError(q, quatIdentity()).axis, [0, 0, 1], 1e-3));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('oscillatorStep is stable, damped and frame-rate independent', () => {
|
||||||
|
// One 30 Hz step and two 60 Hz steps must agree: motion cannot depend on the frame rate
|
||||||
|
// of the machine it happens to run on.
|
||||||
|
const coarse = oscillatorStep({ x: 1, v: 0 }, 1 / 30, 12, 0.3);
|
||||||
|
const fine = oscillatorStep(oscillatorStep({ x: 1, v: 0 }, 1 / 60, 12, 0.3), 1 / 60, 12, 0.3);
|
||||||
|
assert.ok(near(coarse.x, fine.x, 1e-9) && near(coarse.v, fine.v, 1e-9));
|
||||||
|
|
||||||
|
// A critically damped spring approaches rest without crossing it.
|
||||||
|
let state = { x: 1, v: 0 };
|
||||||
|
for (let i = 0; i < 240; i += 1) {
|
||||||
|
state = oscillatorStep(state, 1 / 60, 8, 1);
|
||||||
|
assert.ok(state.x > 0, `critically damped spring overshot at step ${i}`);
|
||||||
|
}
|
||||||
|
assert.ok(Math.abs(state.x) < 1e-3, `did not settle: ${state.x}`);
|
||||||
|
|
||||||
|
// An underdamped spring rings past the target.
|
||||||
|
let ringing = { x: 1, v: 0 };
|
||||||
|
let crossed = false;
|
||||||
|
for (let i = 0; i < 120; i += 1) {
|
||||||
|
ringing = oscillatorStep(ringing, 1 / 60, 10, 0.2);
|
||||||
|
if (ringing.x < 0) crossed = true;
|
||||||
|
}
|
||||||
|
assert.ok(crossed, 'an underdamped spring should overshoot');
|
||||||
|
|
||||||
|
// A stalled frame must not make the simulation explode: that is why it sub-steps.
|
||||||
|
const stalled = oscillatorStep({ x: 1, v: 0 }, 0.5, 50, 0.1);
|
||||||
|
assert.ok(Number.isFinite(stalled.x) && Math.abs(stalled.x) <= 1.01, `blew up: ${stalled.x}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('springToward converges on a target and is frame-rate independent', () => {
|
||||||
|
let state = { x: 0, v: 0 };
|
||||||
|
for (let i = 0; i < 240; i += 1) state = springToward(state, 1, 1 / 60, { omega: 20, zeta: 0.7 });
|
||||||
|
assert.ok(near(state.x, 1, 1e-3), `did not converge: ${state.x}`);
|
||||||
|
|
||||||
|
const rates = { omega: 20, zeta: 0.7 };
|
||||||
|
const coarse = springToward({ x: 0, v: 0 }, 0.5, 1 / 30, rates);
|
||||||
|
const fine = springToward(springToward({ x: 0, v: 0 }, 0.5, 1 / 60, rates), 0.5, 1 / 60, rates);
|
||||||
|
assert.ok(near(coarse.x, fine.x, 1e-9) && near(coarse.v, fine.v, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the roll tumbles smoothly and settles onto the answer face', () => {
|
||||||
|
// The renderer modules are pure, so the whole motion can be stepped headlessly against a
|
||||||
|
// stub canvas: no browser, no WebGL, and fully deterministic.
|
||||||
|
const ball = new OracleBall({ getContext: () => null }, {});
|
||||||
|
const dt = 1 / 60;
|
||||||
|
const step = () => {
|
||||||
|
ball.clock += dt;
|
||||||
|
ball._update(dt);
|
||||||
|
};
|
||||||
|
|
||||||
|
ball.roll();
|
||||||
|
const start = ball.plateQuat.slice();
|
||||||
|
let travelled = 0;
|
||||||
|
for (let i = 0; i < 30; i += 1) {
|
||||||
|
step();
|
||||||
|
travelled += quatError(start, ball.plateQuat).angle;
|
||||||
|
}
|
||||||
|
assert.ok(travelled > 1.5, `the die barely tumbled: ${travelled} rad in half a second`);
|
||||||
|
|
||||||
|
// Answer mid-tumble, then record every frame of the settle.
|
||||||
|
ball.answer('Yes definitely.', 'affirmative');
|
||||||
|
const remainingAngle = [];
|
||||||
|
const jumps = [];
|
||||||
|
let previous = ball.plateQuat.slice();
|
||||||
|
let frames = 0;
|
||||||
|
while (ball.phase !== 'ready' && frames < 900) {
|
||||||
|
step();
|
||||||
|
const now = ball.plateQuat.slice();
|
||||||
|
jumps.push(quatError(previous, now).angle);
|
||||||
|
remainingAngle.push(quatError(now, quatIdentity()).angle);
|
||||||
|
previous = now;
|
||||||
|
frames += 1;
|
||||||
|
}
|
||||||
|
assert.equal(ball.phase, 'ready', 'the settle must finish on its own');
|
||||||
|
|
||||||
|
// No frame may teleport the die: a jump is exactly what a rewinding unwind looks like.
|
||||||
|
assert.ok(Math.max(...jumps) < 0.3, `a single frame rotated ${Math.max(...jumps)} rad`);
|
||||||
|
|
||||||
|
// The remaining error falls away, overshooting at most once — a settle, not a rewind.
|
||||||
|
let sign = 0;
|
||||||
|
let overshoot = 0;
|
||||||
|
let crossed = false;
|
||||||
|
remainingAngle.forEach((value, index) => {
|
||||||
|
if (index === 0) return;
|
||||||
|
const delta = value - remainingAngle[index - 1];
|
||||||
|
if (Math.abs(delta) < 1e-4) return;
|
||||||
|
const next = Math.sign(delta);
|
||||||
|
if (sign !== 0 && next !== sign) crossed = true;
|
||||||
|
sign = next;
|
||||||
|
if (crossed) overshoot = Math.max(overshoot, value);
|
||||||
|
});
|
||||||
|
assert.ok(overshoot > 0.01, 'the settle should bounce once as the die lands');
|
||||||
|
assert.ok(overshoot < 0.2, `the settle bounced too far: ${overshoot} rad`);
|
||||||
|
assert.ok(remainingAngle.at(-1) < 0.03, `the die never landed: ${remainingAngle.at(-1)}`);
|
||||||
|
|
||||||
|
// Face-on to the camera, ink surfaced, floating inside the chamber.
|
||||||
|
assert.ok(dot(quatRotate(ball.plateQuat, [0, 0, 1]), [0, 0, 1]) > 0.999);
|
||||||
|
assert.equal(ball.answerAlpha, 1);
|
||||||
|
assert.ok(ball.plateY.x > 0 && ball.plateY.x < 0.2, `the die drifted to y=${ball.plateY.x}`);
|
||||||
|
|
||||||
|
// The rattle and the liquid must ring down instead of buzzing forever.
|
||||||
|
for (let i = 0; i < 240; i += 1) step();
|
||||||
|
assert.ok(ball.shakeEnergy < 1e-3, `the rattle never stopped: ${ball.shakeEnergy}`);
|
||||||
|
assert.ok(Math.abs(ball.surface.x) < 0.02, `the liquid never settled: ${ball.surface.x}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the renderer camera frames the vessel from a three-quarter angle', () => {
|
||||||
|
const size = 344;
|
||||||
|
const view = mat4LookAt(CAMERA.eye, CAMERA.target, [0, 1, 0]);
|
||||||
|
const projection = mat4Mul(
|
||||||
|
mat4Perspective((CAMERA.fov * Math.PI) / 180, 1, CAMERA.near, CAMERA.far),
|
||||||
|
view
|
||||||
|
);
|
||||||
|
const { extents } = createVessel();
|
||||||
|
const centre = projectToScreen(transformPoint(projection, [0, 0, 0]), size, size);
|
||||||
|
const rim = [-1, 1].map((sign) =>
|
||||||
|
projectToScreen(transformPoint(projection, [sign * extents.radius, 0, extents.rimZ]), size, size)
|
||||||
|
);
|
||||||
|
const fraction = Math.max(...rim.map((point) => Math.abs(point.x - centre.x))) / (size / 2);
|
||||||
|
|
||||||
|
// Big enough to read, small enough to leave room for the rattle and the cast shadow.
|
||||||
|
assert.ok(fraction > 0.68 && fraction < 0.84, `the vessel fills ${fraction} of the frame`);
|
||||||
|
assert.ok(near(centre.x, size / 2, 2) && near(centre.y, size / 2, 4), 'the vessel must stay centred');
|
||||||
|
|
||||||
|
// The camera has to sit off-axis, or the bevelled rim and tapered walls flatten away.
|
||||||
|
const offset = Math.hypot(CAMERA.eye[0] - CAMERA.target[0], CAMERA.eye[1] - CAMERA.target[1]);
|
||||||
|
assert.ok(offset > 0.5, `the camera is almost dead-on: ${offset}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
const JEV_DECISIONS_URL = "https://openrouter.ai/api/alpha/decisions";
|
const JEV_DECISIONS_URL = "https://openrouter.ai/api/alpha/decisions";
|
||||||
const MODEL = "typesafe/jev-1.13";
|
const MODEL = "typesafe/jev-1.13";
|
||||||
|
|
||||||
// The repo intentionally carries a key so the deployed Worker works with no secret
|
// Default placeholder API key. Set OPENROUTER_API_KEY in environment or Cloudflare secrets.
|
||||||
// configured. Delete this constant and set the OPENROUTER_API_KEY secret to require one.
|
const REPO_API_KEY = "your-openrouter-api-key";
|
||||||
const REPO_API_KEY = "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";
|
|
||||||
|
|
||||||
function resolveApiKey(env) {
|
function resolveApiKey(env) {
|
||||||
return env.OPENROUTER_API_KEY || REPO_API_KEY || null;
|
return env.OPENROUTER_API_KEY || REPO_API_KEY || null;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue