jev-8ball/public/js/ball3d.js

710 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Software 3D renderer + animation state machine for the oracle vessel.
*
* Deliberately Canvas 2D rather than WebGL: the scene is a low-poly hexagonal prism
* plus a hexagonal plate, so painter's-algorithm depth sorting with per-face lighting
* costs almost nothing, and it renders identically on machines with no GL drivers
* (headless browsers, VMs, older devices) where a WebGL context cannot be created.
*
* The vessel is convex and the plate floats inside it, so sorting faces by camera
* distance and drawing back-to-front is sufficient: no z-buffer needed.
*/
import {
affineFromThreePoints,
clamp,
dot,
hexToRgb,
lerp,
mat4LookAt,
mat4Mul,
mat4Perspective,
mat4RotateX,
mat4RotateY,
mat4RotateZ,
mat4Translate,
mixRgb,
normalize,
projectToScreen,
rgbCss,
smoothstep,
transformDir,
transformPoint
} from './math3d.js';
import { createPlate, createVessel } from './meshes.js';
const TAU = Math.PI * 2;
const LIGHT = normalize([-0.42, 0.7, 0.86]);
const VIEW = [0, 0, 1];
const BODY_BASE = hexToRgb('#191919');
const BODY_EDGE = hexToRgb('#2b2b30');
const CHAMBER_RGB = hexToRgb('#07070b');
const PLATE_BASE = hexToRgb('#1b1b1f');
const SPECULAR = [236, 245, 255];
const TONES = {
affirmative: hexToRgb('#10b981'),
negative: hexToRgb('#ef4444'),
neutral: hexToRgb('#38bdf8'),
offline: hexToRgb('#8f8f96'),
idle: hexToRgb('#38bdf8')
};
// Two slightly perturbed lights give the large faces a soft falloff without the cost
// of per-vertex normals. Faceted faces stay flat so the silhouette reads as machined.
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 FONT_STACK = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
/** Phases: rolling -> (answer arrives) -> revealing -> ready -> idle. */
export class OracleBall {
constructor(canvas, { reducedMotion = false } = {}) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.reducedMotion = reducedMotion;
this.vessel = createVessel();
this.plate = createPlate();
this.radiusCss = 172;
this.width = 0;
this.height = 0;
this.dpr = 1;
this.clock = 0;
this.lastFrame = 0;
this.running = false;
this.raf = 0;
this.visible = true;
this.phase = 'idle';
this.rollStart = 0;
this.revealStart = 0;
this.holdTime = 0;
this.rollDuration = 1.25;
this.revealDuration = 0.9;
this.answerLines = ['CONCENTRATE', '& ASK'];
this.tone = 'idle';
this.pendingAnswer = null;
this.pointer = { x: 0, y: 0 };
this.tilt = { x: 0, y: 0 };
this.shake = 0;
this.glow = 0;
this.resolveRoll = null;
}
/* ─── public API ─── */
/** Starts the render loop, sizing, and pointer parallax. Safe to call twice. */
attach() {
if (this.running || !this.ctx) return;
this.running = true;
this._resize();
this._observeSize();
this._observeVisibility();
this.canvas.addEventListener('pointermove', this._onPointerMove);
this.canvas.addEventListener('pointerleave', this._onPointerLeave);
this.lastFrame = performance.now();
this.raf = requestAnimationFrame(this._frame);
}
detach() {
this.running = false;
cancelAnimationFrame(this.raf);
this.canvas.removeEventListener('pointermove', this._onPointerMove);
this.canvas.removeEventListener('pointerleave', this._onPointerLeave);
if (this.resizeObserver) this.resizeObserver.disconnect();
if (this.intersectionObserver) this.intersectionObserver.disconnect();
}
setReducedMotion(reduced) {
this.reducedMotion = reduced;
}
/** Resolves once the shake animation has played. */
roll() {
this.phase = 'rolling';
this.rollStart = this.clock;
this.holdTime = 0;
this.glow = 0;
this.answerLines = null;
return new Promise((resolve) => {
// Backstop so a paused render loop (hidden tab, off-screen canvas) can never
// leave the caller waiting forever.
setTimeout(() => {
if (this.resolveRoll === resolve) {
this.resolveRoll = null;
resolve();
}
}, Math.round(this.rollDuration * 1000) + 500);
this.resolveRoll = resolve;
});
}
/** Surfaces the die with the verdict text; ignored before a roll. */
answer(text, tone = 'neutral') {
const lines = wrapAnswer(text);
this.pendingAnswer = { lines, tone };
this.answerLines = lines;
this.tone = tone;
this.revealStart = this.clock;
this.tumbleAtReveal = this.platePose ? { ...this.platePose } : { rx: 0, ry: 0, rz: 0, y: -0.5 };
this.phase = 'revealing';
}
/** Returns the vessel to the resting state and clears the verdict. */
reset() {
this.answerLines = ['CONCENTRATE', '& ASK'];
this.tone = 'idle';
this.phase = 'idle';
this.glow = 0;
this.pendingAnswer = null;
}
/* ─── wiring ─── */
_onPointerMove = (event) => {
const rect = this.canvas.getBoundingClientRect();
this.pointer.x = clamp(((event.clientX - rect.left) / rect.width) * 2 - 1, -1, 1);
this.pointer.y = clamp(((event.clientY - rect.top) / rect.height) * 2 - 1, -1, 1);
};
_onPointerLeave = () => {
this.pointer.x = 0;
this.pointer.y = 0;
};
_observeSize() {
if (typeof ResizeObserver === 'function') {
this.resizeObserver = new ResizeObserver(() => this._resize());
this.resizeObserver.observe(this.canvas.parentElement || this.canvas);
}
window.addEventListener('resize', this._onWindowResize);
}
_onWindowResize = () => this._resize();
_observeVisibility() {
if (typeof IntersectionObserver === 'function') {
this.intersectionObserver = new IntersectionObserver((entries) => {
this.visible = entries.some((entry) => entry.isIntersecting);
});
this.intersectionObserver.observe(this.canvas);
}
}
_resize() {
const rect = this.canvas.getBoundingClientRect();
const size = Math.max(160, Math.min(rect.width || 344, 460));
this.dpr = Math.min(window.devicePixelRatio || 1, 2);
this.width = size;
this.height = size;
this.canvas.width = Math.round(size * this.dpr);
this.canvas.height = Math.round(size * this.dpr);
this.radiusCss = size;
}
/* ─── per-frame simulation ─── */
_frame = (now) => {
if (!this.running) return;
const dt = Math.min((now - this.lastFrame) / 1000, 0.05);
this.lastFrame = now;
// Freeze the clock while hidden or scrolled away so nothing jumps on return.
if (this.visible && !document.hidden) {
this.clock += dt;
this._update(dt);
this._render();
}
this.raf = requestAnimationFrame(this._frame);
};
_update(dt) {
const ease = Math.min(1, dt * 6);
this.tilt.x += ((this.reducedMotion ? 0 : this.pointer.y * 0.2) - this.tilt.x) * ease;
this.tilt.y += ((this.reducedMotion ? 0 : this.pointer.x * 0.3) - this.tilt.y) * ease;
const elapsed = this.clock - this.rollStart;
if (this.phase === 'rolling') {
this.rollProgress = this.reducedMotion ? 1 : clamp(elapsed / this.rollDuration, 0, 1);
this.shake = this.reducedMotion
? 0
: (1 - this.rollProgress * 0.6) * Math.sin(elapsed * 24) * 0.42;
this.liquidSlosh = this.reducedMotion ? 0 : 1;
if (elapsed >= this.rollDuration) {
this.holdTime += dt;
if (this.resolveRoll) {
const resolve = this.resolveRoll;
this.resolveRoll = null;
resolve();
}
}
this.platePose = this._tumblePose(elapsed);
this.answerAlpha = 0;
this.glow = this.reducedMotion ? 0 : 0.12 + Math.sin(elapsed * 9) * 0.06;
} else if (this.phase === 'revealing') {
const t = this.reducedMotion ? 1 : clamp((this.clock - this.revealStart) / this.revealDuration, 0, 1);
const settle = smoothstep(0, 0.72, t);
const from = this.tumbleAtReveal || { rx: 0, ry: 0, rz: 0, y: -0.5 };
// Unwind the tumble to zero so the plate always lands face-on to the camera.
this.platePose = {
rx: from.rx * (1 - settle),
ry: from.ry * (1 - settle),
rz: from.rz * (1 - settle),
y: lerp(from.y, 0.06, settle)
};
this.shake = this.reducedMotion ? 0 : (1 - t) * Math.sin(t * 18) * 0.12;
this.liquidSlosh = this.reducedMotion ? 0 : 1 - t;
this.answerAlpha = smoothstep(0.5, 1, t);
this.glow = Math.sin(t * Math.PI) * 1.1 + 0.16;
if (t >= 1) this.phase = 'ready';
} else {
const bob = this.reducedMotion ? 0 : Math.sin(this.clock * 1.15) * 0.022;
this.platePose = {
rx: this.reducedMotion ? 0 : Math.sin(this.clock * 0.42) * 0.06,
ry: this.reducedMotion ? 0 : Math.sin(this.clock * 0.55) * 0.24,
rz: this.reducedMotion ? 0 : Math.sin(this.clock * 0.37) * 0.05,
y: 0.02 + bob
};
this.liquidSlosh = this.reducedMotion ? 0 : 0.25;
this.answerAlpha = this.answerLines ? 1 : 0;
this.glow = this.phase === 'ready' ? 0.16 + Math.sin(this.clock * 1.6) * 0.04 : 0.06;
}
}
/** Churning pose used while the vessel is being shaken. */
_tumblePose(elapsed) {
const dive = smoothstep(0, this.rollDuration * 0.85, elapsed);
if (this.reducedMotion) {
return { rx: 0.5, ry: 0.6, rz: 0.2, y: lerp(0.02, -0.24, dive) };
}
return {
rx: elapsed * 5.2,
ry: elapsed * 3.05,
rz: elapsed * 2.15,
y: lerp(0.02, -0.5, dive)
};
}
get toneRgb() {
return TONES[this.tone] || TONES.idle;
}
/* ─── rendering ─── */
_render() {
const { ctx } = this;
const w = this.width;
const h = this.height;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
// Camera sits far enough that the vessel fills ~72% of the canvas height,
// leaving room for the shake, the rim glow, and the cast shadow.
const view = mat4LookAt([0, 0, 4.3], [0, 0, 0], [0, 1, 0]);
const proj = mat4Perspective((36 * Math.PI) / 180, 1, 0.1, 40);
this.viewProj = mat4Mul(proj, view);
const pose = this.platePose || { rx: 0, ry: 0, rz: 0, y: 0.02 };
const shakeZ = this.shake * 0.5;
const vesselModel = mat4Mul(
mat4RotateZ(shakeZ),
mat4Mul(
mat4RotateY(this.tilt.y + this.shake * 0.28),
mat4RotateX(this.tilt.x + Math.cos(this.clock * 9) * this.shake * 0.22)
)
);
const plateModel = mat4Mul(
mat4Translate(0, pose.y, 0),
mat4Mul(mat4RotateZ(pose.rz), mat4Mul(mat4RotateY(pose.ry), mat4RotateX(pose.rx)))
);
this._drawGroundGlow(w, h);
this._drawContactShadow(w, h);
const vessel = this._projectMesh(this.vessel.mesh, vesselModel);
const plate = this._projectMesh(this.plate.mesh, plateModel);
const windowPoly = this._projectWindow(vesselModel);
const liquid = windowPoly
? this._liquidGeometry(vesselModel, windowPoly)
: null;
const tone = this.toneRgb;
// 1. shell behind the chamber: back cap first, then the side facets
for (const face of vessel.faces) {
if (face.kind === 'ring') continue;
const base = face.kind === 'back' ? CHAMBER_RGB : BODY_BASE;
this._fillFace(face, this._faceStyle(face, base, tone, face.kind === 'back' ? 0.05 : 0.55));
}
// 2. chamber contents, clipped to the window opening
if (windowPoly && liquid) {
ctx.save();
this._clipPolygon(windowPoly);
this._fillLiquid(liquid.points, tone);
for (const face of plate.faces) {
const base = face.kind === 'answer' ? PLATE_BASE : BODY_EDGE;
this._fillFace(face, this._faceStyle(face, base, tone, face.kind === 'answer' ? 0.35 : 0.8));
}
if (this.answerLines && this.answerAlpha > 0.01) {
this._drawAnswerText(plate, windowPoly);
}
this._overlayLiquid(liquid, tone);
ctx.restore();
}
// 3. the front frame, nearest to the camera, drawn last
for (const face of vessel.faces) {
if (face.kind !== 'ring') continue;
this._fillFace(face, this._faceStyle(face, BODY_EDGE, tone, 0.5));
}
if (windowPoly) this._strokeWindow(windowPoly, tone);
}
/** Projects a mesh and sorts its faces back-to-front. */
_projectMesh(mesh, model) {
const mvp = mat4Mul(this.viewProj, model);
const screen = [];
const depth = [];
for (const vertex of mesh.vertices) {
const clip = transformPoint(mvp, vertex);
depth.push(clip[3]);
screen.push(projectToScreen(clip, this.width, this.height));
}
const faces = [];
for (const face of mesh.faces) {
if (face.indices.some((index) => !screen[index])) continue;
let sum = 0;
for (const index of face.indices) sum += depth[index];
faces.push({
kind: face.kind,
smooth: face.smooth,
normal: transformDir(model, face.normal),
depth: sum / face.indices.length,
points: face.indices.map((index) => screen[index])
});
}
faces.sort((a, b) => b.depth - a.depth);
return { faces, screen };
}
/** Projects the hexagonal opening onto the screen. */
_projectWindow(vesselModel) {
const mvp = mat4Mul(this.viewProj, vesselModel);
const { points, z } = this.vessel.window;
const projected = points.map(([x, y]) =>
projectToScreen(transformPoint(mvp, [x, y, z]), this.width, this.height)
);
return projected.every(Boolean) ? projected : null;
}
/** Screen-space liquid body: the window clipped to everything below the surface. */
_liquidGeometry(vesselModel, windowPoly) {
const mvp = mat4Mul(this.viewProj, vesselModel);
const { radius, z } = this.vessel.window;
const slosh = this.liquidSlosh || 0;
const level = -0.09 + Math.sin(this.clock * 8.5) * 0.05 * slosh;
const tilt = Math.sin(this.clock * 6.3) * 0.16 * slosh;
const left = projectToScreen(
transformPoint(mvp, [-radius * 1.35, level + tilt, z * 0.2]),
this.width,
this.height
);
const right = projectToScreen(
transformPoint(mvp, [radius * 1.35, level - tilt, z * 0.2]),
this.width,
this.height
);
if (!left || !right) return null;
return { points: clipPolygonBelow(windowPoly, left, right), surface: [left, right] };
}
_drawGroundGlow(w, h) {
const { ctx } = this;
const cx = w / 2;
const cy = h * 0.52;
const radius = w * 0.62;
const intensity = 0.3 + this.glow * 0.9;
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
gradient.addColorStop(0, rgbCss(this.toneRgb, Math.min(0.34, 0.2 * intensity)));
gradient.addColorStop(0.5, rgbCss(this.toneRgb, Math.min(0.12, 0.07 * intensity)));
gradient.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, w, h);
}
_drawContactShadow(w, h) {
const { ctx } = this;
const lift = (this.platePose ? 0 : 0) + 0.02;
ctx.save();
ctx.translate(w / 2, h * 0.5 + h * 0.4 + lift);
ctx.scale(1, 0.2);
const radius = w * 0.31;
const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, radius);
gradient.addColorStop(0, 'rgba(0,0,0,0.6)');
gradient.addColorStop(1, 'rgba(0,0,0,0)');
ctx.beginPath();
ctx.arc(0, 0, radius, 0, TAU);
ctx.fillStyle = gradient;
ctx.fill();
ctx.restore();
}
/* ─── shading & paint helpers ─── */
/** Blinn-Phong-ish shade: ambient + Lambert, specular, and a verdict-tinted rim. */
_shade(base, normal, light, tone, rimStrength) {
const diffuse = Math.max(0, dot(normal, light));
const half = normalize([light[0], light[1], light[2] + 1]);
const spec = Math.pow(Math.max(0, dot(normal, half)), 40) * 0.5;
const rim = Math.pow(1 - clamp(dot(normal, VIEW), 0, 1), 3) * rimStrength;
const lit = 0.13 + diffuse * 0.95;
const rgb = [
base[0] * lit + SPECULAR[0] * spec,
base[1] * lit + SPECULAR[1] * spec,
base[2] * lit + SPECULAR[2] * spec
];
return mixRgb(rgb, tone, clamp(rim * 0.55, 0, 1));
}
/** Solid fill for faceted faces, soft gradient for the large curved ones. */
_faceStyle(face, base, tone, rimStrength) {
if (!face.smooth) {
return rgbCss(this._shade(base, face.normal, LIGHT, tone, rimStrength));
}
const near = this._shade(base, face.normal, LIGHT_NEAR, tone, rimStrength);
const far = this._shade(base, face.normal, LIGHT_FAR, tone, rimStrength);
const bounds = boundsOf(face.points);
const gradient = this.ctx.createLinearGradient(bounds.minX, bounds.minY, bounds.maxX, bounds.maxY);
gradient.addColorStop(0, rgbCss(mixRgb(near, SPECULAR, 0.05)));
gradient.addColorStop(1, rgbCss(far));
return gradient;
}
_pathFromPoints(points) {
const ctx = this.ctx;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i += 1) ctx.lineTo(points[i].x, points[i].y);
ctx.closePath();
}
_clipPolygon(points) {
this._pathFromPoints(points);
this.ctx.clip();
}
_fillFace(face, style) {
if (face.points.length < 3) return;
const ctx = this.ctx;
this._pathFromPoints(face.points);
ctx.fillStyle = style;
ctx.fill();
// Hairline edge keeps the facets reading as machined metal rather than flat shapes.
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.stroke();
}
/* ─── liquid ─── */
_fillLiquid(points, tone) {
if (points.length < 3) return;
const ctx = this.ctx;
const bounds = boundsOf(points);
const gradient = ctx.createLinearGradient(bounds.minX, bounds.minY, bounds.minX, bounds.maxY);
gradient.addColorStop(0, rgbCss(mixRgb(hexToRgb('#050a16'), tone, 0.24)));
gradient.addColorStop(0.6, rgbCss(hexToRgb('#03060d')));
gradient.addColorStop(1, rgbCss(mixRgb(hexToRgb('#010205'), tone, 0.12)));
this._pathFromPoints(points);
ctx.fillStyle = gradient;
ctx.fill();
}
_overlayLiquid(liquid, tone) {
const ctx = this.ctx;
const points = liquid.points;
if (points.length >= 3) {
const bounds = boundsOf(points);
const gradient = ctx.createLinearGradient(bounds.minX, bounds.minY, bounds.minX, bounds.maxY);
gradient.addColorStop(0, rgbCss(tone, 0.22));
gradient.addColorStop(1, rgbCss(tone, 0.03));
this._pathFromPoints(points);
ctx.fillStyle = gradient;
ctx.fill();
}
const [a, b] = liquid.surface;
// drifting specular streaks just under the surface
for (let i = 0; i < 3; i += 1) {
const t = (this.clock * 0.33 + i / 3) % 1;
ctx.beginPath();
ctx.ellipse(lerp(a.x, b.x, t), lerp(a.y, b.y, t) + 3, 15, 3, 0, 0, TAU);
ctx.fillStyle = rgbCss(SPECULAR, 0.05);
ctx.fill();
}
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.strokeStyle = rgbCss(mixRgb(tone, SPECULAR, 0.45), 0.55);
ctx.lineWidth = 1.4;
ctx.stroke();
}
_strokeWindow(windowPoly, tone) {
const ctx = this.ctx;
const bounds = boundsOf(windowPoly);
ctx.save();
this._pathFromPoints(windowPoly);
ctx.strokeStyle = rgbCss(mixRgb(tone, SPECULAR, 0.3), Math.min(0.75, 0.3 + this.glow * 0.35));
ctx.lineWidth = 1.5;
ctx.stroke();
this._pathFromPoints(windowPoly);
ctx.clip();
const cx = (bounds.minX + bounds.maxX) / 2;
const cy = (bounds.minY + bounds.maxY) / 2;
const radius = Math.max(bounds.maxX - bounds.minX, bounds.maxY - bounds.minY) / 2;
const vignette = ctx.createRadialGradient(cx, cy, radius * 0.55, cx, cy, radius);
vignette.addColorStop(0, 'rgba(0,0,0,0)');
vignette.addColorStop(1, 'rgba(0,0,0,0.7)');
ctx.fillStyle = vignette;
ctx.fillRect(bounds.minX, bounds.minY, bounds.maxX - bounds.minX, bounds.maxY - bounds.minY);
ctx.restore();
}
/* ─── verdict text mapped onto the die face ─── */
_drawAnswerText(plateView) {
const ctx = this.ctx;
const face = plateView.faces.find((candidate) => candidate.kind === 'answer');
if (!face || face.points.length < 6) return;
if (dot(face.normal, VIEW) < 0.3) return;
// The face keeps the plate's local vertex order, so three corners are enough to
// solve the affine transform from plate-local space into screen space.
const corners = [0, 3, 5].map((index) => ({
local: this.plate.points[index],
screen: [face.points[index].x, face.points[index].y]
}));
const matrix = affineFromThreePoints(corners[0], corners[1], corners[2]);
if (!matrix) return;
const lines = this.answerLines;
if (!lines || !lines.length) return;
const radius = this.plate.radius;
const usableWidth = radius * 1.5;
const maxChars = Math.max(4, ...lines.map((line) => line.length));
let fontSize = clamp(
Math.min(usableWidth / (maxChars * 0.62), (radius * 1.05) / lines.length),
0.05,
0.2
);
const pixelsPerUnit = Math.hypot(matrix[0], matrix[1]) / this.dpr;
ctx.save();
ctx.setTransform(
this.dpr * matrix[0],
this.dpr * matrix[1],
this.dpr * matrix[2],
this.dpr * matrix[3],
this.dpr * matrix[4],
this.dpr * matrix[5]
);
this._pathFromPoints(this.plate.points.map(([x, y]) => ({ x, y })));
ctx.clip();
// Shrink to fit if the monospace metrics run wide of the plate.
for (let attempt = 0; attempt < 3; attempt += 1) {
ctx.font = `700 ${fontSize}px ${FONT_STACK}`;
const widest = Math.max(...lines.map((line) => ctx.measureText(line).width));
if (widest <= usableWidth) break;
fontSize *= usableWidth / widest;
}
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.globalAlpha = this.answerAlpha;
ctx.fillStyle = '#f2f7ff';
ctx.shadowColor = rgbCss(this.toneRgb, 0.85);
ctx.shadowBlur = Math.max(2, fontSize * pixelsPerUnit * 0.45);
const lineHeight = fontSize * 1.3;
const top = -((lines.length - 1) * lineHeight) / 2;
lines.forEach((line, index) => {
ctx.fillText(line, 0, top + index * lineHeight);
});
ctx.restore();
}
}
/* ─── helpers (exported for tests) ─── */
function boundsOf(points) {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const point of points) {
if (point.x < minX) minX = point.x;
if (point.x > maxX) maxX = point.x;
if (point.y < minY) minY = point.y;
if (point.y > maxY) maxY = point.y;
}
return { minX, minY, maxX, maxY };
}
/**
* Sutherland–Hodgman clip of a convex polygon against the half-plane below the line
* a→b. Screen space has y pointing down, so "below the surface line" is a positive
* cross product.
*/
export function clipPolygonBelow(points, a, b) {
const out = [];
const side = (p) => (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
for (let i = 0; i < points.length; i += 1) {
const current = points[i];
const next = points[(i + 1) % points.length];
const currentSide = side(current);
const nextSide = side(next);
if (currentSide >= 0) out.push(current);
if (currentSide >= 0 !== nextSide >= 0) {
const t = currentSide / (currentSide - nextSide);
out.push({
x: current.x + (next.x - current.x) * t,
y: current.y + (next.y - current.y) * t
});
}
}
return out;
}
/** Upper-cases a verdict and wraps it into at most three plate-sized lines. */
export function wrapAnswer(text) {
const words = String(text || 'Cannot predict now')
.toUpperCase()
.replace(/\s+/g, ' ')
.trim()
.split(' ')
.filter(Boolean);
if (!words.length) return ['CANNOT', 'PREDICT'];
const lines = [];
let current = '';
for (const word of words) {
const candidate = current ? `${current} ${word}` : word;
if (!current || candidate.length <= 11) {
current = candidate;
} else {
lines.push(current);
current = word;
}
}
if (current) lines.push(current);
if (lines.length > 3) {
return [...lines.slice(0, 2), lines.slice(2).join(' ').slice(0, 14)];
}
return lines;
}