Files
RecipesCam/docker/frontend/shared/utils/orientation.ts
T

160 lines
7.7 KiB
TypeScript

import type { ColorAdjustments, GradientMask } from '../types';
// 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
// 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;
// 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
// PHOTO, not a place on the screen: turn, straighten or mirror the frame under
// it and the fraction it holds points somewhere else on the picture. HEAL is
// where that shows — the renderer works the repair out of the photo around the
// mask's rim, so a spot that has slid off its mote paints a clean circle beside
// the mote while the mote itself is uncovered again — but the same drift moves
// every hidden patch and every mask.
//
// 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, in the renderer's own order read
// backwards and then forwards again (shared/utils/skiaImage.ts
// applyPhotoRotation): the quarter turn, then STRAIGHTEN, then the mirrors.
//
// STRAIGHTEN is the one that keeps moving — it is a ruler the user drags through
// every degree between two — and its own transform is what is undone here:
// rotateSkImageBy turns the content about the centre and scales it up by the
// cover factor `k` (the axis-aligned wrap of the turned frame, so no empty
// corner shows), the pixels stay those of the frame. Undoing it therefore turns
// the marks back about the same centre and scales them by 1/k, and the radii —
// a fraction of the width, on a frame whose width has not changed — take the
// 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
// window is a rectangle of the frame, the marks are measured inside it, and the
// window does not turn with the frame — the crop already picks a different part
// of the photo after a quarter turn, and the cover factor `k` is the crop's
// sides' and not the photo's. The marks are carried as if the frame turned
// 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 {
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;
flipV: boolean;
}
type Dims = { w: number; h: number };
type Spot = { x: number; y: number; r: number };
// The photo after its quarter turns: the two sides swap on an odd one.
const frameDims = (photo: Dims, rot: number): Dims =>
rot % 180 === 0 ? { w: photo.w, h: photo.h } : { w: photo.h, h: photo.w };
// `turns` quarter turns clockwise in fraction space: (x, y) -> (1 - y, x). The
// frame's sides swap with it, and a radius held as a fraction of the width keeps
// its pixels only if it is rescaled by the width it was measured against over the
// one it is about to sit in — which is why the dims travel back out with the
// point.
function turn(spot: Spot, turns: number, w: number, h: number) {
let { x, y, r } = spot;
for (let i = 0; i < ((turns % 4) + 4) % 4; i++) {
const nx = 1 - y;
y = x;
x = nx;
r = (r * w) / h;
const swap = w;
w = h;
h = swap;
}
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
// dims, a quarter turn does and a straighten does not; the dims are read at each
// step.
function carry(spot: Spot, from: Orientation, to: Orientation, photo: Dims): 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.flipV) p.y = 1 - p.y;
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 on = turn(back.spot, to.rot / 90, back.w, back.h);
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.flipV) p.y = 1 - p.y;
return p;
}
// 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
// radius, so they take the same rescale; the ellipse's angle takes every turn as
// a turn and every mirror as a sign flip — an ellipse is the same ellipse at
// angle θ and θ + π, which is what makes one sign enough for a reflection.
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 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 turned = ((to.rot - from.rot + to.straighten - from.straighten) * Math.PI) / 180;
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) };
}
// Every mark of a look, carried from the frame it was drawn on to the frame the
// user has just moved to. A no-op hands back the same object, so a caller can put
// this on every step of a drag without churning the recipe.
export function layMarks(
adj: ColorAdjustments,
from: Orientation,
to: Orientation,
photo: Dims | null | undefined
): ColorAdjustments {
if (!photo?.w || !photo?.h) return adj;
if (from.rot === to.rot && from.straighten === to.straighten && from.flipH === to.flipH && from.flipV === to.flipV) {
return adj;
}
return {
...adj,
heal: adj.heal?.map((s) => ({ ...s, ...carry(s, from, to, photo) })),
mosaic: adj.mosaic?.map((s) => ({ ...s, ...carry(s, from, to, photo) })),
masks: adj.masks?.map((m) => carryMask(m, from, to, photo)),
};
}