// A mark the brush left is a place ON THE PHOTO. Turn the frame and it has to // turn with it, or the repair slides off its mote and the mote is uncovered // again (shared/utils/orientation.ts). The transform is one line of arithmetic // per mark and none of it throws when it is wrong — it just puts the spot a // little to the side — so it is measured here against the renderer's own moves // written a second time in PIXELS: rotateSkImage90's (x, y) -> (H - y, x) and // flipSkImage's mirror about each axis, undone and redone around every pair of // orientations, on every corner of the frame and both photo shapes. // // node scripts/orientation-check.mjs import assert from 'node:assert/strict'; import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import ts from 'typescript'; const transpile = (path) => ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, }).outputText; const dir = mkdtempSync(join(tmpdir(), 'orientation-check-')); writeFileSync(join(dir, 'orientation.mjs'), transpile('../shared/utils/orientation.ts')); const { layMarks } = await import(pathToFileURL(join(dir, 'orientation.mjs')).href); const ROTS = [0, 90, 180, 270]; const ORI = []; for (const rot of ROTS) for (const flipH of [false, true]) for (const flipV of [false, true]) ORI.push({ rot, flipH, flipV }); // The renderer, a second time and in pixels — this is the reference the module // under test has to agree with, not a call into it. // A quarter turn clockwise: a W x H frame comes out H x W, and the pixel that // was at (x, y) is at (H - y, x) — see rotateSkImage90's translate(h, 0) / // rotate(90) pair. const turnPx = (p, W, H) => ({ x: H - p.y, y: p.x, W: H, H: W }); const unturnPx = (p, W, H) => ({ x: p.y, y: W - p.x, W: H, H: W }); // the inverse of the above const flipPx = (p, flipH, flipV, W, H) => ({ x: flipH ? W - p.x : p.x, y: flipV ? H - p.y : p.y, W, H }); // Where a mark drawn on `from`'s frame lands on `to`'s frame, worked out in // pixels: off the old frame, through the photo as it arrived, onto the new one. // The radius is a length of the photo, so it is carried in pixels too and read // back out as a fraction of the width it ends up under. The order is the // renderer's read backwards and then forwards again — the mirrors come off // first because they go on last. function reference(spot, from, to, photoW, photoH) { const fromW = from.rot % 180 === 0 ? photoW : photoH; const fromH = from.rot % 180 === 0 ? photoH : photoW; let p = { x: spot.x * fromW, y: spot.y * fromH, W: fromW, H: fromH }; const rad = spot.r * fromW; p = flipPx(p, from.flipH, from.flipV, p.W, p.H); for (let i = 0; i < (from.rot / 90) % 4; i++) p = unturnPx(p, p.W, p.H); for (let i = 0; i < (to.rot / 90) % 4; i++) p = turnPx(p, p.W, p.H); p = flipPx(p, to.flipH, to.flipV, p.W, p.H); return { x: p.x / p.W, y: p.y / p.H, r: rad / p.W }; } const near = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-9, `${msg}: ${a} vs ${b}`); // An ellipse is the same ellipse at angle and angle + pi, and the quarter turns // land a full turn apart when the frame starts at 270 (270 -> 0 is +90, read as // a -270 the long way round). So angles are compared where they mean something. const nearAngle = (a, b, msg) => { const d = (((a - b) % Math.PI) + Math.PI) % Math.PI; assert.ok(Math.min(d, Math.PI - d) < 1e-9, `${msg}: ${a} vs ${b}`); }; let checked = 0; for (const [photoW, photoH] of [ [1600, 1200], [1200, 1600], ]) { for (const from of ORI) { for (const to of ORI) { for (const spot of [ { x: 0.13, y: 0.71, r: 0.012 }, { x: 0.5, y: 0.5, r: 0.05 }, { x: 0, y: 0, r: 0.5 }, { x: 1, y: 1, r: 0.001 }, ]) { const want = reference(spot, from, to, photoW, photoH); const got = layMarks({ heal: [spot] }, from, to, { w: photoW, h: photoH }).heal[0]; const tag = `${photoW}x${photoH} ${JSON.stringify(from)} -> ${JSON.stringify(to)} ${JSON.stringify(spot)}`; near(got.x, want.x, `x ${tag}`); near(got.y, want.y, `y ${tag}`); near(got.r, want.r, `r ${tag}`); checked++; } } } } console.log(`spots: ${checked} carried, all on their own photo`); // The two things a quarter turn promises in words: the spot is still the same // number of PIXELS across, and a round trip hands the frame back. const spot = { x: 0.31, y: 0.62, r: 0.02 }; const q = layMarks({ heal: [spot] }, { rot: 0, flipH: false, flipV: false }, { rot: 90, flipH: false, flipV: false }, { w: 1600, h: 1200 }).heal[0]; near(q.r * 1200, spot.r * 1600, 'a 90 degree turn keeps the spot the same width'); near(q.x, 1 - spot.y, '90 CW puts x where 1 - y was'); near(q.y, spot.x, '90 CW puts y where x was'); const back = layMarks({ heal: [q] }, { rot: 90, flipH: false, flipV: false }, { rot: 0, flipH: false, flipV: false }, { w: 1600, h: 1200 }).heal[0]; near(back.x, spot.x, 'four turns come home'); near(back.y, spot.y, 'four turns come home'); near(back.r, spot.r, 'four turns come home'); // Masks: the same two points, the semi-axes rescaled like a radius, and the // ellipse's angle turned with the frame — a mirror is a sign flip, an odd turn // is a quarter of π. const radial = { kind: 'radial', x: 0.2, y: 0.3, ex: 0.4, ey: 0.5, rx: 0.1, ry: 0.05, angle: 0, feather: 0.5, exposure: 1, contrast: 0, saturation: 0 }; const m90 = layMarks({ masks: [radial] }, { rot: 0, flipH: false, flipV: false }, { rot: 90, flipH: false, flipV: false }, { w: 1600, h: 1200 }).masks[0]; nearAngle(m90.angle, Math.PI / 2, '90 degrees turns the ellipse a quarter of pi'); near(m90.x, 1 - radial.y, 'the mask centre turns with the frame'); near(m90.rx * 1200, radial.rx * 1600, 'a semi-axis is a width like any other'); const m270 = layMarks({ masks: [radial] }, { rot: 270, flipH: false, flipV: false }, { rot: 0, flipH: false, flipV: false }, { w: 1600, h: 1200 }).masks[0]; nearAngle(m270.angle, Math.PI / 2, 'a quarter turn is a quarter turn whatever the frame was on'); const mFlip = layMarks({ masks: [radial] }, { rot: 0, flipH: false, flipV: false }, { rot: 0, flipH: true, flipV: false }, { w: 1600, h: 1200 }).masks[0]; nearAngle(mFlip.angle, -radial.angle, 'a mirror reflects the ellipse'); near(mFlip.x, 1 - radial.x, 'a mirror reflects the centre'); const mBoth = layMarks({ masks: [radial] }, { rot: 0, flipH: true, flipV: false }, { rot: 0, flipH: true, flipV: true }, { w: 1600, h: 1200 }).masks[0]; nearAngle(mBoth.angle, radial.angle, 'two mirrors are a half turn, and that is no turn for an ellipse'); // Nothing moves when nothing moved, and the marks of a look that has none stay // absent rather than becoming empty lists the renderer would build a pass for. const empty = { exposureCompensation: 0 }; assert.equal(layMarks(empty, ORI[0], ORI[0], { w: 1600, h: 1200 }), empty, 'a still frame hands the same object back'); const carried = layMarks({ ...empty, heal: [spot] }, ORI[0], ORI[5], { w: 1600, h: 1200 }); assert.equal(carried.mosaic, undefined, 'a look without hidden patches gains none'); assert.equal(carried.masks, undefined, 'a look without masks gains none'); assert.equal(layMarks({ ...empty, heal: [spot] }, ORI[0], ORI[5], null).heal[0], spot, 'nothing is carried before the photo is known'); console.log('VERDICT orientation: ok');