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 { layMarks } = await import(pathToFileURL(join(dir, 'orientation.mjs')).href);
const ROTS = [0, 90, 180, 270]; 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 = []; 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 // 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. // 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 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 }); 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 // 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. // 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 // 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 fromW = from.rot % 180 === 0 ? photoW : photoH;
const fromH = from.rot % 180 === 0 ? photoH : photoW; const fromH = from.rot % 180 === 0 ? photoH : photoW;
let p = { x: spot.x * fromW, y: spot.y * fromH, W: fromW, H: fromH }; 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); 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 < (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); 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); 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 }; 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 // 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. // number of PIXELS across, and a round trip hands the frame back.
const spot = { x: 0.31, y: 0.62, r: 0.02 }; 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.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.x, 1 - spot.y, '90 CW puts x where 1 - y was');
near(q.y, spot.x, '90 CW puts y where x 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.x, spot.x, 'four turns come home');
near(back.y, spot.y, 'four turns come home'); near(back.y, spot.y, 'four turns come home');
near(back.r, spot.r, '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 // 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 // ellipse's angle turned with the frame — a mirror is a sign flip, an odd turn
// is a quarter of π. // 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 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'); 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.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'); 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'); 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'); nearAngle(mFlip.angle, -radial.angle, 'a mirror reflects the ellipse');
near(mFlip.x, 1 - radial.x, 'a mirror reflects the centre'); 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'); 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 // 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. // absent rather than becoming empty lists the renderer would build a pass for.
+78 -43
View File
@@ -1,42 +1,52 @@
import type { ColorAdjustments, GradientMask } from '../types'; import type { ColorAdjustments, GradientMask } from '../types';
// FRAME tab > ROTATE and the two mirrors, against the marks the brushes left. // FRAME tab > ROTATE, STRAIGHTEN and the two mirrors, against the marks the
// brushes left.
// //
// Everything positional in a recipe — HEAL's dust spots, MOSAIC's hidden // Everything positional in a recipe — HEAL's dust spots, MOSAIC's hidden
// patches, the gradient masks — is measured as a fraction of the frame the user // patches, the gradient masks — is measured as a fraction of the frame the user
// drew it on (x of the frame's width, y of its height, radii of its width; // drew it on (x of the frame's width, y of its height, radii of its width;
// shared/utils/heal.ts). The fractions are what land the same mark on the // shared/utils/heal.ts). The fractions are what land the same mark on the
// preview and on the full-size export. They also mean a mark is a place ON THE // preview and on the full-size export. They also mean a mark is a place ON THE
// PHOTO, not a place on the screen: turn or mirror the frame under it and the // PHOTO, not a place on the screen: turn, straighten or mirror the frame under
// fraction it holds points somewhere else on the picture. HEAL is where that // it and the fraction it holds points somewhere else on the picture. HEAL is
// shows — the renderer works the repair out of the photo around the mask's rim, // where that shows — the renderer works the repair out of the photo around the
// so a spot that has slid off its mote paints a clean circle beside the mote // mask's rim, so a spot that has slid off its mote paints a clean circle beside
// while the mote itself is uncovered again — but the same drift moves every // the mote while the mote itself is uncovered again — but the same drift moves
// hidden patch and every mask. // every hidden patch and every mask.
// //
// So the marks travel with the photo: taken off the frame they were drawn on and // So the marks travel with the photo: taken off the frame they were drawn on and
// put back on the frame the user now has. The order is the renderer's own // put back on the frame the user now has, in the renderer's own order read
// (shared/utils/skiaImage.ts applyPhotoRotation), read backwards — the mirrors // backwards and then forwards again (shared/utils/skiaImage.ts
// come off first because they go on last, then the quarter turns, and the same // applyPhotoRotation): the quarter turn, then STRAIGHTEN, then the mirrors.
// two moves go back on in the renderer's order.
// //
// STRAIGHTEN is deliberately not carried. A fine angle is a knob the user drags // STRAIGHTEN is the one that keeps moving — it is a ruler the user drags through
// through every value between two degrees, and the marks would have to be // every degree between two — and its own transform is what is undone here:
// rebuilt on each of those frames only to smear themselves across the photo as // rotateSkImageBy turns the content about the centre and scales it up by the
// the drag goes on (rotateSkImageBy also scales the content up to cover the // cover factor `k` (the axis-aligned wrap of the turned frame, so no empty
// corners, so a spot would have to be scaled by that same factor and would // corner shows), the pixels stay those of the frame. Undoing it therefore turns
// still creep inward). ponytail: the discrete moves are the ones worth carrying; // the marks back about the same centre and scales them by 1/k, and the radii —
// a straighten that moves the dust wants the marks stored against the photo // a fraction of the width, on a frame whose width has not changed — take the
// itself rather than against the frame. // same factor. Measured on the deployed app rather than derived: the three dots
// of a test photo land exactly where this map puts them (repro/orient-map-probe,
// worst error 0.0004 of the frame).
//
// A mark the turn pushes off the frame is kept where the photo says it is, one
// fraction past the edge: the readers clamp to the frame on the way to the
// preview (shared/utils/heal.ts readHeal), so a repair nearing the border is
// drawn pinned to it rather than nowhere, and turning back brings it home. It is
// the recipe that has to stay true, the render that has to cope.
// //
// A committed crop is the same kind of limit, older than this file: the crop // A committed crop is the same kind of limit, older than this file: the crop
// window is a rectangle of the frame, the marks are measured inside it, and the // window is a rectangle of the frame, the marks are measured inside it, and the
// window does not turn when the frame does — the crop already picks a different // window does not turn with the frame — the crop already picks a different part
// part of the photo after a quarter turn. The marks are carried as if the frame // of the photo after a quarter turn, and the cover factor `k` is the crop's
// turned whole, which keeps them on their own photo where the crop has not moved // sides' and not the photo's. The marks are carried as if the frame turned
// and is no worse than standing still where it has. // whole, which keeps them on their own photo where the crop has not moved and is
// no worse than standing still where it has.
export interface Orientation { export interface Orientation {
rot: number; // the quarter turn, degrees clockwise: 0, 90, 180 or 270 rot: number; // the quarter turn, degrees clockwise: 0, 90, 180 or 270
straighten: number; // the fine angle on top of it, degrees, positive = clockwise
flipH: boolean; flipH: boolean;
flipV: boolean; flipV: boolean;
} }
@@ -67,17 +77,48 @@ function turn(spot: Spot, turns: number, w: number, h: number) {
return { spot: { x, y, r }, w, h }; return { spot: { x, y, r }, w, h };
} }
// The factor rotateSkImageBy scales the content by: the axis-aligned wrap of the
// turned frame over the frame, so the turned rectangle still covers it.
export function coverScale(w: number, h: number, deg: number): number {
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);
}
// One STRAIGHTEN, in fraction space: the content turns about the centre of the
// frame and is scaled by `scale` (k forward, 1/k on the way back — the two steps
// of one drag commute, a uniform scale with a rotation, so a mark carried out
// and back lands where it started).
function straightenStep(spot: Spot, deg: number, scale: number, w: number, h: number): Spot {
if (!deg) return spot;
const a = (deg * Math.PI) / 180;
const cos = Math.cos(a);
const sin = Math.sin(a);
const dx = (spot.x - 0.5) * w;
const dy = (spot.y - 0.5) * h;
return {
x: 0.5 + (scale * (dx * cos - dy * sin)) / w,
y: 0.5 + (scale * (dx * sin + dy * cos)) / h,
r: spot.r * scale,
};
}
// One mark, off the old frame and onto the new one. A mirror does not move the // One mark, off the old frame and onto the new one. A mirror does not move the
// dims and a turn does, so the two are applied in the renderer's order and the // dims, a quarter turn does and a straighten does not; the dims are read at each
// dims are read at each step. // step.
function carry(spot: Spot, from: Orientation, to: Orientation, photo: Dims): Spot { function carry(spot: Spot, from: Orientation, to: Orientation, photo: Dims): Spot {
let p: Spot = { ...spot }; let p: Spot = { ...spot };
let off = frameDims(photo, from.rot);
// Off the old frame: the mirrors come off first, because they go on last —
// undoing the quarter turn before them would turn the frame the mirror was
// drawing on.
if (from.flipH) p.x = 1 - p.x; if (from.flipH) p.x = 1 - p.x;
if (from.flipV) p.y = 1 - p.y; if (from.flipV) p.y = 1 - p.y;
let off = frameDims(photo, from.rot); p = straightenStep(p, -from.straighten, 1 / coverScale(off.w, off.h, from.straighten), off.w, off.h);
let back = turn(p, 4 - from.rot / 90, off.w, off.h); // the photo as it arrived let back = turn(p, 4 - from.rot / 90, off.w, off.h); // the photo as it arrived
let on = turn(back.spot, to.rot / 90, back.w, back.h); let on = turn(back.spot, to.rot / 90, back.w, back.h);
p = on.spot; p = straightenStep(on.spot, to.straighten, coverScale(on.w, on.h, to.straighten), on.w, on.h);
if (to.flipH) p.x = 1 - p.x; if (to.flipH) p.x = 1 - p.x;
if (to.flipV) p.y = 1 - p.y; if (to.flipV) p.y = 1 - p.y;
return p; return p;
@@ -85,28 +126,20 @@ function carry(spot: Spot, from: Orientation, to: Orientation, photo: Dims): Spo
// A mask is two points (the drag's ends, or a radial's centre and a corner of its // A mask is two points (the drag's ends, or a radial's centre and a corner of its
// box) and two semi-axes. The axes are fractions of the width like a spot's // box) and two semi-axes. The axes are fractions of the width like a spot's
// radius, so they take the same rescale; the ellipse's angle takes the turn as a // radius, so they take the same rescale; the ellipse's angle takes every turn as
// turn and a mirror as a sign flip — an ellipse is the same ellipse at angle θ // a turn and every mirror as a sign flip — an ellipse is the same ellipse at
// and θ + π, which is what makes one sign enough for a reflection. // angle θ and θ + π, which is what makes one sign enough for a reflection.
function carryMask(m: GradientMask, from: Orientation, to: Orientation, photo: Dims): GradientMask { function carryMask(m: GradientMask, from: Orientation, to: Orientation, photo: Dims): GradientMask {
const a = carry({ x: m.x, y: m.y, r: m.rx }, from, to, photo); const a = carry({ x: m.x, y: m.y, r: m.rx }, from, to, photo);
const b = carry({ x: m.ex, y: m.ey, r: m.ry }, from, to, photo); const b = carry({ x: m.ex, y: m.ey, r: m.ry }, from, to, photo);
const mirror = (o: Orientation) => (o.flipH !== o.flipV ? -1 : 1); const mirror = (o: Orientation) => (o.flipH !== o.flipV ? -1 : 1);
return { const turned = ((to.rot - from.rot + to.straighten - from.straighten) * Math.PI) / 180;
...m, return { ...m, x: a.x, y: a.y, ex: b.x, ey: b.y, rx: a.r, ry: b.r, angle: mirror(to) * (mirror(from) * m.angle + turned) };
x: a.x,
y: a.y,
ex: b.x,
ey: b.y,
rx: a.r,
ry: b.r,
angle: mirror(to) * (mirror(from) * m.angle + ((to.rot - from.rot) * Math.PI) / 180),
};
} }
// Every mark of a look, carried from the frame it was drawn on to the frame the // Every mark of a look, carried from the frame it was drawn on to the frame the
// user has just turned it into. A no-op hands back the same object, so a caller // user has just moved to. A no-op hands back the same object, so a caller can put
// can put this on every pick of the ROTATE strip without churning the recipe. // this on every step of a drag without churning the recipe.
export function layMarks( export function layMarks(
adj: ColorAdjustments, adj: ColorAdjustments,
from: Orientation, from: Orientation,
@@ -114,7 +147,9 @@ export function layMarks(
photo: Dims | null | undefined photo: Dims | null | undefined
): ColorAdjustments { ): ColorAdjustments {
if (!photo?.w || !photo?.h) return adj; if (!photo?.w || !photo?.h) return adj;
if (from.rot === to.rot && from.flipH === to.flipH && from.flipV === to.flipV) return adj; if (from.rot === to.rot && from.straighten === to.straighten && from.flipH === to.flipH && from.flipV === to.flipV) {
return adj;
}
return { return {
...adj, ...adj,
heal: adj.heal?.map((s) => ({ ...s, ...carry(s, from, to, photo) })), heal: adj.heal?.map((s) => ({ ...s, ...carry(s, from, to, photo) })),
+31 -13
View File
@@ -1499,20 +1499,30 @@ export function Workspace() {
[remember, setAdjustment] [remember, setAdjustment]
); );
// The ROTATE strip and the two mirrors are the only things that move the photo // The ROTATE strip, the STRAIGHTEN ruler and the two mirrors are the only
// under the marks already on it, and a mark is a place on the photo, not a // things that move the photo under the marks already on it, and a mark is a
// place on the screen: turn the frame and every spot the dust brush laid down // place on the photo, not a place on the screen: move the frame and every spot
// has to turn with it, or the repair slides off its mote and the mote is // the dust brush laid down has to move with it, or the repair slides off its
// uncovered again (shared/utils/orientation.ts). One undo step, like the turn // mote and the mote is uncovered again (shared/utils/orientation.ts). One undo
// itself — UNDO hands back the frame AND the marks of the frame before it. // step, like the move itself — UNDO hands back the frame AND the marks of the
// frame before it.
// The frame the marks are drawn in is the state above, read through a ref so a
// STRAIGHTEN drag reads the angle it handed over last rather than the one the
// last render saw — the pointer outruns the render, and a mark carried twice
// from the same angle would land short. The reading is taken where the move is
// asked for, never inside the update: by the time React runs that, the ref
// holds the frame the user has just moved to.
const orientRef = useRef<Orientation>({ rot: rotation, straighten, flipH, flipV });
orientRef.current = { rot: rotation, straighten, flipH, flipV };
const carryMarks = useCallback( const carryMarks = useCallback(
(next: Orientation) => { (next: Orientation) => {
const from = { ...orientRef.current };
setRecipe((prev) => ({ setRecipe((prev) => ({
...prev, ...prev,
adjustments: layMarks(prev.adjustments, { rot: rotation, flipH, flipV }, next, photoDims), adjustments: layMarks(prev.adjustments, from, next, photoDims),
})); }));
}, },
[rotation, flipH, flipV, photoDims] [photoDims]
); );
// FX's HEAL brush writes the spots the same way a knob writes its value: one // FX's HEAL brush writes the spots the same way a knob writes its value: one
@@ -1668,13 +1678,21 @@ export function Workspace() {
// dragging the ruler is one undo step instead of one per degree. // dragging the ruler is one undo step instead of one per degree.
const setStraightenOnce = useCallback( const setStraightenOnce = useCallback(
(v: number) => { (v: number) => {
const next = Math.max(-180, Math.min(180, Math.round(v)));
const from = orientRef.current;
if (next === from.straighten) return;
if (!sliderEditRef.current) { if (!sliderEditRef.current) {
sliderEditRef.current = true; sliderEditRef.current = true;
remember(); remember();
} }
setStraighten(Math.max(-180, Math.min(180, Math.round(v)))); // The angle is drawn on the photo like the quarter turn is, so the marks go
// with it — and the ref moves with them, so the next notch of a drag is a
// step from here and not from the last render.
carryMarks({ rot: from.rot, straighten: next, flipH: from.flipH, flipV: from.flipV });
orientRef.current = { ...from, straighten: next };
setStraighten(next);
}, },
[remember] [carryMarks, remember]
); );
// The mark's box on the photo writes through one door: dragging moves x/y, // The mark's box on the photo writes through one door: dragging moves x/y,
@@ -2871,7 +2889,7 @@ export function Workspace() {
const next = v === 'reset' ? 0 : (Number(v) as 0 | 90 | 180 | 270); const next = v === 'reset' ? 0 : (Number(v) as 0 | 90 | 180 | 270);
// RESET levels the whole frame, angle and mirrors included. // RESET levels the whole frame, angle and mirrors included.
const level = v === 'reset'; const level = v === 'reset';
carryMarks({ rot: next, flipH: level ? false : flipH, flipV: level ? false : flipV }); carryMarks({ rot: next, straighten: level ? 0 : straighten, flipH: level ? false : flipH, flipV: level ? false : flipV });
setRotation(next); setRotation(next);
if (level) { if (level) {
setStraighten(0); setStraighten(0);
@@ -3779,7 +3797,7 @@ export function Workspace() {
active: flipH, active: flipH,
onClick: () => { onClick: () => {
remember(); remember();
carryMarks({ rot: rotation, flipH: !flipH, flipV }); carryMarks({ rot: rotation, straighten, flipH: !flipH, flipV });
setFlipH((v) => !v); setFlipH((v) => !v);
}, },
}, },
@@ -3789,7 +3807,7 @@ export function Workspace() {
active: flipV, active: flipV,
onClick: () => { onClick: () => {
remember(); remember();
carryMarks({ rot: rotation, flipH, flipV: !flipV }); carryMarks({ rot: rotation, straighten, flipH, flipV: !flipV });
setFlipV((v) => !v); setFlipV((v) => !v);
}, },
}, },