diff --git a/public/js/ball3d.js b/public/js/ball3d.js index 54ce86a..bbe293c 100644 --- a/public/js/ball3d.js +++ b/public/js/ball3d.js @@ -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; diff --git a/test/frontend.test.mjs b/test/frontend.test.mjs index 2baca22..4c054de 100644 --- a/test/frontend.test.mjs +++ b/test/frontend.test.mjs @@ -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 },