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;
|
if (dot(face.normal, VIEW) < 0.3) return;
|
||||||
|
|
||||||
// The face keeps the plate's local vertex order, so three corners are enough to
|
// 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.
|
// solve the affine transform from text space into screen space.
|
||||||
const corners = [0, 3, 5].map((index) => ({
|
const matrix = inkTransform(this.plate.points, face.points);
|
||||||
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;
|
if (!matrix) return;
|
||||||
|
|
||||||
const lines = this.answerLines;
|
const lines = this.answerLines;
|
||||||
|
|
@ -615,7 +611,9 @@ export class OracleBall {
|
||||||
this.dpr * matrix[4],
|
this.dpr * matrix[4],
|
||||||
this.dpr * matrix[5]
|
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();
|
ctx.clip();
|
||||||
|
|
||||||
// Shrink to fit if the monospace metrics run wide of the plate.
|
// Shrink to fit if the monospace metrics run wide of the plate.
|
||||||
|
|
@ -643,6 +641,26 @@ export class OracleBall {
|
||||||
|
|
||||||
/* ─── helpers (exported for tests) ─── */
|
/* ─── 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) {
|
function boundsOf(points) {
|
||||||
let minX = Infinity;
|
let minX = Infinity;
|
||||||
let minY = Infinity;
|
let minY = Infinity;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { clipPolygonBelow, wrapAnswer } from '../public/js/ball3d.js';
|
import { clipPolygonBelow, inkTransform, wrapAnswer } from '../public/js/ball3d.js';
|
||||||
import {
|
import {
|
||||||
affineFromThreePoints,
|
affineFromThreePoints,
|
||||||
clamp,
|
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', () => {
|
test('clipPolygonBelow keeps only the part under the surface line', () => {
|
||||||
const square = [
|
const square = [
|
||||||
{ x: 0, y: 0 },
|
{ x: 0, y: 0 },
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue