fix(studio): a dust spot is a place on the photo, so the angle carries it too

This commit is contained in:
2026-10-13 07:08:28 +07:00
parent 66f044822e
commit b37ec97352
3 changed files with 177 additions and 64 deletions
+68 -8
View File
@@ -25,8 +25,9 @@ writeFileSync(join(dir, 'orientation.mjs'), transpile('../shared/utils/orientati
const { layMarks } = await import(pathToFileURL(join(dir, 'orientation.mjs')).href);
const ROTS = [0, 90, 180, 270];
const STRAIGHTENS = [0, 12, -25]; // the free angle is a drag through every degree; three are enough to cross the map
const ORI = [];
for (const rot of ROTS) for (const flipH of [false, true]) for (const flipV of [false, true]) ORI.push({ rot, flipH, flipV });
for (const rot of ROTS) for (const flipH of [false, true]) for (const flipV of [false, true]) for (const straighten of STRAIGHTENS) ORI.push({ rot, straighten, 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.
@@ -37,6 +38,27 @@ 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 });
// STRAIGHTEN, in pixels: the content turns about the centre of the frame (y down,
// so a positive angle is clockwise on screen) and is scaled by the cover factor —
// the axis-aligned wrap of the turned frame over the frame, so the corners stay
// covered. The frame's sides do not change; the radii scale with the content.
// Rotations about the centre commute with the quarter turn, which is what lets
// this sit between the turns and the mirrors in either direction.
const coverPx = (W, H, deg) => {
const a = (deg * Math.PI) / 180;
const c = Math.abs(Math.cos(a));
const s = Math.abs(Math.sin(a));
return Math.max((W * c + H * s) / W, (W * s + H * c) / H);
};
const straightenPx = (p, deg, scale) => {
const a = (deg * Math.PI) / 180;
const c = Math.cos(a);
const s = Math.sin(a);
const dx = p.x - p.W / 2;
const dy = p.y - p.H / 2;
return { x: p.W / 2 + scale * (dx * c - dy * s), y: p.H / 2 + scale * (dx * s + dy * c), W: p.W, H: p.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
@@ -47,10 +69,20 @@ 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;
let rad = spot.r * fromW;
p = flipPx(p, from.flipH, from.flipV, p.W, p.H);
if (from.straighten) {
const k = coverPx(p.W, p.H, from.straighten);
p = straightenPx(p, -from.straighten, 1 / k);
rad /= k;
}
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);
if (to.straighten) {
const k = coverPx(p.W, p.H, to.straighten);
p = straightenPx(p, to.straighten, k);
rad *= k;
}
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 };
}
@@ -93,30 +125,58 @@ 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];
const level = (rot, flipH = false, flipV = false, straighten = 0) => ({ rot, straighten, flipH, flipV });
const q = layMarks({ heal: [spot] }, level(0), level(90), { 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];
const back = layMarks({ heal: [q] }, level(90), level(0), { 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');
// STRAIGHTEN: the same promise, and the one the ruler makes while the user is
// still dragging it — a mark dead centre of the frame is a mark the angle cannot
// move, the frame's sides hold, and the drag hands the frame back when it comes
// home. The two sides here are the frame's own, which is what the cover factor
// is read against.
const PHOTO = { w: 1600, h: 1200 };
const centre = { x: 0.5, y: 0.4, r: 0.01 };
const angled = layMarks({ heal: [centre] }, level(0), level(0, false, false, 10), PHOTO).heal[0];
const onCentre = layMarks({ heal: [{ x: 0.5, y: 0.5, r: 0.01 }] }, level(0), level(0, false, false, 10), PHOTO).heal[0];
near(onCentre.x, 0.5, 'a mark on the centre of the frame cannot slide off it');
near(onCentre.y, 0.5, 'a mark on the centre of the frame cannot slide off it');
near(angled.r, centre.r * coverPx(1600, 1200, 10), 'the angle scales the radii with the content it covers');
const squared = layMarks({ heal: [centre] }, level(0), level(0, false, false, 90), PHOTO).heal[0];
near(squared.r, centre.r * coverPx(1600, 1200, 90), 'a straighten of a quarter circle covers by the wrap it makes');
const unangled = layMarks({ heal: [angled] }, level(0, false, false, 10), level(0), PHOTO).heal[0];
near(unangled.x, centre.x, 'the angle comes home');
near(unangled.y, centre.y, 'the angle comes home');
near(unangled.r, centre.r, 'the angle comes home');
const rolled = layMarks({ heal: [{ ...centre, x: 0.5, y: 0.5 }] }, level(0, false, false, 10), level(0, false, false, -30), PHOTO).heal[0];
near(rolled.x, 0.5, 'two angles on one frame still cannot move the middle');
near(rolled.y, 0.5, 'two angles on one frame still cannot move the middle');
near(rolled.r, centre.r * (coverPx(1600, 1200, -30) / coverPx(1600, 1200, 10)), 'a straighten off another is a scale off the other');
// 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];
const m90 = layMarks({ masks: [radial] }, level(0), level(90), PHOTO).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];
const m270 = layMarks({ masks: [radial] }, level(270), level(0), PHOTO).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];
const mFlip = layMarks({ masks: [radial] }, level(0), level(0, true), PHOTO).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];
const mBoth = layMarks({ masks: [radial] }, level(0, true), level(0, true, true), PHOTO).masks[0];
nearAngle(mBoth.angle, radial.angle, 'two mirrors are a half turn, and that is no turn for an ellipse');
const mAngled = layMarks({ masks: [radial] }, level(0), level(0, false, false, 30), PHOTO).masks[0];
nearAngle(mAngled.angle, (30 * Math.PI) / 180, 'the angle turns the ellipse with the content it turns');
const mTurnedAngle = layMarks({ masks: [radial] }, level(0, false, false, 30), level(90, false, false, 30), PHOTO).masks[0];
nearAngle(mTurnedAngle.angle, radial.angle + Math.PI / 2, 'a turn and an angle together are the two of them together');
// 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.