diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 6ca1338..5f2d6ca 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -2233,6 +2233,69 @@ export function Workspace() { || (Object.keys(DEFAULT_GPS_STYLE) as (keyof GpsStyle)[]).some((k) => gpsStyle[k] !== DEFAULT_GPS_STYLE[k]); }, [recipe, simId, frameId, crop, rotation, straighten, markOn, markText, markStyle, useGeotag, gpsStyle]); + // The chosen mask's knobs, as the app's own rulers: one parameter to a row, + // the same row every other number in the app is edited with. EXPOSURE is the + // spec's EV (-5..+5, its own `pow(2.0, e)`); CONTRAST and SATURATION are the + // app's -10..+10, which the shader reads as the spec's -1..+1; and a radial + // mask adds the feather it fades over — the percentage of its own axis the + // alpha holds full before it dies at the rim. + const maskRulers: SliderDef[] = !selMask + ? [] + : [ + { + key: 'mask-exposure', + label: 'EXPOSURE', + value: selMask.exposure, + min: -MASK_EXPOSURE_MAX, + max: MASK_EXPOSURE_MAX, + step: 0.1, + display: `${selMask.exposure > 0 ? '+' : ''}${selMask.exposure.toFixed(1)}`, + edited: selMask.exposure !== 0, + onChange: (v) => setMaskKnob({ exposure: v }), + onReset: () => setMaskKnob({ exposure: 0 }), + }, + { + key: 'mask-contrast', + label: 'CONTRAST', + value: selMask.contrast, + min: -10, + max: 10, + step: 1, + display: selMask.contrast > 0 ? `+${selMask.contrast}` : String(selMask.contrast), + edited: selMask.contrast !== 0, + onChange: (v) => setMaskKnob({ contrast: Math.round(v) }), + onReset: () => setMaskKnob({ contrast: 0 }), + }, + { + key: 'mask-saturation', + label: 'SATURATION', + value: selMask.saturation, + min: -10, + max: 10, + step: 1, + display: selMask.saturation > 0 ? `+${selMask.saturation}` : String(selMask.saturation), + edited: selMask.saturation !== 0, + onChange: (v) => setMaskKnob({ saturation: Math.round(v) }), + onReset: () => setMaskKnob({ saturation: 0 }), + }, + ...(selMask.kind === 'radial' + ? [ + { + key: 'mask-feather', + label: 'FEATHER', + value: Math.round(selMask.feather * 100), + min: 0, + max: 100, + step: 5, + display: `${Math.round(selMask.feather * 100)}%`, + edited: selMask.feather !== MASK_DEFAULT_FEATHER, + onChange: (v: number) => setMaskKnob({ feather: v / 100 }), + onReset: () => setMaskKnob({ feather: MASK_DEFAULT_FEATHER }), + }, + ] + : []), + ]; + const resetChip = (): ChipDef => ({ key: 'reset-all', label: resetDirty ? 'RESET *' : 'RESET', @@ -2768,9 +2831,9 @@ export function Workspace() { {/* column 2 — FX's GRADIENT MASK, while one is chosen: the shape's own - name, the way to take it off the photo, and the three knobs it - grades with. A radial mask adds the feather it fades over, since - that is the number its rim is made of. */} + name, the way to take it off the photo, and its knobs as the app's + own rulers — one parameter to a row. A radial mask adds the feather + it fades over, since that is the number its rim is made of. */} {selMask ? (
-
- `${v > 0 ? '+' : ''}${v.toFixed(1)}`} - onChange={(v) => setMaskKnob({ exposure: v })} - onReset={() => setMaskKnob({ exposure: 0 })} - /> - setMaskKnob({ contrast: Math.round(v) })} - onReset={() => setMaskKnob({ contrast: 0 })} - /> - setMaskKnob({ saturation: Math.round(v) })} - onReset={() => setMaskKnob({ saturation: 0 })} - /> - {selMask.kind === 'radial' ? ( - `${v}%`} - onChange={(v) => setMaskKnob({ feather: v / 100 })} - onReset={() => setMaskKnob({ feather: MASK_DEFAULT_FEATHER })} - /> - ) : null} -
+ {maskRulers.map((def) => ( + + ))}
) : null} diff --git a/docker/frontend/src/ui/ChipColumn.tsx b/docker/frontend/src/ui/ChipColumn.tsx index 832ae45..080b94d 100644 --- a/docker/frontend/src/ui/ChipColumn.tsx +++ b/docker/frontend/src/ui/ChipColumn.tsx @@ -39,8 +39,9 @@ export interface SliderDef { onChange: (value: number) => void; // Double-clicking the track puts the parameter back to its default. onReset: () => void; - // "<" — closes the ruler and reopens the strip it was launched from. - onBack: () => void; + // "<" — closes the ruler and reopens the strip it was launched from. Left out + // by a panel that IS the strip: a mask's own column has nowhere to go back to. + onBack?: () => void; } export function ChipRow({ chips }: { chips: ChipDef[] }) { @@ -96,8 +97,13 @@ export function SliderRow({ slider }: { slider: SliderDef }) { return () => el.removeEventListener('wheel', onWheel); }, []); return ( + // No data-key on the row itself: the parameter's own key is already on the + // chip that opened it, and a second element answering to it would make a + // probe's own selector ambiguous.
- + {slider.onBack ? ( + + ) : null}
{slider.label} diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index 3d1720c..e1c27c9 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -167,7 +167,10 @@ const maskFromDrag = ( // 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. 'move' takes the whole of it, the pin's own drag. +// 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. const maskDragged = ( m: GradientMask, part: MaskPart, @@ -188,8 +191,26 @@ const maskDragged = ( const ddy = p.fy - m.y; return { ...m, x: p.fx, y: p.fy, ex: m.ex + ddx, ey: m.ey + ddy }; } - case 'rot': - return { ...m, angle: Math.atan2(dy, dx) - Math.PI / 2 }; + 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; + // 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 ca = Math.cos(turn); + const sa = Math.sin(turn); + const spin = (x: number, y: number) => { + const ox = (x - cx) * box.width; + const oy = (y - cy) * box.height; + return { x: cx + (ox * ca - oy * sa) / box.width, y: cy + (ox * sa + oy * ca) / box.height }; + }; + const s = spin(m.x, m.y); + const e = spin(m.ex, m.ey); + return { ...m, x: s.x, y: s.y, ex: e.x, ey: e.y }; + } case 'rx': return { ...m, rx: Math.max(MASK_MIN, Math.abs(dx * Math.cos(m.angle) + dy * Math.sin(m.angle)) / box.width) }; default: @@ -991,19 +1012,30 @@ export function ImageStage({ // renderer's own measurement, so an ellipse stays the same ellipse on any // photo shape), which is why a y offset is turned back into a fraction of the // height here. The rotation handle rides just outside the +ry axis, a fixed - // number of SCREEN pixels clear of the rim, so it stays reachable at any zoom. + // number of SCREEN pixels clear of the rim, so it stays reachable at any zoom; + // a linear mask has no ry to hang it on, so it rides clear of the ramp's + // middle along the ramp's own normal, the direction the gradient falls in. const maskGeom = (m: GradientMask) => { const u = { x: Math.cos(m.angle), y: Math.sin(m.angle) }; const v = { x: -Math.sin(m.angle), y: Math.cos(m.angle) }; const w = box?.width ?? 0; + const h = box?.height ?? 0; const k = box && box.height > 0 ? box.width / box.height : 1; const clear = 24 / Math.max(1, view.s) / Math.max(1, w); - const at = (dx: number, dy: number) => ({ x: m.x + dx, y: m.y + dy * k }); + // The point a shape is moved by: an ellipse's centre, the middle of a ramp — + // which is what the pin sits on and what the handles are placed around. + const pin = m.kind === 'linear' ? { x: (m.x + m.ex) / 2, y: (m.y + m.ey) / 2 } : { x: m.x, y: m.y }; + const at = (dx: number, dy: number) => ({ x: pin.x + dx, y: pin.y + dy * k }); + const rx = (m.ex - m.x) * w; + const ry = (m.ey - m.y) * h; + const len = Math.hypot(rx, ry) || 1; return { - at, axis: (sx: number, sy: number) => at(sx * m.rx * u.x + sy * m.ry * v.x, sx * m.rx * u.y + sy * m.ry * v.y), - rot: at(v.x * (m.ry + clear), v.y * (m.ry + clear)), + rot: + m.kind === 'linear' + ? at((-ry / len) * clear, (rx / len) * clear) + : at(v.x * (m.ry + clear), v.y * (m.ry + clear)), }; }; @@ -1615,7 +1647,8 @@ export function ImageStage({ them is chosen at all — and the chosen one adds the outline of what the render will do (the two ends of a linear ramp and the ramp between them; an ellipse and the inner ellipse its feather fades - to) plus the handles that resize it. */} + to) plus the handles that resize it and the one that turns it: the + angle of an ellipse, or the direction a ramp falls in. */} {maskTool && !cropEditing && box ? (
))} {selMask && selGeom - ? selMask.kind === 'linear' - ? ([ - { part: 'p0' as const, at: { x: selMask.x, y: selMask.y }, k: 'l0' }, - { part: 'p1' as const, at: { x: selMask.ex, y: selMask.ey }, k: 'l1' }, - ].map(({ part, at, k }) => ( - grabMask(maskSel as number, part, e)} - onPointerMove={moveMask} - onPointerUp={endMask} - onPointerCancel={endMask} - /> - ))) - : ([ - { part: 'rx' as const, at: selGeom.axis(1, 0), k: 'x1' }, - { part: 'rx' as const, at: selGeom.axis(-1, 0), k: 'x2' }, - { part: 'ry' as const, at: selGeom.axis(0, 1), k: 'y1' }, - { part: 'ry' as const, at: selGeom.axis(0, -1), k: 'y2' }, - { part: 'rot' as const, at: selGeom.rot, k: 'rot' }, - ].map(({ part, at, k }) => ( - grabMask(maskSel as number, part, e)} - onPointerMove={moveMask} - onPointerUp={endMask} - onPointerCancel={endMask} - /> - ))) + ? (selMask.kind === 'linear' + ? [ + { part: 'p0' as const, at: { x: selMask.x, y: selMask.y }, k: 'p0' }, + { part: 'p1' as const, at: { x: selMask.ex, y: selMask.ey }, k: 'p1' }, + // The ramp's angle, which is the direction it falls in. + { part: 'rot' as const, at: selGeom.rot, k: 'rot' }, + ] + : [ + { part: 'rx' as const, at: selGeom.axis(1, 0), k: 'rx-x1' }, + { part: 'rx' as const, at: selGeom.axis(-1, 0), k: 'rx-x2' }, + { part: 'ry' as const, at: selGeom.axis(0, 1), k: 'ry-y1' }, + { part: 'ry' as const, at: selGeom.axis(0, -1), k: 'ry-y2' }, + { part: 'rot' as const, at: selGeom.rot, k: 'rot-rot' }, + ] + ).map(({ part, at, k }) => ( + grabMask(maskSel as number, part, e)} + onPointerMove={moveMask} + onPointerUp={endMask} + onPointerCancel={endMask} + /> + )) : null}
) : null}