diff --git a/data/kernels/retouch.cl b/data/kernels/retouch.cl
index 01a29273c387..a5e526f76316 100644
--- a/data/kernels/retouch.cl
+++ b/data/kernels/retouch.cl
@@ -59,16 +59,66 @@ retouch_copy_buffer_to_image(global float4 *in,
__write_only image2d_t out,
global dt_iop_roi_t *roi_out,
const int xoffs,
- const int yoffs)
+ const int yoffs,
+ const float angle,
+ const float cx,
+ const float cy)
{
const int x = get_global_id(0);
const int y = get_global_id(1);
if(x >= roi_out->width || y >= roi_out->height) return;
- if(x + xoffs >= roi_in->width || y + yoffs >= roi_in->height) return;
- const int idx = mad24(y + yoffs, roi_in->width, x + xoffs);
- write_imagef(out, (int2)(x, y), in[idx]);
+ // skip the rotation math for negligibly small angles (cheaper, and avoids
+ // resampling artifacts on a near-zero rotation from repeated UI actions)
+ if (fabs(angle) < 0.01f)
+ {
+ if(x + xoffs >= roi_in->width || y + yoffs >= roi_in->height || x + xoffs < 0 || y + yoffs < 0) return;
+ const int idx = mad24(y + yoffs, roi_in->width, x + xoffs);
+ write_imagef(out, (int2)(x, y), in[idx]);
+ }
+ else
+ {
+ // same rotation as the on-screen source outline, so the overlay marks the
+ // region actually copied (see rt_copy_in_to_out in retouch.c)
+ const float c = dtcl_cos(angle);
+ const float s = dtcl_sin(angle);
+ // (cx, cy) is the mask centroid (rotation pivot), in roi_out-local coords
+ const float cx_source = cx + xoffs;
+ const float cy_source = cy + yoffs;
+
+ const float sx = x + xoffs;
+ const float sy = y + yoffs;
+ const float rx = sx - cx_source;
+ const float ry = sy - cy_source;
+ const float ix = cx_source + rx * c - ry * s;
+ const float iy = cy_source + rx * s + ry * c;
+
+ // Edge-clamp (replicate) out-of-bounds samples instead of zero-filling:
+ // rt_compute_roi_in grows roi_in to the rotation-aware source area, but not
+ // past the image border, so a rotated source near the edge can still sample
+ // just outside roi_in. Replicating avoids a hard black seam.
+ const float ixc = clamp(ix, 0.0f, (float)(roi_in->width - 1));
+ const float iyc = clamp(iy, 0.0f, (float)(roi_in->height - 1));
+
+ int x0 = (int)ixc;
+ int y0 = (int)iyc;
+ x0 = clamp(x0, 0, roi_in->width - 2);
+ y0 = clamp(y0, 0, roi_in->height - 2);
+ const float dx0 = ixc - x0;
+ const float dy0 = iyc - y0;
+
+ float4 in00 = in[mad24(y0, roi_in->width, x0)];
+ float4 in10 = in[mad24(y0, roi_in->width, x0 + 1)];
+ float4 in01 = in[mad24(y0 + 1, roi_in->width, x0)];
+ float4 in11 = in[mad24(y0 + 1, roi_in->width, x0 + 1)];
+
+ float4 val = in00 * (1.0f - dx0) * (1.0f - dy0) +
+ in10 * dx0 * (1.0f - dy0) +
+ in01 * (1.0f - dx0) * dy0 +
+ in11 * dx0 * dy0;
+ write_imagef(out, (int2)(x, y), val);
+ }
}
kernel void
@@ -77,15 +127,65 @@ retouch_copy_buffer_to_buffer(global float4 *in,
global float4 *out,
global dt_iop_roi_t *roi_out,
const int xoffs,
- const int yoffs)
+ const int yoffs,
+ const float angle,
+ const float cx,
+ const float cy)
{
const int x = get_global_id(0);
const int y = get_global_id(1);
if(x >= roi_out->width || y >= roi_out->height) return;
- if(x + xoffs >= roi_in->width || y + yoffs >= roi_in->height) return;
- out[mad24(y, roi_out->width, x)] = in[mad24(y + yoffs, roi_in->width, x + xoffs)];
+ // skip the rotation math for negligibly small angles (cheaper, and avoids
+ // resampling artifacts on a near-zero rotation from repeated UI actions)
+ if (fabs(angle) < 0.01f)
+ {
+ if(x + xoffs >= roi_in->width || y + yoffs >= roi_in->height || x + xoffs < 0 || y + yoffs < 0) return;
+ out[mad24(y, roi_out->width, x)] = in[mad24(y + yoffs, roi_in->width, x + xoffs)];
+ }
+ else
+ {
+ // same rotation as the on-screen source outline, so the overlay marks the
+ // region actually copied (see rt_copy_in_to_out in retouch.c)
+ const float c = dtcl_cos(angle);
+ const float s = dtcl_sin(angle);
+ // (cx, cy) is the mask centroid (rotation pivot), in roi_out-local coords
+ const float cx_source = cx + xoffs;
+ const float cy_source = cy + yoffs;
+
+ const float sx = x + xoffs;
+ const float sy = y + yoffs;
+ const float rx = sx - cx_source;
+ const float ry = sy - cy_source;
+ const float ix = cx_source + rx * c - ry * s;
+ const float iy = cy_source + rx * s + ry * c;
+
+ // Edge-clamp (replicate) out-of-bounds samples instead of zero-filling:
+ // rt_compute_roi_in grows roi_in to the rotation-aware source area, but not
+ // past the image border, so a rotated source near the edge can still sample
+ // just outside roi_in. Replicating avoids a hard black seam.
+ const float ixc = clamp(ix, 0.0f, (float)(roi_in->width - 1));
+ const float iyc = clamp(iy, 0.0f, (float)(roi_in->height - 1));
+
+ int x0 = (int)ixc;
+ int y0 = (int)iyc;
+ x0 = clamp(x0, 0, roi_in->width - 2);
+ y0 = clamp(y0, 0, roi_in->height - 2);
+ const float dx0 = ixc - x0;
+ const float dy0 = iyc - y0;
+
+ float4 in00 = in[mad24(y0, roi_in->width, x0)];
+ float4 in10 = in[mad24(y0, roi_in->width, x0 + 1)];
+ float4 in01 = in[mad24(y0 + 1, roi_in->width, x0)];
+ float4 in11 = in[mad24(y0 + 1, roi_in->width, x0 + 1)];
+
+ float4 val = in00 * (1.0f - dx0) * (1.0f - dy0) +
+ in10 * dx0 * (1.0f - dy0) +
+ in01 * (1.0f - dx0) * dy0 +
+ in11 * dx0 * dy0;
+ out[mad24(y, roi_out->width, x)] = val;
+ }
}
kernel void
diff --git a/src/develop/masks.h b/src/develop/masks.h
index 786a1a80dbfd..5a2112c93587 100644
--- a/src/develop/masks.h
+++ b/src/develop/masks.h
@@ -346,8 +346,8 @@ typedef struct dt_masks_form_t
dt_masks_type_t type;
const dt_masks_functions_t *functions;
- // position of the source (used only for clone)
- float source[2];
+ // position of the source (used only for clone). [0]=dx, [1]=dy, [2]=angle
+ float source[3];
// name of the form
char name[128];
// id used to store the form
@@ -401,6 +401,14 @@ typedef struct dt_masks_form_gui_t
gboolean form_selected;
gboolean border_selected;
gboolean source_selected;
+ gboolean source_rotating;
+ gboolean counter_rotate_source;
+ // joint rotation grabbed from the source shape: the mouse circles the source,
+ // so its angular sweep must be measured about the source centroid (not the
+ // destination centroid) to keep the rotation gain symmetric with grabbing the
+ // target. The applied angle is identical either way; only the pivot used to
+ // read the mouse motion differs.
+ gboolean rotate_about_source;
gboolean pivot_selected;
gboolean select_only_border;
dt_masks_edit_mode_t edit_mode;
@@ -1133,6 +1141,26 @@ void dt_masks_closest_point(const int count,
float *x,
float *y);
+/* Rotate the control points of a path/brush outline in screen space and project
+ them back to normalized image coordinates. `gpt_points` is the gui display
+ buffer (interleaved x,y) whose first `nb*3` pairs are the control points,
+ stored per node as ctrl1, corner, ctrl2; `points_count` is its number of
+ (x,y) pairs. Each control point is rotated by (cos_a, sin_a) around the screen
+ pivot (cx, cy), back-transformed through the pipe in a single batch, and
+ written to `out` (normalized, same interleaving, nb*6 floats). Shared by the
+ path and brush rotate gestures. */
+void dt_masks_rotate_ctrl_points(dt_develop_t *dev,
+ const float *const gpt_points,
+ const int points_count,
+ const int nb,
+ const float cx,
+ const float cy,
+ const float cos_a,
+ const float sin_a,
+ const float iwidth,
+ const float iheight,
+ float *const out);
+
/* draw a line from -> to with an arrow at the end.
if touch_dest is true then the arrow will be at the
(to_x, to_y) location, otherwise a small space will
diff --git a/src/develop/masks/brush.c b/src/develop/masks/brush.c
index 10d1e30ce190..7c462bc6e7fb 100644
--- a/src/develop/masks/brush.c
+++ b/src/develop/masks/brush.c
@@ -50,6 +50,32 @@ static inline int _nb_ctrl_point(const int nb_point)
return nb_point * 3;
}
+// Centroid of a brush stroke: the arithmetic mean of `n` dense centerline
+// points stored with the given stride (floats between consecutive points; 2 for
+// a packed x,y array). A brush "outline" is the centerline traced up then back
+// down, so it has no enclosed area -- a shoelace area centroid would be unstable
+// there. The mean is rotation-invariant (mean(R*p) = R*mean(p)), so the source
+// spins in place; a bounding-box center is not, and would make it drift on
+// repeated rotation.
+static void _brush_centroid(const float *const pts,
+ const int n,
+ const int stride,
+ float *const ox,
+ float *const oy)
+{
+ double mx = 0.0, my = 0.0;
+ for(int i = 0; i < n; i++)
+ {
+ mx += pts[i * stride];
+ my += pts[i * stride + 1];
+ }
+ if(n > 0)
+ {
+ *ox = (float)(mx / n);
+ *oy = (float)(my / n);
+ }
+}
+
/** get squared distance of indexed point to line segment, taking
* weighted payload data into account */
static float _brush_point_line_distance2(const int index,
@@ -1068,6 +1094,35 @@ static int _brush_get_pts_border(dt_develop_t *dev,
ptsbuf[i * 2 + 1] += dy;
}
+ // rotate the source outline by form->source[2] around its centroid. The
+ // buffer starts with nb*3 control-point entries (ctrl1/corner/ctrl2 per
+ // node) that are NOT in boundary order, so the pivot is the mean of the
+ // dense centerline that follows (see _brush_centroid). The centroid is
+ // rotation-invariant, so the source spins in place; retouch.c rotates the
+ // cloned/healed source around the centroid of the rasterized mask (which
+ // approximates this), so the overlay tracks the sampled region.
+ if(!feqf(form->source[2], 0.0f, 0.01f))
+ {
+ float cx = 0.0f, cy = 0.0f;
+ const int off = _nb_ctrl_point(nb);
+ if(*points_count - off >= 3)
+ _brush_centroid(&ptsbuf[off * 2], *points_count - off, 2, &cx, &cy);
+ else
+ _brush_centroid(ptsbuf, *points_count, 2, &cx, &cy);
+
+ const float rc = cosf(form->source[2]);
+ const float rs = sinf(form->source[2]);
+
+ DT_OMP_FOR(if(*points_count > 100))
+ for(int i = 0; i < *points_count; i++)
+ {
+ const float rx = ptsbuf[i * 2] - cx;
+ const float ry = ptsbuf[i * 2 + 1] - cy;
+ ptsbuf[i * 2] = cx + rx * rc - ry * rs;
+ ptsbuf[i * 2 + 1] = cy + rx * rs + ry * rc;
+ }
+ }
+
// we apply the rest of the distortions (those after the module)
// so we have now the SOURCE points in final image reference
if(!dt_dev_distort_transform_plus(dev, pipe, iop_order,
@@ -1144,25 +1199,24 @@ static void _brush_get_distance(const float x,
// we first check if we are inside the source form
- // add support for clone masks
- if(gpt->points_count > 2 + _nb_ctrl_point(corner_count)
- && gpt->source_count > 2 + _nb_ctrl_point(corner_count))
+ // add support for clone masks. Use the source points directly (they already
+ // carry the source rotation) instead of reconstructing them from the target
+ // points plus a translation -- otherwise the hit area would ignore the source
+ // rotation and sit away from the displayed source stroke.
+ if(gpt->source_count > 2 + _nb_ctrl_point(corner_count))
{
- const float dx = -gpt->points[2] + gpt->source[2];
- const float dy = -gpt->points[3] + gpt->source[3];
-
int current_seg = 1;
- for(int i = _nb_ctrl_point(corner_count); i < gpt->points_count; i++)
+ for(int i = _nb_ctrl_point(corner_count); i < gpt->source_count; i++)
{
// do we change of path segment ?
- if(gpt->points[i * 2 + 1] == gpt->points[current_seg * 6 + 3]
- && gpt->points[i * 2] == gpt->points[current_seg * 6 + 2])
+ if(gpt->source[i * 2 + 1] == gpt->source[current_seg * 6 + 3]
+ && gpt->source[i * 2] == gpt->source[current_seg * 6 + 2])
{
current_seg = (current_seg + 1) % corner_count;
}
- // distance from tested point to current form point
- const float yy = gpt->points[i * 2 + 1] + dy;
- const float xx = gpt->points[i * 2] + dx;
+ // distance from tested point to current source point
+ const float yy = gpt->source[i * 2 + 1];
+ const float xx = gpt->source[i * 2];
const float sdx = x - xx;
const float sdy = y - yy;
@@ -1552,6 +1606,44 @@ static int _brush_events_button_pressed(dt_iop_module_t *module,
dt_control_queue_redraw_center();
return 1;
}
+ else if((gui->form_selected || gui->source_selected || (gui->seg_selected >= 0 && gui->point_selected < 0 &&
+ gui->feather_selected < 0 && gui->point_border_selected < 0)) &&
+ gui->edit_mode == DT_MASKS_EDIT_FULL && dt_modifiers_include(state, GDK_CONTROL_MASK))
+ {
+ // Modifier scheme (matches the rotate combo used elsewhere for masks):
+ // - CTRL: rotate only the shape under the pointer (source or target)
+ // - CTRL+SHIFT: rotate both shapes together about their centroids
+ gui->rotate_about_source = FALSE;
+ if(dt_modifiers_include(state, GDK_SHIFT_MASK))
+ {
+ gui->form_rotating = TRUE;
+ gui->counter_rotate_source = FALSE; // joint rotation
+ // If the joint rotation was grabbed on the source, the mouse circles the
+ // source centroid; measure its angular sweep there so the rotation gain
+ // matches grabbing the target (see rotate_about_source in masks.h).
+ gui->rotate_about_source = gui->source_selected;
+ }
+ else if(gui->source_selected)
+ {
+ gui->source_rotating = TRUE; // source only
+ }
+ else
+ {
+ gui->form_rotating = TRUE;
+ gui->counter_rotate_source = TRUE; // target only, source stays put
+ }
+
+ gui->point_edited = -1;
+ gui->seg_selected = -1;
+
+ // the rotation pivot is recomputed each motion event from the displayed
+ // outline (see the form_rotating/source_rotating branches in mouse_moved);
+ // here we only record the initial mouse position for the incremental delta
+ gui->scrollx = gui->scrolly = 0.0f;
+ gui->dx = pzx;
+ gui->dy = pzy;
+ return 1;
+ }
else if(gui->source_selected
&& gui->edit_mode == DT_MASKS_EDIT_FULL)
{
@@ -1559,8 +1651,15 @@ static int _brush_events_button_pressed(dt_iop_module_t *module,
if(!guipt) return 0;
// we start the form dragging
gui->source_dragging = TRUE;
- gui->dx = guipt->source[2] - gui->posx;
- gui->dy = guipt->source[3] - gui->posy;
+ // Anchor the drag on the (unrotated) source position rather than the
+ // displayed node guipt->source[2]: when the source is rotated
+ // (source[2] != 0) the displayed node differs from form->source, so
+ // grabbing it would snap the anchor onto it and make the source jump on
+ // every click. Use the forward-transformed source position instead.
+ float anchor[2] = { form->source[0] * iwidth, form->source[1] * iheight };
+ dt_dev_distort_transform(darktable.develop, anchor, 1);
+ gui->dx = anchor[0] - gui->posx;
+ gui->dy = anchor[1] - gui->posy;
return 1;
}
else if(gui->form_selected
@@ -1626,7 +1725,10 @@ static int _brush_events_button_pressed(dt_iop_module_t *module,
{
const guint nb = g_list_length(form->points);
gui->point_edited = -1;
- if(dt_modifier_is(state, GDK_CONTROL_MASK) && gui->seg_selected < nb - 1)
+ // add a node with SHIFT+click on a segment. This used to be CTRL+click,
+ // but CTRL is now reserved for the rotate gesture (see the rotate branch
+ // above), so the add-node modifier was moved to SHIFT to avoid a clash.
+ if(dt_modifier_is(state, GDK_SHIFT_MASK) && gui->seg_selected < nb - 1)
{
// we add a new point to the brush
dt_masks_point_brush_t *bzpt = (malloc(sizeof(dt_masks_point_brush_t)));
@@ -2009,6 +2111,28 @@ static int _brush_events_button_released(dt_iop_module_t *module,
dt_control_queue_redraw_center();
return 1;
}
+ else if(gui->form_rotating)
+ {
+ // rotation was applied incrementally in mouse_moved; just finalise
+ gui->form_rotating = FALSE;
+ gui->rotate_about_source = FALSE;
+ gui->scrollx = gui->scrolly = 0.0f;
+
+ dt_dev_add_masks_history_item(darktable.develop, module, TRUE);
+ dt_masks_gui_form_create(form, gui, index, module);
+
+ return 1;
+ }
+ else if(gui->source_rotating)
+ {
+ gui->source_rotating = FALSE;
+ gui->scrollx = gui->scrolly = 0.0f;
+
+ dt_dev_add_masks_history_item(darktable.develop, module, TRUE);
+ dt_masks_gui_form_create(form, gui, index, module);
+
+ return 1;
+ }
else if(gui->form_dragging)
{
// we end the form dragging
@@ -2278,6 +2402,147 @@ static int _brush_events_mouse_moved(struct dt_iop_module_t *module,
dt_control_queue_redraw_center();
return 1;
}
+ else if(gui->form_rotating)
+ {
+ // Rotate in screen (backbuffer pixel) space for perfect visual pivot.
+ // The outer gpt (fetched at top of function) already has the current display positions.
+ if(!gpt || gpt->points_count < 6)
+ {
+ // not enough data; just swallow the event
+ gui->dx = pzx;
+ gui->dy = pzy;
+ return 1;
+ }
+
+ // current and previous mouse in backbuffer pixels
+ const float cmx = pzx * wd;
+ const float cmy = pzy * ht;
+ const float pmx = gui->dx * wd;
+ const float pmy = gui->dy * ht;
+
+ // centroid of the stroke on screen, computed from the dense centerline
+ // (which follows the nb*3 control-point entries) so the destination spins
+ // around its rotation-invariant centroid (see the note in
+ // _brush_get_pts_border).
+ const int nc = g_list_length(form->points);
+ const int off = _nb_ctrl_point(nc);
+ float cx = cmx, cy = cmy;
+ if(gpt->points_count - off >= 3)
+ _brush_centroid(&gpt->points[off * 2], gpt->points_count - off, 2, &cx, &cy);
+
+ // Pivot used to read the mouse's angular sweep. For a joint rotation grabbed
+ // on the source, the mouse circles the source centroid, which is offset from
+ // the destination centroid; measuring the sweep about the destination would
+ // under-report the angle (smaller subtended angle at a distant pivot) and
+ // make the source feel "heavier" to rotate. Measure about the source centroid
+ // in that case. The destination geometry is still rotated about cx,cy below.
+ float mx = cx, my = cy;
+ if(gui->rotate_about_source && gpt->source_count - off >= 3)
+ _brush_centroid(&gpt->source[off * 2], gpt->source_count - off, 2, &mx, &my);
+
+ float dv = atan2f(cmy - my, cmx - mx) - atan2f(pmy - my, pmx - mx);
+ if(fabsf(dv) > M_PI_F)
+ dv -= copysignf(DT_2PI_F, dv);
+ const float c = cosf(dv);
+ const float s = sinf(dv);
+
+ // The source is derived from the destination shape (translated by the
+ // source-to-target offset), so rotating the destination geometry already
+ // rotates the source by the same amount around its own centroid -- provided
+ // that offset stays constant. Remember the reference node (first point, which
+ // anchors the offset) so we can hold the offset fixed below; otherwise the
+ // source would orbit the destination instead of spinning in place.
+ dt_masks_point_brush_t *const pt0 = form->points ? form->points->data : NULL;
+ const float ref_old[2] = { pt0 ? pt0->corner[0] : 0.f, pt0 ? pt0->corner[1] : 0.f };
+
+ // Rotate each node's three control points (ctrl1, corner, ctrl2) around the
+ // screen centroid and project them back to image coordinates in one batch,
+ // then scatter the result into the brush nodes.
+ float *const npts = dt_alloc_align_float((size_t)nc * 6);
+ if(npts)
+ {
+ dt_masks_rotate_ctrl_points(
+ darktable.develop, gpt->points, gpt->points_count, nc, cx, cy, c, s, iwidth, iheight, npts);
+
+ int k = 0;
+ for(GList *l = form->points; l; l = g_list_next(l), k++)
+ {
+ dt_masks_point_brush_t *pt = l->data;
+ pt->ctrl1[0] = npts[k * 6 + 0];
+ pt->ctrl1[1] = npts[k * 6 + 1];
+ pt->corner[0] = npts[k * 6 + 2];
+ pt->corner[1] = npts[k * 6 + 3];
+ pt->ctrl2[0] = npts[k * 6 + 4];
+ pt->ctrl2[1] = npts[k * 6 + 5];
+ }
+ dt_free_align(npts);
+ }
+
+ // Source bookkeeping only matters for clone/heal forms (those with a source).
+ // - joint rotation: both shapes rotate by dv. The source inherits the
+ // rotation from the destination geometry, so source[2] is left untouched.
+ // - target-only rotation: cancel that inherited rotation so the source keeps
+ // its current orientation.
+ if(form->type & DT_MASKS_CLONE)
+ {
+ if(gui->counter_rotate_source)
+ form->source[2] -= dv;
+
+ // Hold the source-to-target offset constant by shifting the source anchor
+ // by the displacement of the reference node, keeping the source centroid
+ // in place (it spins or stays, but never orbits).
+ if(pt0)
+ {
+ form->source[0] += pt0->corner[0] - ref_old[0];
+ form->source[1] += pt0->corner[1] - ref_old[1];
+ }
+ }
+
+ // remember current mouse (backbuffer-normalized) for next incremental step
+ gui->dx = pzx;
+ gui->dy = pzy;
+
+ dt_masks_gui_form_create(form, gui, index, module);
+ dt_control_queue_redraw_center();
+ return 1;
+ }
+ else if(gui->source_rotating)
+ {
+ if(!gpt || gpt->source_count < 6)
+ {
+ gui->dx = pzx;
+ gui->dy = pzy;
+ return 1;
+ }
+
+ const float cmx = pzx * wd;
+ const float cmy = pzy * ht;
+ const float pmx = gui->dx * wd;
+ const float pmy = gui->dy * ht;
+
+ // Rotate the source around its centroid (mean of the dense centerline, which
+ // follows the nb*3 control-point entries). The centroid is rotation-invariant,
+ // so the source spins in place; it is the same pivot used by the source
+ // display and (approximately) the pixel sampler, so the overlay tracks the
+ // cloned/healed result. The destination is untouched.
+ const int off = _nb_ctrl_point(g_list_length(form->points));
+ float cx = cmx, cy = cmy;
+ if(gpt->source_count - off >= 3)
+ _brush_centroid(&gpt->source[off * 2], gpt->source_count - off, 2, &cx, &cy);
+
+ float dv = atan2f(cmy - cy, cmx - cx) - atan2f(pmy - cy, pmx - cx);
+ if(fabsf(dv) > M_PI_F)
+ dv -= copysignf(DT_2PI_F, dv);
+
+ form->source[2] += dv;
+
+ gui->dx = pzx;
+ gui->dy = pzy;
+
+ dt_masks_gui_form_create(form, gui, index, module);
+ dt_control_queue_redraw_center();
+ return 1;
+ }
else if(gui->form_dragging || gui->source_dragging)
{
float pts[2] = { pzx * wd + gui->dx, pzy * ht + gui->dy };
@@ -3137,6 +3402,16 @@ static int _brush_get_mask_roi(const dt_iop_module_t *const module,
static GSList *_brush_setup_mouse_actions(const struct dt_masks_form_t *const form)
{
GSList *lm = NULL;
+ lm = dt_mouse_action_create_simple(
+ lm, DT_MOUSE_ACTION_LEFT, GDK_SHIFT_MASK, _("[BRUSH on segment] add node"));
+ lm = dt_mouse_action_create_simple(lm,
+ DT_MOUSE_ACTION_LEFT_DRAG,
+ GDK_CONTROL_MASK,
+ _("[BRUSH] rotate shape (source only on a clone source)"));
+ lm = dt_mouse_action_create_simple(lm,
+ DT_MOUSE_ACTION_LEFT_DRAG,
+ GDK_SHIFT_MASK | GDK_CONTROL_MASK,
+ _("[BRUSH] rotate shape and source together"));
lm = dt_mouse_action_create_simple(lm, DT_MOUSE_ACTION_SCROLL,
0, _("[BRUSH] change size"));
lm = dt_mouse_action_create_simple(lm, DT_MOUSE_ACTION_SCROLL,
@@ -3166,9 +3441,32 @@ static void _brush_set_hint_message(const dt_masks_form_gui_t *const gui,
// TODO: check if it would be good idea to have same controls on
// creation and for selected brush
if(gui->creation || gui->form_selected)
- g_snprintf(msgbuf, msgbuf_len,
+ {
+ g_snprintf(msgbuf,
+ msgbuf_len,
_("size: scroll, hardness: shift+scroll\n"
- "opacity: ctrl+scroll (%d%%)"), opacity);
+ "opacity: ctrl+scroll (%d%%), rotate: ctrl+drag"),
+ opacity);
+ // joint rotation of both shapes only makes sense when there is a source
+ // (clone/heal forms, e.g. in the retouch module), not while still drawing
+ if(!gui->creation && (form->type & DT_MASKS_CLONE))
+ g_strlcat(msgbuf, _(", rotate both: shift+ctrl+drag"), msgbuf_len);
+ }
+ else if(gui->source_selected)
+ // the source only exists for clone/heal forms, so joint rotation always applies here
+ g_strlcat(msgbuf,
+ _("move: drag, rotate source: ctrl+drag, "
+ "rotate both: shift+ctrl+drag"),
+ msgbuf_len);
+ else if(gui->seg_selected >= 0)
+ {
+ g_strlcat(msgbuf,
+ _("move segment: drag, add node: shift+click\n"
+ "rotate: ctrl+drag"),
+ msgbuf_len);
+ if(form->type & DT_MASKS_CLONE)
+ g_strlcat(msgbuf, _(", rotate both: shift+ctrl+drag"), msgbuf_len);
+ }
else if(gui->border_selected)
g_strlcat(msgbuf, _("size: scroll"), msgbuf_len);
}
diff --git a/src/develop/masks/ellipse.c b/src/develop/masks/ellipse.c
index f3d1847178e6..775555da3ea1 100644
--- a/src/develop/masks/ellipse.c
+++ b/src/develop/masks/ellipse.c
@@ -394,8 +394,11 @@ static int _ellipse_get_points_border(dt_develop_t *dev,
if(source)
{
const float xs = form->source[0], ys = form->source[1];
+ // the source content is sampled rotated by form->source[2] (radians),
+ // so the source outline must reflect that extra rotation
return _ellipse_get_points_source(dev, x, y, xs, ys, a, b,
- ellipse->rotation, points, points_count, module);
+ ellipse->rotation + rad2degf(form->source[2]),
+ points, points_count, module);
}
else
{
@@ -585,7 +588,7 @@ static int _ellipse_events_button_pressed(dt_iop_module_t *module,
}
else if(gui->edit_mode == DT_MASKS_EDIT_FULL)
{
- if(gui->source_selected)
+ if(gui->source_selected && !dt_modifiers_include(state, GDK_CONTROL_MASK))
{
gui->dx = gpt->source[0] - gui->posx;
gui->dy = gpt->source[1] - gui->posy;
@@ -597,9 +600,38 @@ static int _ellipse_events_button_pressed(dt_iop_module_t *module,
gui->dx = gpt->points[0] - gui->posx;
gui->dy = gpt->points[1] - gui->posy;
- if(gui->form_selected && dt_modifier_is(state, GDK_CONTROL_MASK))
+ if((gui->form_selected || gui->source_selected) && dt_modifiers_include(state, GDK_CONTROL_MASK))
{
- gui->form_rotating = TRUE;
+ // Modifier scheme (matches the rotate combo used elsewhere for masks):
+ // - CTRL: rotate only the shape under the pointer (source or target)
+ // - CTRL+SHIFT: rotate both shapes together
+ gui->rotate_about_source = FALSE;
+ if(dt_modifiers_include(state, GDK_SHIFT_MASK))
+ {
+ gui->form_rotating = TRUE;
+ gui->counter_rotate_source = FALSE; // joint rotation
+ // If the joint rotation was grabbed on the source, the mouse circles
+ // the source center; measure its angular sweep there so the rotation
+ // gain matches grabbing the target (see rotate_about_source in masks.h).
+ gui->rotate_about_source = gui->source_selected;
+ if(gui->source_selected)
+ {
+ gui->dx = gpt->source[0] - gui->posx;
+ gui->dy = gpt->source[1] - gui->posy;
+ }
+ }
+ else if(gui->source_selected)
+ {
+ gui->source_rotating = TRUE; // source only
+ // rotate around the source center, so use it as reference point
+ gui->dx = gpt->source[0] - gui->posx;
+ gui->dy = gpt->source[1] - gui->posy;
+ }
+ else
+ {
+ gui->form_rotating = TRUE;
+ gui->counter_rotate_source = TRUE; // target only, source stays put
+ }
return 1;
}
else if(gui->point_selected >= 1)
@@ -875,9 +907,11 @@ static int _ellipse_events_button_released(dt_iop_module_t *module,
dt_masks_form_gui_points_t *gpt = g_list_nth_data(gui->points, index);
if(!gpt) return 0;
- // ellipse center
- const float xref = gpt->points[0];
- const float yref = gpt->points[1];
+ // pivot for reading the final mouse sweep: the center of the shape the mouse
+ // is circling (source for a joint rotation grabbed on the source), matching
+ // the mouse_moved branch above.
+ const float xref = gui->rotate_about_source ? gpt->source[0] : gpt->points[0];
+ const float yref = gui->rotate_about_source ? gpt->source[1] : gpt->points[1];
const float pts[8] = { xref, yref, x , y, 0, 0, gui->dx, gui->dy };
@@ -898,7 +932,16 @@ static int _ellipse_events_button_released(dt_iop_module_t *module,
else
ellipse->rotation += rad2degf(dv);
+ // Rotation behavior (counter_rotate_source is set at button-press time):
+ // - CTRL only (target): only the target rotates, the source stays fixed
+ // (counter_rotate_source == TRUE, so the branch below is skipped)
+ // - CTRL+SHIFT: both shapes rotate together by the same amount
+ // (counter_rotate_source == FALSE, so the source angle follows)
+ if(!gui->counter_rotate_source)
+ form->source[2] += dv;
+
dt_conf_set_float(DT_MASKS_CONF(form->type, ellipse, rotation), ellipse->rotation);
+ gui->rotate_about_source = FALSE;
dt_dev_add_masks_history_item(darktable.develop, module, TRUE);
@@ -907,6 +950,33 @@ static int _ellipse_events_button_released(dt_iop_module_t *module,
return 1;
}
+ else if(gui->source_rotating && gui->edit_mode == DT_MASKS_EDIT_FULL)
+ {
+ gui->source_rotating = FALSE;
+
+ const float x = pzx * wd;
+ const float y = pzy * ht;
+
+ dt_masks_form_gui_points_t *gpt = g_list_nth_data(gui->points, index);
+ if(!gpt) return 0;
+
+ const float xref = gpt->source[0];
+ const float yref = gpt->source[1];
+
+ const float pts[8] = { xref, yref, x , y, 0, 0, gui->dx, gui->dy };
+
+ const float dv = atan2f(pts[3] - pts[1],
+ pts[2] - pts[0]) - atan2f(-(pts[7] - pts[5]),
+ -(pts[6] - pts[4]));
+
+ form->source[2] += dv;
+
+ dt_dev_add_masks_history_item(darktable.develop, module, TRUE);
+
+ dt_masks_gui_form_create(form, gui, index, module);
+
+ return 1;
+ }
else if(gui->point_dragging >= 1
&& gui->edit_mode == DT_MASKS_EDIT_FULL)
{
@@ -1100,9 +1170,14 @@ static int _ellipse_events_mouse_moved(dt_iop_module_t *module,
dt_masks_form_gui_points_t *gpt = g_list_nth_data(gui->points, index);
if(!gpt) return 0;
- // ellipse center
- const float xref = gpt->points[0];
- const float yref = gpt->points[1];
+ // Pivot used to read the mouse's angular sweep: the center of the shape the
+ // mouse is circling. For a joint rotation grabbed on the source, that is the
+ // source center; measuring about the destination center would under-report
+ // the angle (smaller subtended angle at a distant pivot) and make the source
+ // feel "heavier" to rotate. The rotation amount applied to both shapes is the
+ // same scalar either way.
+ const float xref = gui->rotate_about_source ? gpt->source[0] : gpt->points[0];
+ const float yref = gui->rotate_about_source ? gpt->source[1] : gpt->points[1];
const float pts[8] = { xref, yref, x, y, 0, 0, gui->dx, gui->dy };
@@ -1117,10 +1192,18 @@ static int _ellipse_events_mouse_moved(dt_iop_module_t *module,
pts2[4] - pts2[0]);
// Normalize to the range -180 to 180 degrees
check_angle = atan2f(sinf(check_angle), cosf(check_angle));
- if(check_angle < 0)
- ellipse->rotation -= rad2degf(dv);
- else
- ellipse->rotation += rad2degf(dv);
+
+ float diff = (check_angle < 0) ? -rad2degf(dv) : rad2degf(dv);
+
+ // Rotation behavior (counter_rotate_source is set at button-press time):
+ // - CTRL only (target): only the target rotates, the source stays fixed
+ // (counter_rotate_source == TRUE, so the branch below is skipped)
+ // - CTRL+SHIFT: both shapes rotate together by the same amount
+ // (counter_rotate_source == FALSE, so the source angle follows)
+ if(!gui->counter_rotate_source)
+ form->source[2] += deg2radf(diff);
+
+ ellipse->rotation += diff;
dt_conf_set_float(DT_MASKS_CONF(form->type, ellipse, rotation), ellipse->rotation);
@@ -1128,8 +1211,46 @@ static int _ellipse_events_mouse_moved(dt_iop_module_t *module,
dt_masks_gui_form_create(form, gui, index, module);
// we remap dx, dy to the right values, as it will be used in next movements
- gui->dx = xref - gui->posx;
- gui->dy = yref - gui->posy;
+ gui->dx = xref - x;
+ gui->dy = yref - y;
+
+ dt_control_queue_redraw_center();
+ return 1;
+ }
+ else if(gui->source_rotating)
+ {
+ float wd, ht, iwidth, iheight;
+ dt_masks_get_image_size(&wd, &ht, &iwidth, &iheight);
+ const float x = pzx * wd;
+ const float y = pzy * ht;
+
+ dt_masks_form_gui_points_t *gpt = g_list_nth_data(gui->points, index);
+ if(!gpt) return 0;
+
+ const float xref = gpt->source[0];
+ const float yref = gpt->source[1];
+
+ const float pts[8] = { xref, yref, x, y, 0, 0, gui->dx, gui->dy };
+
+ const float dv = atan2f(pts[3] - pts[1], pts[2] - pts[0])
+ - atan2f(-(pts[7] - pts[5]), -(pts[6] - pts[4]));
+
+ float pts2[8] = { xref, yref, x, y, xref + 10.0f, yref, xref, yref + 10.0f };
+ dt_dev_distort_backtransform(darktable.develop, pts2, 4);
+
+ float check_angle = atan2f(pts2[7] - pts2[1],
+ pts2[6] - pts2[0]) - atan2f(pts2[5] - pts2[1],
+ pts2[4] - pts2[0]);
+ // Normalize to the range -180 to 180 degrees
+ check_angle = atan2f(sinf(check_angle), cosf(check_angle));
+
+ float diff = (check_angle < 0) ? -dv : dv;
+ form->source[2] += diff;
+
+ dt_masks_gui_form_create(form, gui, index, module);
+
+ gui->dx = xref - x;
+ gui->dy = yref - y;
dt_control_queue_redraw_center();
return 1;
@@ -1965,9 +2086,14 @@ static GSList *_ellipse_setup_mouse_actions(const struct dt_masks_form_t *const
lm = dt_mouse_action_create_simple(lm, DT_MOUSE_ACTION_LEFT,
GDK_SHIFT_MASK,
_("[ELLIPSE] switch feathering mode"));
- lm = dt_mouse_action_create_simple(lm, DT_MOUSE_ACTION_LEFT_DRAG,
+ lm = dt_mouse_action_create_simple(lm,
+ DT_MOUSE_ACTION_LEFT_DRAG,
GDK_CONTROL_MASK,
- _("[ELLIPSE] rotate shape"));
+ _("[ELLIPSE] rotate shape (source only on a clone source)"));
+ lm = dt_mouse_action_create_simple(lm,
+ DT_MOUSE_ACTION_LEFT_DRAG,
+ GDK_SHIFT_MASK | GDK_CONTROL_MASK,
+ _("[ELLIPSE] rotate shape and source together"));
return lm;
}
@@ -2016,11 +2142,23 @@ static void _ellipse_set_hint_message(const dt_masks_form_gui_t *const gui,
"opacity: ctrl+scroll (%d%%)"), opacity);
else if(gui->point_selected >= 0)
g_strlcat(msgbuf, _("rotate: ctrl+drag"), msgbuf_len);
+ else if(gui->source_selected)
+ // the source only exists for clone/heal forms, so joint rotation always applies here
+ g_strlcat(msgbuf,
+ _("move: drag, rotate source: ctrl+drag, "
+ "rotate both: shift+ctrl+drag"),
+ msgbuf_len);
else if(gui->form_selected)
+ {
g_snprintf(msgbuf, msgbuf_len,
_("feather mode: alt+click, rotate: ctrl+drag\n"
"size: scroll, feather size: shift+scroll,"
" opacity: ctrl+scroll (%d%%)"), opacity);
+ // joint rotation of both shapes only makes sense when there is a source
+ // (clone/heal forms, e.g. in the retouch module)
+ if(form->type & DT_MASKS_CLONE)
+ g_strlcat(msgbuf, _("\nrotate both: shift+ctrl+drag"), msgbuf_len);
+ }
}
static void _ellipse_sanitize_config(const dt_masks_type_t type)
diff --git a/src/develop/masks/gradient.c b/src/develop/masks/gradient.c
index 42bdbf78895b..1fbe6a81330c 100644
--- a/src/develop/masks/gradient.c
+++ b/src/develop/masks/gradient.c
@@ -231,7 +231,10 @@ static int _gradient_events_button_pressed(dt_iop_module_t *module,
const dt_masks_form_gui_points_t *gpt = g_list_nth_data(gui->points, index);
if(!gpt) return 0;
// we start the form rotating or dragging
- if(gui->pivot_selected)
+ // - dragging the pivot handle rotates (legacy gesture)
+ // - CTRL+drag anywhere rotates too, matching the rotate convention
+ // used by the other masks (ellipse, path, brush)
+ if(gui->pivot_selected || dt_modifier_is(state, GDK_CONTROL_MASK))
gui->form_rotating = TRUE;
else
gui->form_dragging = TRUE;
@@ -1416,6 +1419,8 @@ static GSList *_gradient_setup_mouse_actions(const dt_masks_form_t *const form)
GSList *lm = NULL;
lm = dt_mouse_action_create_simple(lm, DT_MOUSE_ACTION_LEFT_DRAG,
0, _("[GRADIENT on pivot] rotate shape"));
+ lm = dt_mouse_action_create_simple(lm, DT_MOUSE_ACTION_LEFT_DRAG,
+ GDK_CONTROL_MASK, _("[GRADIENT] rotate shape"));
lm = dt_mouse_action_create_simple(lm, DT_MOUSE_ACTION_LEFT_DRAG,
0, _("[GRADIENT creation] set rotation"));
lm = dt_mouse_action_create_simple(lm, DT_MOUSE_ACTION_SCROLL,
@@ -1453,7 +1458,7 @@ static void _gradient_set_hint_message(const dt_masks_form_gui_t *const gui,
else if(gui->form_selected)
g_snprintf(msgbuf, msgbuf_len,
_("curvature: scroll, compression: shift+scroll\n"
- "opacity: ctrl+scroll (%d%%)"), opacity);
+ "rotate: ctrl+drag, opacity: ctrl+scroll (%d%%)"), opacity);
else if(gui->pivot_selected)
g_strlcat(msgbuf, _("rotate: drag"), msgbuf_len);
}
diff --git a/src/develop/masks/group.c b/src/develop/masks/group.c
index d293845d4964..645a1377afc4 100644
--- a/src/develop/masks/group.c
+++ b/src/develop/masks/group.c
@@ -67,6 +67,8 @@ static int _group_events_button_pressed(dt_iop_module_t *module,
gui->border_selected = FALSE;
gui->form_dragging = FALSE;
gui->form_rotating = FALSE;
+ gui->source_rotating = FALSE;
+ gui->counter_rotate_source = FALSE;
gui->pivot_selected = FALSE;
gui->point_border_selected = -1;
gui->seg_selected = -1;
@@ -133,6 +135,7 @@ static inline gboolean _is_handling_form(dt_masks_form_gui_t *gui)
|| gui->source_dragging
|| gui->gradient_toggling
|| gui->form_rotating
+ || gui->source_rotating
|| (gui->point_edited != -1)
|| (gui->point_dragging != -1)
|| (gui->feather_dragging != -1)
diff --git a/src/develop/masks/masks.c b/src/develop/masks/masks.c
index 031eb52e104e..bebf99f6d044 100644
--- a/src/develop/masks/masks.c
+++ b/src/develop/masks/masks.c
@@ -165,7 +165,10 @@ static void _set_hinter_message(const dt_masks_form_gui_t *gui,
if(sel->functions && sel->functions->set_hint_message)
{
- sel->functions->set_hint_message(gui, form, opacity, msg, sizeof(msg));
+ // pass the selected sub-form (sel), not the outer form: when editing a
+ // group member, `form` is the group, so its type/points would not describe
+ // the shape the hint is about (e.g. whether it is a clone with a source)
+ sel->functions->set_hint_message(gui, sel, opacity, msg, sizeof(msg));
}
dt_control_hinter_message(msg);
@@ -428,6 +431,7 @@ int dt_masks_form_duplicate(dt_develop_t *dev, const dt_mask_id_t formid)
// we copy the base values
fdest->source[0] = fbase->source[0];
fdest->source[1] = fbase->source[1];
+ fdest->source[2] = fbase->source[2];
fdest->version = fbase->version;
snprintf(fdest->name, sizeof(fdest->name), _("copy of `%s'"), fbase->name);
@@ -971,7 +975,25 @@ void dt_masks_read_masks_history(dt_develop_t *dev, const dt_imgid_t imgid)
form->version = sqlite3_column_int(stmt, 4);
form->points = NULL;
const int nb_points = sqlite3_column_int(stmt, 6);
- memcpy(form->source, sqlite3_column_blob(stmt, 7), sizeof(float) * 2);
+ const int source_bytes = sqlite3_column_bytes(stmt, 7);
+ if(source_bytes == sizeof(float) * 2)
+ {
+ memcpy(form->source, sqlite3_column_blob(stmt, 7), sizeof(float) * 2);
+ form->source[2] = 0.0f;
+ }
+ else if(source_bytes == sizeof(float) * 3)
+ {
+ memcpy(form->source, sqlite3_column_blob(stmt, 7), sizeof(float) * 3);
+ }
+ else if(source_bytes == sizeof(float) * 4)
+ {
+ // migration: old format stored an unused scale field as source[3]; drop it
+ memcpy(form->source, sqlite3_column_blob(stmt, 7), sizeof(float) * 3);
+ }
+ else
+ {
+ memset(form->source, 0, sizeof(float) * 3);
+ }
// and now we "read" the blob
if(form->functions)
@@ -1060,7 +1082,7 @@ void dt_masks_write_masks_history_item(const dt_imgid_t imgid,
DT_DEBUG_SQLITE3_BIND_INT(stmt, 2, form->formid);
DT_DEBUG_SQLITE3_BIND_INT(stmt, 3, form->type);
DT_DEBUG_SQLITE3_BIND_TEXT(stmt, 4, form->name, -1, SQLITE_TRANSIENT);
- DT_DEBUG_SQLITE3_BIND_BLOB(stmt, 8, form->source, 2 * sizeof(float), SQLITE_TRANSIENT);
+ DT_DEBUG_SQLITE3_BIND_BLOB(stmt, 8, form->source, 3 * sizeof(float), SQLITE_TRANSIENT);
DT_DEBUG_SQLITE3_BIND_INT(stmt, 5, form->version);
if(form->functions)
{
@@ -1332,6 +1354,8 @@ void dt_masks_clear_form_gui(const dt_develop_t *dev)
dev->form_gui->form_dragging = dev->form_gui->form_rotating =
dev->form_gui->border_toggling = dev->form_gui->gradient_toggling = FALSE;
dev->form_gui->source_selected = dev->form_gui->source_dragging = FALSE;
+ dev->form_gui->source_rotating = dev->form_gui->counter_rotate_source = FALSE;
+ dev->form_gui->rotate_about_source = FALSE;
dev->form_gui->pivot_selected = FALSE;
dev->form_gui->point_border_selected = dev->form_gui->seg_selected =
dev->form_gui->point_selected = dev->form_gui->feather_selected = -1;
@@ -2045,7 +2069,7 @@ dt_hash_t dt_masks_group_hash(dt_hash_t hash, dt_masks_form_t *form)
hash = dt_hash(hash, &form->type, sizeof(dt_masks_type_t));
hash = dt_hash(hash, &form->formid, sizeof(dt_mask_id_t));
hash = dt_hash(hash, &form->version, sizeof(int));
- hash = dt_hash(hash, &form->source, sizeof(float) * 2);
+ hash = dt_hash(hash, &form->source, sizeof(float) * 3);
for(const GList *forms = form->points; forms; forms = g_list_next(forms))
{
@@ -2786,6 +2810,49 @@ void dt_masks_closest_point(const int count,
}
}
+void dt_masks_rotate_ctrl_points(dt_develop_t *dev,
+ const float *const gpt_points,
+ const int points_count,
+ const int nb,
+ const float cx,
+ const float cy,
+ const float cos_a,
+ const float sin_a,
+ const float iwidth,
+ const float iheight,
+ float *const out)
+{
+ // the control points are the first nb*3 (x,y) pairs of the display buffer
+ const int nctrl = nb * 3;
+ if(nctrl < 1 || points_count < nctrl)
+ return;
+
+ // rotate every control point around the screen pivot into a scratch buffer,
+ // then back-transform the whole batch in one pipe traversal (cheaper and more
+ // accurate than inverting the pipe per point)
+ float *const scr = dt_alloc_align_float((size_t)nctrl * 2);
+ if(!scr)
+ return;
+
+ for(int i = 0; i < nctrl; i++)
+ {
+ const float rx = gpt_points[i * 2] - cx;
+ const float ry = gpt_points[i * 2 + 1] - cy;
+ scr[i * 2] = cx + rx * cos_a - ry * sin_a;
+ scr[i * 2 + 1] = cy + rx * sin_a + ry * cos_a;
+ }
+
+ dt_dev_distort_backtransform(dev, scr, nctrl);
+
+ for(int i = 0; i < nctrl; i++)
+ {
+ out[i * 2] = scr[i * 2] / iwidth;
+ out[i * 2 + 1] = scr[i * 2 + 1] / iheight;
+ }
+
+ dt_free_align(scr);
+}
+
void dt_masks_line_stroke(cairo_t *cr,
const gboolean border,
const gboolean source,
diff --git a/src/develop/masks/path.c b/src/develop/masks/path.c
index 4da6a3e5ff90..33e9912cb203 100644
--- a/src/develop/masks/path.c
+++ b/src/develop/masks/path.c
@@ -28,6 +28,44 @@
#include "develop/openmp_maths.h"
#include
+// Polygon area centroid (shoelace) of `n` points stored with the given stride
+// (number of floats between consecutive points; 2 for a packed x,y array). The
+// area centroid is rotation-invariant (rotating the shape about it leaves it
+// fixed), which is exactly what the clone/heal source rotation needs: a
+// bounding-box center would shift every time the shape is re-rotated and make
+// the source drift. Falls back to the arithmetic mean for a degenerate
+// (near-zero-area) polygon.
+static void _path_centroid(const float *const pts,
+ const int n,
+ const int stride,
+ float *const ox,
+ float *const oy)
+{
+ double a = 0.0, cx = 0.0, cy = 0.0, mx = 0.0, my = 0.0;
+ for(int i = 0; i < n; i++)
+ {
+ const int j = (i + 1) % n;
+ const double xi = pts[i * stride], yi = pts[i * stride + 1];
+ const double xj = pts[j * stride], yj = pts[j * stride + 1];
+ const double cr = xi * yj - xj * yi;
+ a += cr;
+ cx += (xi + xj) * cr;
+ cy += (yi + yj) * cr;
+ mx += xi;
+ my += yi;
+ }
+ if(fabs(a) > 1e-9)
+ {
+ *ox = (float)(cx / (3.0 * a));
+ *oy = (float)(cy / (3.0 * a));
+ }
+ else if(n > 0)
+ {
+ *ox = (float)(mx / n);
+ *oy = (float)(my / n);
+ }
+}
+
static void _path_bounding_box_raw(const float *const points,
const float *border,
const int nb_corner,
@@ -1514,6 +1552,38 @@ static int _path_get_pts_border(dt_develop_t *dev,
ptsbuf[i * 2 + 1] += dy;
}
+ // rotate the source outline by form->source[2] around its area centroid.
+ // The centroid is rotation-invariant, so re-rotating (or rotating the
+ // target, which the source geometry inherits) keeps the source in place;
+ // retouch.c rotates the cloned/healed source around the centroid of the
+ // rasterized mask, which approximates this outline centroid (they differ
+ // only by the feather), so the overlay tracks the sampled region.
+ if(!feqf(form->source[2], 0.0f, 0.01f))
+ {
+ // area centroid of the source = rotation pivot. The buffer starts with
+ // nb*3 control-point entries (ctrl1/corner/ctrl2 per node) that are NOT
+ // in boundary order, so only the dense outline that follows is a valid
+ // polygon for the shoelace centroid.
+ float cx = 0.0f, cy = 0.0f;
+ const int off = _nb_wctrl_points(nb);
+ if(*points_count - off >= 3)
+ _path_centroid(&ptsbuf[off * 2], *points_count - off, 2, &cx, &cy);
+ else
+ _path_centroid(ptsbuf, *points_count, 2, &cx, &cy);
+
+ const float rc = cosf(form->source[2]);
+ const float rs = sinf(form->source[2]);
+
+ DT_OMP_FOR(if(*points_count > 100))
+ for(int i = 0; i < *points_count; i++)
+ {
+ const float rx = ptsbuf[i * 2] - cx;
+ const float ry = ptsbuf[i * 2 + 1] - cy;
+ ptsbuf[i * 2] = cx + rx * rc - ry * rs;
+ ptsbuf[i * 2 + 1] = cy + rx * rs + ry * rc;
+ }
+ }
+
// we apply the rest of the distortions (those after the module)
// so we have now the SOURCE points in final image reference
if(!dt_dev_distort_transform_plus(dev, pipe, iop_order,
@@ -2103,14 +2173,59 @@ static int _path_events_button_pressed(dt_iop_module_t *module,
dt_control_queue_redraw_center();
return 1;
}
+ else if((gui->form_selected || gui->source_selected) && gui->edit_mode == DT_MASKS_EDIT_FULL &&
+ dt_modifiers_include(state, GDK_CONTROL_MASK))
+ {
+ // Modifier scheme (matches the rotate combo used elsewhere for masks):
+ // - CTRL: rotate only the shape under the pointer (source or target)
+ // - CTRL+SHIFT: rotate both shapes together about their centroids
+ gui->rotate_about_source = FALSE;
+ if(dt_modifiers_include(state, GDK_SHIFT_MASK))
+ {
+ gui->form_rotating = TRUE;
+ gui->counter_rotate_source = FALSE; // joint rotation
+ // If the joint rotation was grabbed on the source, the mouse circles the
+ // source centroid; measure its angular sweep there so the rotation gain
+ // matches grabbing the target (see rotate_about_source in masks.h).
+ gui->rotate_about_source = gui->source_selected;
+ }
+ else if(gui->source_selected)
+ {
+ gui->source_rotating = TRUE; // source only
+ }
+ else
+ {
+ gui->form_rotating = TRUE;
+ gui->counter_rotate_source = TRUE; // target only, source stays put
+ }
+
+ gui->point_edited = -1;
+ gui->seg_selected = -1;
+
+ // the rotation pivot is recomputed each motion event from the displayed
+ // outline (see the form_rotating/source_rotating branches in mouse_moved);
+ // here we only record the initial mouse position for the incremental delta
+ gui->scrollx = gui->scrolly = 0.0f;
+ gui->dx = pzx;
+ gui->dy = pzy;
+ return 1;
+ }
else if(gui->source_selected && gui->edit_mode == DT_MASKS_EDIT_FULL)
{
if(!gpt) return 0;
// we start the form dragging
gui->source_dragging = TRUE;
gui->point_edited = -1;
- gui->dx = gpt->source[2] - gui->posx;
- gui->dy = gpt->source[3] - gui->posy;
+ // Anchor the drag on the (unrotated) source position rather than the
+ // displayed corner gpt->source[2]: when the source is rotated
+ // (source[2] != 0) the displayed corner differs from form->source, so
+ // grabbing the corner would snap the anchor onto it and make the source
+ // jump on every click. Use the forward-transformed source position so the
+ // offset is computed against the same point that form->source represents.
+ float anchor[2] = { form->source[0] * iwidth, form->source[1] * iheight };
+ dt_dev_distort_transform(darktable.develop, anchor, 1);
+ gui->dx = anchor[0] - gui->posx;
+ gui->dy = anchor[1] - gui->posy;
return 1;
}
else if(gui->form_selected && gui->edit_mode == DT_MASKS_EDIT_FULL)
@@ -2199,7 +2314,10 @@ static int _path_events_button_pressed(dt_iop_module_t *module,
else if(gui->seg_selected >= 0)
{
gui->point_edited = -1;
- if(dt_modifier_is(state, GDK_CONTROL_MASK))
+ // add a node with SHIFT+click on a segment. This used to be CTRL+click,
+ // but CTRL is now reserved for the rotate gesture, and SHIFT matches the
+ // add-node modifier used by brushes.
+ if(dt_modifier_is(state, GDK_SHIFT_MASK))
{
// we add a new point to the path
dt_masks_point_path_t *bzpt = malloc(sizeof(dt_masks_point_path_t));
@@ -2404,6 +2522,28 @@ static int _path_events_button_released(dt_iop_module_t *module,
return 1;
}
+ else if(gui->form_rotating)
+ {
+ // rotation was applied incrementally in mouse_moved; just finalise
+ gui->form_rotating = FALSE;
+ gui->rotate_about_source = FALSE;
+ gui->scrollx = gui->scrolly = 0.0f;
+
+ dt_dev_add_masks_history_item(darktable.develop, module, TRUE);
+ dt_masks_gui_form_create(form, gui, index, module);
+
+ return 1;
+ }
+ else if(gui->source_rotating)
+ {
+ gui->source_rotating = FALSE;
+ gui->scrollx = gui->scrolly = 0.0f;
+
+ dt_dev_add_masks_history_item(darktable.develop, module, TRUE);
+ dt_masks_gui_form_create(form, gui, index, module);
+
+ return 1;
+ }
else if(gui->source_dragging)
{
// we end the form dragging
@@ -2690,6 +2830,147 @@ static int _path_events_mouse_moved(dt_iop_module_t *module,
dt_control_queue_redraw_center();
return 1;
}
+ else if(gui->source_rotating)
+ {
+ if(!gpt || gpt->source_count < 6)
+ {
+ gui->dx = pzx;
+ gui->dy = pzy;
+ return 1;
+ }
+
+ const float cmx = pzx * wd;
+ const float cmy = pzy * ht;
+ const float pmx = gui->dx * wd;
+ const float pmy = gui->dy * ht;
+
+ // Rotate the source around its area centroid (computed from the dense
+ // outline, which follows the nb*3 control-point entries). The centroid is
+ // rotation-invariant, so the source spins in place; it is the same pivot used
+ // by the source display and (approximately) the pixel sampler, so the overlay
+ // tracks the cloned/healed result. The destination is untouched.
+ const int off = _nb_wctrl_points(g_list_length(form->points));
+ float cx = cmx, cy = cmy;
+ if(gpt->source_count - off >= 3)
+ _path_centroid(&gpt->source[off * 2], gpt->source_count - off, 2, &cx, &cy);
+
+ float dv = atan2f(cmy - cy, cmx - cx) - atan2f(pmy - cy, pmx - cx);
+ if(fabsf(dv) > M_PI_F)
+ dv -= copysignf(DT_2PI_F, dv);
+
+ form->source[2] += dv;
+
+ gui->dx = pzx;
+ gui->dy = pzy;
+
+ dt_masks_gui_form_create(form, gui, index, module);
+ dt_control_queue_redraw_center();
+ return 1;
+ }
+ else if(gui->form_rotating)
+ {
+ // Rotate in screen (backbuffer pixel) space for perfect visual pivot.
+ // The outer gpt (fetched at top of function) already has the current display positions.
+ if(!gpt || gpt->points_count < 6)
+ {
+ // not enough data; just swallow the event
+ gui->dx = pzx;
+ gui->dy = pzy;
+ return 1;
+ }
+
+ // current and previous mouse in backbuffer pixels
+ const float cmx = pzx * wd;
+ const float cmy = pzy * ht;
+ const float pmx = gui->dx * wd;
+ const float pmy = gui->dy * ht;
+
+ // area centroid of the shape on screen, computed from the dense outline
+ // (which follows the nb*3 control-point entries) so the destination spins
+ // around its rotation-invariant centroid -- the same point the source and
+ // the rasterized mask rotate about (see the note in _path_get_pts_border).
+ const int nc = g_list_length(form->points);
+ const int off = _nb_wctrl_points(nc);
+ float cx = cmx, cy = cmy;
+ if(gpt->points_count - off >= 3)
+ _path_centroid(&gpt->points[off * 2], gpt->points_count - off, 2, &cx, &cy);
+
+ // Pivot used to read the mouse's angular sweep. For a joint rotation grabbed
+ // on the source, the mouse circles the source centroid, which is offset from
+ // the destination centroid; measuring the sweep about the destination would
+ // under-report the angle (smaller subtended angle at a distant pivot) and
+ // make the source feel "heavier" to rotate. Measure about the source centroid
+ // in that case. The destination geometry is still rotated about cx,cy below.
+ float mx = cx, my = cy;
+ if(gui->rotate_about_source && gpt->source_count - off >= 3)
+ _path_centroid(&gpt->source[off * 2], gpt->source_count - off, 2, &mx, &my);
+
+ float dv = atan2f(cmy - my, cmx - mx) - atan2f(pmy - my, pmx - mx);
+ if(fabsf(dv) > M_PI_F)
+ dv -= copysignf(DT_2PI_F, dv);
+ const float c = cosf(dv);
+ const float s = sinf(dv);
+
+ // The source is derived from the destination shape (translated by the
+ // source-to-target offset), so rotating the destination geometry already
+ // rotates the source by the same amount around its own centroid -- provided
+ // that offset stays constant. Remember the reference corner (first node,
+ // which anchors the offset) so we can hold the offset fixed below; otherwise
+ // the source would orbit the destination instead of spinning in place.
+ dt_masks_point_path_t *const pt0 = form->points ? form->points->data : NULL;
+ const float ref_old[2] = { pt0 ? pt0->corner[0] : 0.f, pt0 ? pt0->corner[1] : 0.f };
+
+ // Rotate each node's three control points (ctrl1, corner, ctrl2) around the
+ // screen centroid and project them back to image coordinates in one batch,
+ // then scatter the result into the path nodes.
+ float *const npts = dt_alloc_align_float((size_t)nc * 6);
+ if(npts)
+ {
+ dt_masks_rotate_ctrl_points(
+ darktable.develop, gpt->points, gpt->points_count, nc, cx, cy, c, s, iwidth, iheight, npts);
+
+ int k = 0;
+ for(GList *l = form->points; l; l = g_list_next(l), k++)
+ {
+ dt_masks_point_path_t *pt = l->data;
+ pt->ctrl1[0] = npts[k * 6 + 0];
+ pt->ctrl1[1] = npts[k * 6 + 1];
+ pt->corner[0] = npts[k * 6 + 2];
+ pt->corner[1] = npts[k * 6 + 3];
+ pt->ctrl2[0] = npts[k * 6 + 4];
+ pt->ctrl2[1] = npts[k * 6 + 5];
+ }
+ dt_free_align(npts);
+ }
+
+ // Source bookkeeping only matters for clone/heal forms (those with a source).
+ // - joint rotation: both shapes rotate by dv. The source inherits the
+ // rotation from the destination geometry, so source[2] is left untouched.
+ // - target-only rotation: cancel that inherited rotation so the source keeps
+ // its current orientation.
+ if(form->type & DT_MASKS_CLONE)
+ {
+ if(gui->counter_rotate_source)
+ form->source[2] -= dv;
+
+ // Hold the source-to-target offset constant by shifting the source anchor
+ // by the displacement of the reference corner. This keeps the source
+ // centroid in place (it spins or stays, but never orbits).
+ if(pt0)
+ {
+ form->source[0] += pt0->corner[0] - ref_old[0];
+ form->source[1] += pt0->corner[1] - ref_old[1];
+ }
+ }
+
+ // remember current mouse (backbuffer-normalized) for next incremental step
+ gui->dx = pzx;
+ gui->dy = pzy;
+
+ dt_masks_gui_form_create(form, gui, index, module);
+ dt_control_queue_redraw_center();
+ return 1;
+ }
gui->form_selected = FALSE;
gui->border_selected = FALSE;
@@ -2776,7 +3057,12 @@ static int _path_events_mouse_moved(dt_iop_module_t *module,
int near = 0;
float dist = 0;
_path_get_distance(pzx, pzy, as, gui, index, nb, &in, &inb, &near, &ins, &dist);
- gui->seg_selected = !gui->select_only_border && dist < sqf(as) ? near : -1;
+ // Note: segment selection is intentionally NOT gated on select_only_border
+ // (SHIFT). SHIFT now also means "add a node on a segment" (matching brushes),
+ // and SHIFT is what sets select_only_border -- gating here would make the
+ // segment deselect under SHIFT and break add-node. Border handles are matched
+ // earlier (and return first), so this only enables SHIFT+click add-node.
+ gui->seg_selected = dist < sqf(as) ? near : -1;
// no segment selected, set form or source selection
if(near < 0)
@@ -3951,8 +4237,16 @@ static GSList *_path_setup_mouse_actions(const dt_masks_form_t *const form)
_("[PATH on node] remove the node"));
lm = dt_mouse_action_create_simple(lm, DT_MOUSE_ACTION_RIGHT, 0,
_("[PATH on feather] reset curvature"));
- lm = dt_mouse_action_create_simple(lm, DT_MOUSE_ACTION_LEFT, GDK_CONTROL_MASK,
- _("[PATH on segment] add node"));
+ lm = dt_mouse_action_create_simple(
+ lm, DT_MOUSE_ACTION_LEFT, GDK_SHIFT_MASK, _("[PATH on segment] add node"));
+ lm = dt_mouse_action_create_simple(lm,
+ DT_MOUSE_ACTION_LEFT_DRAG,
+ GDK_CONTROL_MASK,
+ _("[PATH] rotate shape (source only on a clone source)"));
+ lm = dt_mouse_action_create_simple(lm,
+ DT_MOUSE_ACTION_LEFT_DRAG,
+ GDK_SHIFT_MASK | GDK_CONTROL_MASK,
+ _("[PATH] rotate shape and source together"));
lm = dt_mouse_action_create_simple(lm, DT_MOUSE_ACTION_SCROLL, 0,
_("[PATH] change size"));
lm = dt_mouse_action_create_simple(lm, DT_MOUSE_ACTION_SCROLL,
@@ -4001,15 +4295,27 @@ static void _path_set_hint_message(const dt_masks_form_gui_t *const gui,
msgbuf_len);
else if(gui->seg_selected >= 0)
g_strlcat(msgbuf,
- _("move segment: drag, add node: ctrl+click\n"
+ _("move segment: drag, add node: shift+click\n"
"remove path: right-click"),
msgbuf_len);
+ else if(gui->source_selected)
+ // the source only exists for clone/heal forms, so joint rotation always applies here
+ g_strlcat(msgbuf,
+ _("move: drag, rotate source: ctrl+drag, "
+ "rotate both: shift+ctrl+drag"),
+ msgbuf_len);
else if(gui->form_selected)
+ {
g_snprintf(msgbuf,
msgbuf_len,
_("size: scroll, feather size: shift+scroll\n"
- "opacity: ctrl+scroll (%d%%)"),
+ "opacity: ctrl+scroll (%d%%), rotate: ctrl+drag"),
opacity);
+ // joint rotation of both shapes only makes sense when there is a source
+ // (clone/heal forms, e.g. in the retouch module)
+ if(form->type & DT_MASKS_CLONE)
+ g_strlcat(msgbuf, _(", rotate both: shift+ctrl+drag"), msgbuf_len);
+ }
}
static void _path_duplicate_points(dt_develop_t *const dev,
diff --git a/src/iop/retouch.c b/src/iop/retouch.c
index 1ce4a9ddc2bc..8ffbcac1d1c4 100644
--- a/src/iop/retouch.c
+++ b/src/iop/retouch.c
@@ -897,12 +897,13 @@ static int rt_masks_point_calc_delta(dt_iop_module_t *self,
}
/* returns (dx dy) to get from the source to the destination */
-static int rt_masks_get_delta_to_destination(dt_iop_module_t *self,
+static int rt_masks_get_transform_to_destination(dt_iop_module_t *self,
dt_dev_pixelpipe_iop_t *piece,
const dt_iop_roi_t *roi,
dt_masks_form_t *form,
float *dx,
float *dy,
+ float *angle,
const int distort_mode)
{
int res = 0;
@@ -914,6 +915,13 @@ static int rt_masks_get_delta_to_destination(dt_iop_module_t *self,
res = rt_masks_point_calc_delta(self, piece, roi, pt->corner,
form->source, dx, dy, distort_mode);
}
+ else if(form->type & DT_MASKS_BRUSH)
+ {
+ const dt_masks_point_brush_t *pt = form->points->data;
+
+ res = rt_masks_point_calc_delta(self, piece, roi, pt->corner,
+ form->source, dx, dy, distort_mode);
+ }
else if(form->type & DT_MASKS_CIRCLE)
{
const dt_masks_point_circle_t *pt = form->points->data;
@@ -928,13 +936,9 @@ static int rt_masks_get_delta_to_destination(dt_iop_module_t *self,
res = rt_masks_point_calc_delta(self, piece, roi, pt->center,
form->source, dx, dy, distort_mode);
}
- else if(form->type & DT_MASKS_BRUSH)
- {
- const dt_masks_point_brush_t *pt = form->points->data;
- res = rt_masks_point_calc_delta(self, piece, roi, pt->corner,
- form->source, dx, dy, distort_mode);
- }
+ if (angle)
+ *angle = form->source[2];
return res;
}
@@ -2799,14 +2803,35 @@ static void rt_compute_roi_in(dt_iop_module_t *self,
if(p->rt_forms[index].algorithm == DT_IOP_RETOUCH_HEAL
|| p->rt_forms[index].algorithm == DT_IOP_RETOUCH_CLONE)
{
- float dx = 0.f, dy = 0.f;
- if(rt_masks_get_delta_to_destination(self, piece, roi_in, form, &dx, &dy,
- p->rt_forms[index].distort_mode))
+ float dx = 0.f, dy = 0.f, angle = 0.f;
+ if(rt_masks_get_transform_to_destination(
+ self, piece, roi_in, form, &dx, &dy, &angle, p->rt_forms[index].distort_mode))
{
+ // source region = destination box translated by (-dx, -dy)
roiy = fminf(ft - dy, roiy);
roix = fminf(fl - dx, roix);
roir = fmaxf(fl + fw - dx, roir);
roib = fmaxf(ft + fh - dy, roib);
+
+ // when the source is rotated, the box above does not bound the
+ // rotated footprint. dt_masks_get_source_area already applies the
+ // source rotation (it goes through the source point generation), so
+ // it gives the true rotated source bbox -- union it in.
+ if(!feqf(angle, 0.0f, 0.01f))
+ {
+ int sfl, sft, sfw, sfh;
+ if(dt_masks_get_source_area(self, piece, form, &sfw, &sfh, &sfl, &sft))
+ {
+ sfw *= roi_in->scale;
+ sfh *= roi_in->scale;
+ sfl *= roi_in->scale;
+ sft *= roi_in->scale;
+ roiy = fminf(sft, roiy);
+ roix = fminf(sfl, roix);
+ roir = fmaxf(sfl + sfw, roir);
+ roib = fmaxf(sft + sfh, roib);
+ }
+ }
}
}
}
@@ -2885,7 +2910,7 @@ static void rt_extend_roi_in_from_source_clones(dt_iop_module_t *self,
// get the destination area
int fl_dest, ft_dest;
float dx = 0.f, dy = 0.f;
- if(!rt_masks_get_delta_to_destination(self, piece, roi_in, form, &dx, &dy,
+ if(!rt_masks_get_transform_to_destination(self, piece, roi_in, form, &dx, &dy, NULL,
p->rt_forms[index].distort_mode))
{
continue;
@@ -3234,28 +3259,137 @@ static void rt_intersect_2_rois(dt_iop_roi_t *const roi_1,
roi_dest->height = y_to - y_from;
}
+// opacity-weighted centroid (center of mass) of a single-channel mask, in
+// roi_mask_scaled-local coords. The centroid is rotation-invariant, which is why
+// it -- not the bounding-box center -- is used as the rotation pivot: it stays
+// put as the source is rotated, matching the overlay which pivots about the same
+// (area) centroid (see _path_centroid / _brush_centroid).
+static void rt_mask_centroid(const float *const mask_scaled,
+ const dt_iop_roi_t *const roi_mask_scaled,
+ float *const cx,
+ float *const cy)
+{
+ const int w = roi_mask_scaled->width;
+ const int h = roi_mask_scaled->height;
+ double sx = 0.0, sy = 0.0, sw = 0.0;
+
+ for(int y = 0; y < h; y++)
+ {
+ const float *const m = mask_scaled + (size_t)y * w;
+ for(int x = 0; x < w; x++)
+ {
+ const float v = m[x];
+ sx += (double)v * x;
+ sy += (double)v * y;
+ sw += v;
+ }
+ }
+
+ if(sw > 1e-6)
+ {
+ *cx = (float)(sx / sw);
+ *cy = (float)(sy / sw);
+ }
+ else
+ {
+ *cx = w * 0.5f;
+ *cy = h * 0.5f;
+ }
+}
+
+// (cx, cy) is the rotation pivot in roi_out-local coords (the mask centroid);
+// only used when angle != 0.
static void rt_copy_in_to_out(const float *const in,
const dt_iop_roi_t *const roi_in,
float *const out,
const dt_iop_roi_t *const roi_out,
const int ch,
const int dx,
- const int dy)
+ const int dy,
+ const float angle,
+ const float cx,
+ const float cy)
{
const size_t rowsize = sizeof(float) * ch * MIN(roi_out->width, roi_in->width);
const int xoffs = roi_out->x - roi_in->x - dx;
const int yoffs = roi_out->y - roi_in->y - dy;
const int y_to = MIN(roi_out->height, roi_in->height);
- DT_OMP_FOR()
- for(int y = 0; y < y_to; y++)
+ // treat a negligibly small rotation as no rotation: skips the resampling
+ // path (cheaper) and avoids artifacts from repeated UI actions
+ if(feqf(angle, 0.0f, 0.01f))
{
- const size_t iindex = ((size_t)(y + yoffs) * roi_in->width + xoffs) * ch;
- const size_t oindex = (size_t)y * roi_out->width * ch;
- float *in1 = (float *)in + iindex;
- float *out1 = (float *)out + oindex;
+ DT_OMP_FOR()
+ for(int y = 0; y < y_to; y++)
+ {
+ const size_t iindex = ((size_t)(y + yoffs) * roi_in->width + xoffs) * ch;
+ const size_t oindex = (size_t)y * roi_out->width * ch;
+ const float *in1 = in + iindex;
+ float *out1 = out + oindex;
- memcpy(out1, in1, rowsize);
+ memcpy(out1, in1, rowsize);
+ }
+ }
+ else
+ {
+ // Sampling with rotation: the destination pixel at offset (rx,ry) from the
+ // mask centroid is filled with the image pixel that lies under the rotated
+ // source overlay at the matching offset, i.e. centroid + R(angle)*(rx,ry).
+ // This uses the SAME rotation and pivot as the on-screen source outline
+ // (masks code), so the overlay marks the region being copied.
+ const float c = cosf(angle);
+ const float s = sinf(angle);
+ // Rotation pivot: the mask centroid (passed in as cx, cy in roi_out-local
+ // coords), mapped into the source region in roi_in coordinates.
+ const float cx_source = cx + xoffs;
+ const float cy_source = cy + yoffs;
+ const int x_to = MIN(roi_out->width, roi_in->width);
+
+ DT_OMP_FOR()
+ for(int y = 0; y < y_to; y++)
+ {
+ for(int x = 0; x < x_to; x++)
+ {
+ // Translate to source, rotate around source center, get final position
+ const float sx = x + xoffs;
+ const float sy = y + yoffs;
+ const float rx = sx - cx_source;
+ const float ry = sy - cy_source;
+ const float ix = cx_source + rx * c - ry * s;
+ const float iy = cy_source + rx * s + ry * c;
+
+ const size_t oindex = ((size_t)y * roi_out->width + x) * ch;
+ float *out1 = out + oindex;
+
+ // Edge-clamp (replicate) rather than zero-filling out-of-bounds samples:
+ // rt_compute_roi_in grows roi_in to the rotation-aware source area, but it
+ // cannot grow past the actual image border (and the box corners outside
+ // the mask are not covered), so a rotated source near the image edge can
+ // still sample just outside roi_in. Replicating the border keeps a benign
+ // smear there instead of seeding black pixels, which would otherwise
+ // create a hard seam for heal.
+ const float ixc = CLAMPF(ix, 0.0f, roi_in->width - 1.0f);
+ const float iyc = CLAMPF(iy, 0.0f, roi_in->height - 1.0f);
+
+ const int x0 = CLAMP((int)ixc, 0, roi_in->width - 2);
+ const int y0 = CLAMP((int)iyc, 0, roi_in->height - 2);
+ const float dx0 = ixc - x0;
+ const float dy0 = iyc - y0;
+
+ const float *in00 = in + ((size_t)y0 * roi_in->width + x0) * ch;
+ const float *in10 = in + ((size_t)y0 * roi_in->width + x0 + 1) * ch;
+ const float *in01 = in + ((size_t)(y0 + 1) * roi_in->width + x0) * ch;
+ const float *in11 = in + ((size_t)(y0 + 1) * roi_in->width + x0 + 1) * ch;
+
+ for(int c_idx = 0; c_idx < ch; c_idx++)
+ {
+ const float val = in00[c_idx] * (1.0f - dx0) * (1.0f - dy0) +
+ in10[c_idx] * dx0 * (1.0f - dy0) + in01[c_idx] * (1.0f - dx0) * dy0 +
+ in11[c_idx] * dx0 * dy0;
+ out1[c_idx] = val;
+ }
+ }
+ }
}
}
@@ -3416,6 +3550,9 @@ static void _retouch_clone(float *const in,
dt_iop_roi_t *const roi_mask_scaled,
const int dx,
const int dy,
+ const float angle,
+ const float cx,
+ const float cy,
const float opacity)
{
// alloc temp image to avoid issues when areas self-intersects
@@ -3427,7 +3564,7 @@ static void _retouch_clone(float *const in,
}
// copy source image to tmp
- rt_copy_in_to_out(in, roi_in, img_src, roi_mask_scaled, 4, dx, dy);
+ rt_copy_in_to_out(in, roi_in, img_src, roi_mask_scaled, 4, dx, dy, angle, cx, cy);
// clone it
rt_copy_image_masked(img_src, in, roi_in, mask_scaled, roi_mask_scaled, opacity);
@@ -3460,7 +3597,7 @@ static void _retouch_blur(dt_iop_module_t *self,
// copy source image so we blur just the mask area (at least the
// smallest rect that covers it)
- rt_copy_in_to_out(in, roi_in, img_dest, roi_mask_scaled, 4, 0, 0);
+ rt_copy_in_to_out(in, roi_in, img_dest, roi_mask_scaled, 4, 0, 0, 0.0f, 0.0f, 0.0f);
if(blur_type == DT_IOP_RETOUCH_BLUR_GAUSSIAN && fabsf(blur_radius) > 0.1f)
{
@@ -3531,6 +3668,9 @@ static void _retouch_heal(float *const in,
dt_iop_roi_t *const roi_mask_scaled,
const int dx,
const int dy,
+ const float angle,
+ const float cx,
+ const float cy,
const float opacity,
const int max_iter)
{
@@ -3544,8 +3684,8 @@ static void _retouch_heal(float *const in,
}
// copy source and destination to temp images
- rt_copy_in_to_out(in, roi_in, img_src, roi_mask_scaled, 4, dx, dy);
- rt_copy_in_to_out(in, roi_in, img_dest, roi_mask_scaled, 4, 0, 0);
+ rt_copy_in_to_out(in, roi_in, img_src, roi_mask_scaled, 4, dx, dy, angle, cx, cy);
+ rt_copy_in_to_out(in, roi_in, img_dest, roi_mask_scaled, 4, 0, 0, 0.0f, 0.0f, 0.0f);
// heal it
dt_heal(img_src, img_dest, mask_scaled,
@@ -3658,11 +3798,11 @@ static void rt_process_forms(float *layer, dwt_params_t *const wt_p, const int s
// search the delta with the source
const dt_iop_retouch_algo_type_t algo = p->rt_forms[index].algorithm;
- float dx = 0.f, dy = 0.f;
+ float dx = 0.f, dy = 0.f, angle = 0.f;
if(algo != DT_IOP_RETOUCH_BLUR && algo != DT_IOP_RETOUCH_FILL)
{
- if(!rt_masks_get_delta_to_destination(self, piece, roi_layer, form, &dx, &dy,
+ if(!rt_masks_get_transform_to_destination(self, piece, roi_layer, form, &dx, &dy, &angle,
p->rt_forms[index].distort_mode))
{
dt_free_align(mask);
@@ -3689,6 +3829,11 @@ static void rt_process_forms(float *layer, dwt_params_t *const wt_p, const int s
continue;
}
+ // rotation pivot: mask centroid in roi_mask_scaled-local coords
+ float cx = 0.f, cy = 0.f;
+ if(!feqf(angle, 0.0f, 0.01f))
+ rt_mask_centroid(mask_scaled, &roi_mask_scaled, &cx, &cy);
+
if((dx != 0
|| dy != 0
|| algo == DT_IOP_RETOUCH_BLUR
@@ -3699,12 +3844,12 @@ static void rt_process_forms(float *layer, dwt_params_t *const wt_p, const int s
if(algo == DT_IOP_RETOUCH_CLONE)
{
_retouch_clone(layer, roi_layer, mask_scaled,
- &roi_mask_scaled, dx, dy, form_opacity);
+ &roi_mask_scaled, dx, dy, angle, cx, cy, form_opacity);
}
else if(algo == DT_IOP_RETOUCH_HEAL)
{
_retouch_heal(layer, roi_layer, mask_scaled,
- &roi_mask_scaled, dx, dy, form_opacity, p->max_heal_iter);
+ &roi_mask_scaled, dx, dy, angle, cx, cy, form_opacity, p->max_heal_iter);
}
else if(algo == DT_IOP_RETOUCH_BLUR)
{
@@ -3885,7 +4030,7 @@ void process(dt_iop_module_t *self,
}
// return final image
- rt_copy_in_to_out(in_retouch, roi_rt, ovoid, roi_out, 4, 0, 0);
+ rt_copy_in_to_out(in_retouch, roi_rt, ovoid, roi_out, 4, 0, 0, 0.0f, 0.0f, 0.0f);
cleanup:
dt_free_align(in_retouch);
@@ -3899,7 +4044,7 @@ void distort_mask(dt_iop_module_t *self,
const dt_iop_roi_t *const roi_in,
const dt_iop_roi_t *const roi_out)
{
- rt_copy_in_to_out(in, roi_in, out, roi_out, 1, 0, 0);
+ rt_copy_in_to_out(in, roi_in, out, roi_out, 1, 0, 0, 0.0f, 0.0f, 0.0f);
}
#ifdef HAVE_OPENCL
@@ -3984,6 +4129,9 @@ static cl_int rt_copy_in_to_out_cl(const int devid,
const dt_iop_roi_t *const roi_out,
const int dx,
const int dy,
+ const float angle,
+ const float cx,
+ const float cy,
const int kernel)
{
cl_int err = CL_MEM_OBJECT_ALLOCATION_FAILURE;
@@ -4002,7 +4150,7 @@ static cl_int rt_copy_in_to_out_cl(const int devid,
err = dt_opencl_enqueue_kernel_2d_args(devid, kernel, MIN(roi_out->width, roi_in->width), MIN(roi_out->height, roi_in->height),
CLARG(dev_in), CLARG(dev_roi_in),
CLARG(dev_out), CLARG(dev_roi_out),
- CLARG(xoffs), CLARG(yoffs));
+ CLARG(xoffs), CLARG(yoffs), CLARG(angle), CLARG(cx), CLARG(cy));
if(err != CL_SUCCESS)
dt_print(DT_DEBUG_ALWAYS, "rt_copy_in_to_out_cl error 2");
@@ -4120,6 +4268,9 @@ static cl_int _retouch_clone_cl(const int devid,
dt_iop_roi_t *const roi_mask_scaled,
const int dx,
const int dy,
+ const float angle,
+ const float cx,
+ const float cy,
const float opacity,
dt_iop_retouch_global_data_t *gd)
{
@@ -4131,7 +4282,7 @@ static cl_int _retouch_clone_cl(const int devid,
goto cleanup;
// copy source image to tmp
- err = rt_copy_in_to_out_cl(devid, dev_layer, roi_layer, dev_src, roi_mask_scaled, dx, dy,
+ err = rt_copy_in_to_out_cl(devid, dev_layer, roi_layer, dev_src, roi_mask_scaled, dx, dy, angle, cx, cy,
gd->kernel_retouch_copy_buffer_to_buffer);
if(err != CL_SUCCESS)
goto cleanup;
@@ -4214,7 +4365,7 @@ static cl_int _retouch_blur_cl(const int devid,
}
err = rt_copy_in_to_out_cl(devid, dev_layer, roi_layer, dev_dest,
- roi_mask_scaled, 0, 0,
+ roi_mask_scaled, 0, 0, 0.0f, 0.0f, 0.0f,
gd->kernel_retouch_copy_buffer_to_image);
if(err != CL_SUCCESS)
goto cleanup;
@@ -4279,14 +4430,17 @@ static cl_int _retouch_blur_cl(const int devid,
static cl_int _retouch_heal_cl(const int devid,
cl_mem dev_layer,
dt_iop_roi_t *const roi_layer,
- float *mask_scaled,
cl_mem dev_mask_scaled,
+ float *const mask_scaled,
dt_iop_roi_t *const roi_mask_scaled,
const int dx,
const int dy,
+ const float angle,
+ const float cx,
+ const float cy,
const float opacity,
- dt_iop_retouch_global_data_t *gd,
- const int max_iter)
+ const int max_iter,
+ dt_iop_retouch_global_data_t *gd)
{
cl_int err = CL_MEM_OBJECT_ALLOCATION_FAILURE;
@@ -4297,13 +4451,13 @@ static cl_int _retouch_heal_cl(const int devid,
goto cleanup;
err = rt_copy_in_to_out_cl(devid, dev_layer, roi_layer, dev_src,
- roi_mask_scaled, dx, dy,
+ roi_mask_scaled, dx, dy, angle, cx, cy,
gd->kernel_retouch_copy_buffer_to_buffer);
if(err != CL_SUCCESS)
goto cleanup;
err = rt_copy_in_to_out_cl(devid, dev_layer, roi_layer, dev_dest,
- roi_mask_scaled, 0, 0,
+ roi_mask_scaled, 0, 0, 0.0f, 0.0f, 0.0f,
gd->kernel_retouch_copy_buffer_to_buffer);
if(err != CL_SUCCESS)
goto cleanup;
@@ -4440,13 +4594,13 @@ static cl_int rt_process_forms_cl(cl_mem dev_layer,
continue;
}
- float dx = 0.f, dy = 0.f;
+ float dx = 0.f, dy = 0.f, angle = 0.f;
// search the delta with the source
const dt_iop_retouch_algo_type_t algo = p->rt_forms[index].algorithm;
if(algo != DT_IOP_RETOUCH_BLUR && algo != DT_IOP_RETOUCH_FILL)
{
- if(!rt_masks_get_delta_to_destination(self, piece, roi_layer, form, &dx, &dy,
+ if(!rt_masks_get_transform_to_destination(self, piece, roi_layer, form, &dx, &dy, &angle,
p->rt_forms[index].distort_mode))
{
dt_free_align(mask);
@@ -4466,6 +4620,12 @@ static cl_int rt_process_forms_cl(cl_mem dev_layer,
&roi_mask_scaled,
roi_layer, dx, dy, algo);
+ // rotation pivot: mask centroid in roi_mask_scaled-local coords
+ // (computed before mask_scaled may be freed below)
+ float cx = 0.f, cy = 0.f;
+ if(!feqf(angle, 0.0f, 0.01f) && mask_scaled != NULL)
+ rt_mask_centroid(mask_scaled, &roi_mask_scaled, &cx, &cy);
+
// only heal needs mask scaled
if(algo != DT_IOP_RETOUCH_HEAL && mask_scaled != NULL)
{
@@ -4494,14 +4654,14 @@ static cl_int rt_process_forms_cl(cl_mem dev_layer,
if(algo == DT_IOP_RETOUCH_CLONE)
{
err = _retouch_clone_cl(devid, dev_layer, roi_layer,
- dev_mask_scaled, &roi_mask_scaled, dx, dy,
+ dev_mask_scaled, &roi_mask_scaled, dx, dy, angle, cx, cy,
form_opacity, gd);
}
else if(algo == DT_IOP_RETOUCH_HEAL)
{
err = _retouch_heal_cl(devid, dev_layer, roi_layer,
- mask_scaled, dev_mask_scaled, &roi_mask_scaled, dx,
- dy, form_opacity, gd, p->max_heal_iter);
+ dev_mask_scaled, mask_scaled, &roi_mask_scaled, dx,
+ dy, angle, cx, cy, form_opacity, p->max_heal_iter, gd);
}
else if(algo == DT_IOP_RETOUCH_BLUR)
{
@@ -4711,7 +4871,7 @@ int process_cl(dt_iop_module_t *self,
}
// return final image
- err = rt_copy_in_to_out_cl(devid, in_retouch, roi_in, dev_out, roi_out, 0, 0,
+ err = rt_copy_in_to_out_cl(devid, in_retouch, roi_in, dev_out, roi_out, 0, 0, 0.0f, 0.0f, 0.0f,
gd->kernel_retouch_copy_buffer_to_image);
cleanup: