From cca6fc46f7b8a0668513efd49fc04548b4dbe701 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Thu, 24 Sep 2026 11:32:58 +0700 Subject: [PATCH] web: turn a mask by the turn the hand makes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docker/frontend/src/ui/ImageStage.tsx | 39 ++++++++++++++++----------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index 64ff48a..8f76df3 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -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) => { @@ -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; };