web: turn a mask by the turn the hand makes

A press anywhere on a mask's outline takes hold of it to turn it, and the turn it
asked for was read as an absolute bearing: the direction from the shape's pin to
wherever the finger now happened to be. The outline is a grip a hand lands on
wherever it likes — a ramp's line runs the whole way across the photo — so the
moment a press was set down on it the shape swung round to face that finger. A
press on the edge of a ramp lying across the photo stood it upright on a ten-pixel
move, and a shape already turned snapped to the angle of the press before it had
been dragged at all.

The turn is now what the hand turns: the change in bearing about the pin since the
press, which on the first move is the press itself. A finger that lands on the
outline and stays there leaves the angle exactly as it found it; one that carries
the shape round by a quarter turn turns it by a quarter turn, whatever angle it
was at to begin with. An ellipse adds that turn to the angle it stores, a ramp
spins its two ends about its own middle, and both are measured in the photo's own
pixels so the two bearings are the same kind of thing.

ponytail: The turn is read against the previous pointer position the drag already
keeps, so it costs one subtraction and no state. A pointer that leaves the photo
mid-turn keeps the angle it stood at — the rule the brush already follows — and
the next move on the picture resumes from the last position that was on it.

Verified: tsc clean; mask-probe 52/0 on the dev server and again on 8090 — a press
on a ramp's own edge that travels a hundredth of the photo's width leaves the ramp
at the angle it was taken hold of at (ends 0.500 and 0.500), and on a shape
already turned the same press keeps it upright (0.562,0.260 and 0.558,0.860); a
quarter turn of the hand on the edge turns the ramp a quarter of the way round
(ends 0.200 and 0.800, one pin, no second shape laid); the pin still takes the
shape with the hand to where the hand went (0.560,0.560, no sideways throw); the
rim of an ellipse still turns with the hand on it (1 pin); another chip still
leaves 0 mask columns and the mask chip brings 1 back; DELETE still takes the
chosen shape off the photo with its grade (64 -> 255, then back to 61). Against
the code before this change the same probe fails 9, both angle checks among them —
the ramp lands at 0.850,0.560 and 0.860,0.562, that is, wherever the finger was.
brush-edit 33/0, heal-idle 23/0, heal-zoom-drag 28/0, landing/pro-gate/
award-column/otp-code/tone-curve/hsl-panel/grain-controls/chip-edge/chips-desk/
slider-reset/temp-swatch all ALL PASS, backend 180/0.
This commit is contained in:
2026-09-24 11:32:58 +07:00
parent 1f6c97be62
commit cca6fc46f7
+23 -16
View File
@@ -169,17 +169,22 @@ const maskFromDrag = (
// One handle's move, as the shape it leaves. The two ends of a linear ramp are
// themselves; a radial mask resizes along the ellipse's OWN axes — the pointer
// is projected onto the axis the handle rides, so a hand that wanders across it
// does not shrink it — and its rotation handle sits on the +ry axis, so the
// angle is simply the direction from the centre to the pointer, one quarter turn
// back. A line has no angle to store, so turning one is moving its two ends
// about its own middle by the same turn, which keeps the ramp's length and its
// place — the gradient falls in the new direction and nowhere else changes.
// 'move' takes the whole of it, the pin's own drag.
// does not shrink it. A line has no angle to store, so turning one is moving its
// two ends about its own middle by the same turn, which keeps the ramp's length
// and its place — the gradient falls in the new direction and nowhere else
// changes. 'move' takes the whole of it, the pin's own drag.
//
// Turning is RELATIVE to where the hand took hold: the turn is how far it has
// travelled about the pin since then, not the direction it now points. A mask's
// edge is a grip you can land on anywhere, so an absolute angle would snap the
// shape to face the finger the instant it was brushed. `from` is the previous
// pointer position, the press itself on the first move.
const maskDragged = (
m: GradientMask,
part: MaskPart,
p: { fx: number; fy: number },
box: { width: number; height: number }
box: { width: number; height: number },
from: { fx: number; fy: number }
): GradientMask => {
const dx = (p.fx - m.x) * box.width;
const dy = (p.fy - m.y) * box.height;
@@ -199,14 +204,15 @@ const maskDragged = (
return { ...m, x: m.x + ddx, y: m.y + ddy, ex: m.ex + ddx, ey: m.ey + ddy };
}
case 'rot': {
if (m.kind === 'radial') return { ...m, angle: Math.atan2(dy, dx) - Math.PI / 2 };
const cx = (m.x + m.ex) / 2;
const cy = (m.y + m.ey) / 2;
const pin = maskPin(m);
// The turn is asked for in the photo's own pixels, so the angle the hand
// asks for and the angle the ramp lies at are measured the same way.
const turn =
Math.atan2((p.fy - cy) * box.height, (p.fx - cx) * box.width) -
Math.atan2((m.ey - cy) * box.height, (m.ex - cx) * box.width);
const bearing = (q: { fx: number; fy: number }) =>
Math.atan2((q.fy - pin.y) * box.height, (q.fx - pin.x) * box.width);
const turn = bearing(p) - bearing(from);
if (m.kind === 'radial') return { ...m, angle: m.angle + turn };
const cx = (m.x + m.ex) / 2;
const cy = (m.y + m.ey) / 2;
const ca = Math.cos(turn);
const sa = Math.sin(turn);
const spin = (x: number, y: number) => {
@@ -952,7 +958,7 @@ export function ImageStage({
// the shape can be seen before it is let go; the flags below are the gesture's
// own undo boundary, the same one the brush's grab keeps.
const [draft, setDraft] = useState<{ a: BrushPoint; b: BrushPoint } | null>(null);
const maskGrab = useRef<{ i: number; part: MaskPart } | null>(null);
const maskGrab = useRef<{ i: number; part: MaskPart; from: { fx: number; fy: number } | null } | null>(null);
const maskUndo = useRef(false);
const maskDown = (e: React.PointerEvent<HTMLDivElement>) => {
@@ -988,7 +994,7 @@ export function ImageStage({
if (!onMaskEdit) return;
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
maskGrab.current = { i, part };
maskGrab.current = { i, part, from: brushPoint(e.clientX, e.clientY) };
maskUndo.current = false;
onMaskSel?.(i);
};
@@ -1000,7 +1006,8 @@ export function ImageStage({
const p = brushPoint(e.clientX, e.clientY);
if (!p) return;
const next = masks.slice();
next[g.i] = maskDragged(was, g.part, p, box);
next[g.i] = maskDragged(was, g.part, p, box, g.from ?? p);
g.from = p;
onMaskEdit(next, !maskUndo.current);
maskUndo.current = true;
};