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:
parent
455181290f
commit
a8dfdbcb60
2 changed files with 63 additions and 8 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue