fix(ui): ink the verdict upright on the die face

The plate is authored y-up while canvas text is laid out y-down, so the affine
solved from the raw local corners had a negative determinant: every verdict drew
mirrored, with the line order reversed, in idle and reveal alike.

Solve the transform in a y-down text space instead, flip the clip to match, and
cover the orientation with a regression test.
This commit is contained in:
katR 2026-09-22 21:59:49 +05:30
parent 455181290f
commit a8dfdbcb60
2 changed files with 63 additions and 8 deletions

View file

@ -586,12 +586,8 @@ export class OracleBall {
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]);
// solve the affine transform from text space into screen space.
const matrix = inkTransform(this.plate.points, face.points);
if (!matrix) return;
const lines = this.answerLines;
@ -615,7 +611,9 @@ export class OracleBall {
this.dpr * matrix[4],
this.dpr * matrix[5]
);
this._pathFromPoints(this.plate.points.map(([x, y]) => ({ x, y })));
// The clip lives in the same y-down text space as the ink, so its y axis is flipped
// to match the transform above.
this._pathFromPoints(this.plate.points.map(([x, y]) => ({ x, y: -y })));
ctx.clip();
// Shrink to fit if the monospace metrics run wide of the plate.
@ -643,6 +641,26 @@ export class OracleBall {
/* ─── helpers (exported for tests) ─── */
/**
* Affine map from canvas text space onto a projected plate face, returned as
* [a, b, c, d, e, f] for `setTransform`.
*
* The plate is authored y-up, but canvas text is laid out y-down. Solving the affine
* against the raw local points therefore mirrors the basis (negative determinant), which
* renders the ink upside down and reverses the line order. Negating the local y axis
* first means the result consumes ordinary text coordinates and draws upright glyphs.
*
* @param {number[][]} platePoints face-local [x, y] pairs, in face winding order
* @param {{x: number, y: number}[]} facePoints projected screen points, same order
*/
export function inkTransform(platePoints, facePoints) {
const corners = [0, 3, 5].map((index) => ({
local: [platePoints[index][0], -platePoints[index][1]],
screen: [facePoints[index].x, facePoints[index].y]
}));
return affineFromThreePoints(corners[0], corners[1], corners[2]);
}
function boundsOf(points) {
let minX = Infinity;
let minY = Infinity;

View file

@ -5,7 +5,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { clipPolygonBelow, wrapAnswer } from '../public/js/ball3d.js';
import { clipPolygonBelow, inkTransform, wrapAnswer } from '../public/js/ball3d.js';
import {
affineFromThreePoints,
clamp,
@ -159,6 +159,43 @@ test('plate mesh exposes an ordered answer face for text projection', () => {
});
});
test('inkTransform keeps verdict glyphs upright on a face-on plate', () => {
const plate = createPlate();
const answer = plate.mesh.faces.find((face) => face.kind === 'answer');
// A face-on plate: local x runs right, local y (up) runs up the screen, so it maps to
// negative screen y because canvas y grows downwards.
const scale = 100;
const centre = 172;
const facePoints = answer.indices.map((index) => {
const local = plate.mesh.vertices[index];
return { x: centre + local[0] * scale, y: centre - local[1] * scale };
});
const matrix = inkTransform(plate.points, facePoints);
assert.ok(matrix, 'the text transform must be solvable');
// A mirrored basis (negative determinant) renders every glyph upside down — the exact
// bug this helper exists to prevent.
const determinant = matrix[0] * matrix[3] - matrix[1] * matrix[2];
assert.ok(determinant > 0, `text basis is mirrored: det = ${determinant}`);
// Text "right" and text "down" must travel right and down the screen respectively.
assert.ok(matrix[0] > 0 && near(matrix[1], 0), `right drifts: (${matrix[0]}, ${matrix[1]})`);
assert.ok(matrix[3] > 0 && near(matrix[2], 0), `down drifts: (${matrix[2]}, ${matrix[3]})`);
// Successive lines are laid out at increasing y, so line two must land below line one.
const origin = { x: matrix[4], y: matrix[5] };
const secondLine = { x: matrix[2] + matrix[4], y: matrix[3] + matrix[5] };
assert.ok(secondLine.y > origin.y, 'later lines must render below earlier ones');
assert.equal(secondLine.y - origin.y, scale, 'one text unit must scale uniformly');
// Collinear local corners cannot define a basis, so the solve must fail loudly rather
// than hand the renderer a garbage transform.
const collinear = Array.from({ length: 6 }, (_, index) => [index, 0]);
assert.equal(inkTransform(collinear, facePoints), null);
});
test('clipPolygonBelow keeps only the part under the surface line', () => {
const square = [
{ x: 0, y: 0 },