From 077dab6276858634ab24a5732a1451fd19706a9c Mon Sep 17 00:00:00 2001 From: Daniele Pighin Date: Sat, 6 Jun 2026 18:36:00 +0200 Subject: [PATCH] Better mask editing near/outside image borders. - When creating a mask, expand the canvas around the image to allow to position points outside the image area. - When editing mask elements, the canvas is always expanded to make all control points accessible. --- src/develop/develop.c | 131 +++++++++++++++++++++++++++++++++++++- src/develop/masks/masks.c | 7 ++ src/views/darkroom.c | 61 ++++++++++++++---- src/views/view.c | 54 ++++++++++++---- 4 files changed, 226 insertions(+), 27 deletions(-) diff --git a/src/develop/develop.c b/src/develop/develop.c index eef9b34d3596..113de9093638 100644 --- a/src/develop/develop.c +++ b/src/develop/develop.c @@ -52,6 +52,14 @@ #define DT_DEV_AVERAGE_DELAY_COUNT 5 +// Margins (as a fraction of the image size) by which the editable "canvas" may +// extend beyond the image while working on a mask, so off-image content can be +// panned into view. MASK_HANDLE_MARGIN keeps an off-image node clear of the +// viewport border; MASK_CREATION_MARGIN gives room to drop new nodes outside +// the picture while drawing. +#define MASK_HANDLE_MARGIN 0.03f +#define MASK_CREATION_MARGIN 0.15f + // Forward declaration static inline void _dt_dev_load_raw(dt_develop_t *dev, const dt_imgid_t imgid); @@ -3065,6 +3073,124 @@ static gboolean _dev_distort_transform_locked(dt_develop_t *dev, return TRUE; } +// Compute the bounding box of the mask overlay currently being edited +// (all displayed points, borders and clone sources), expressed in +// normalised image coordinates where the image spans [-0.5, 0.5]. The +// overlay points live in preview-pipe output space, so we normalise by +// the preview-pipe processed size. Returns FALSE when no overlay is +// available, leaving the outputs untouched. Used to let the user pan +// beyond the canvas to reach mask handles that lie outside it. +static gboolean +_dev_mask_overlay_bounds(const dt_develop_t *dev, float *x0, float *y0, float *x1, float *y1) +{ + if(!dev->form_visible || !dev->form_gui || !dev->form_gui->points) + return FALSE; + + float wd, ht; + if(!dt_dev_get_preview_size(dev, &wd, &ht)) + return FALSE; + + float minx = FLT_MAX, miny = FLT_MAX, maxx = -FLT_MAX, maxy = -FLT_MAX; + + for(GList *l = dev->form_gui->points; l; l = g_list_next(l)) + { + const dt_masks_form_gui_points_t *gpt = l->data; + if(!gpt) + continue; + + // points[], border[] and source[] are interleaved (x, y) pairs + const float *const arrays[3] = { gpt->points, gpt->border, gpt->source }; + const int counts[3] = { gpt->points_count, gpt->border_count, gpt->source_count }; + for(int a = 0; a < 3; a++) + { + const float *p = arrays[a]; + if(!p) + continue; + for(int i = 0; i < counts[a]; i++) + { + const float px = p[i * 2]; + const float py = p[i * 2 + 1]; + minx = fminf(minx, px); + maxx = fmaxf(maxx, px); + miny = fminf(miny, py); + maxy = fmaxf(maxy, py); + } + } + } + + if(minx > maxx) // no points contributed to the bounding box + return FALSE; + + *x0 = minx / wd - 0.5f; + *x1 = maxx / wd - 0.5f; + *y0 = miny / ht - 0.5f; + *y1 = maxy / ht - 0.5f; + return TRUE; +} + +// Clamp the viewport centre (zoom_x, zoom_y, in [-0.5, 0.5] image space) to the +// editable "canvas". Normally the canvas is just the image, so the viewport +// can't pan past the picture. While working on a mask the canvas is enlarged so +// off-image content can be panned into view (it is otherwise never on screen +// once the image fills the viewport, e.g. at fit): +// - while drawing, by MASK_CREATION_MARGIN all round, giving room to drop new +// nodes outside the picture; +// - by any overlay point already outside the image (e.g. a node dragged past +// the edge), plus MASK_HANDLE_MARGIN so it isn't flush to the border. +// boxw/boxh are the viewport extents in image units along each axis. +static void _clamp_zoom_to_mask( + const dt_develop_t *dev, const float boxw, const float boxh, float *zoom_x, float *zoom_y) +{ + const gboolean creating = dev->form_gui && dev->form_gui->creation; + + float lox = -0.5f, hix = 0.5f, loy = -0.5f, hiy = 0.5f; + if(creating) + { + lox -= MASK_CREATION_MARGIN; + hix += MASK_CREATION_MARGIN; + loy -= MASK_CREATION_MARGIN; + hiy += MASK_CREATION_MARGIN; + } + float mx0, my0, mx1, my1; + if(_dev_mask_overlay_bounds(dev, &mx0, &my0, &mx1, &my1)) + { + if(mx0 < -0.5f) + lox = fminf(lox, mx0 - MASK_HANDLE_MARGIN); + if(mx1 > 0.5f) + hix = fmaxf(hix, mx1 + MASK_HANDLE_MARGIN); + if(my0 < -0.5f) + loy = fminf(loy, my0 - MASK_HANDLE_MARGIN); + if(my1 > 0.5f) + hiy = fmaxf(hiy, my1 + MASK_HANDLE_MARGIN); + } + + // Clamp the viewport CENTRE to the editable region. On each side the centre + // may travel between two positions: + // + // - its "home": image centred when the image fits the viewport (homew == 0), + // or the image edge held at the viewport edge when the image is larger than + // the viewport (homew == 0.5 - halfw, the plain darktable clamp); + // - the node position (lox/hix, MASK_HANDLE_MARGIN already included), if that + // side was extended for off-image mask content, so the node can be reached. + // + // The home position is NOT forced -- the incoming (cursor-anchored or panned) + // centre is merely clamped into [home..node] -- so a plain zoom that leaves + // the centre near home keeps the image centred, while a deliberate pan can + // still travel out to a node. Crucially the node bound does not depend on + // zoom, so the framing neither drifts nor swims as you zoom in/out while + // reaching an off-image node (an earlier "... - halfw" bound, or centring the + // whole canvas, dragged the picture across the screen on every zoom step). + const float halfw = 0.5f * boxw, halfh = 0.5f * boxh; + const float homew = boxw >= 1.0f ? 0.0f : 0.5f - halfw; + const float homeh = boxh >= 1.0f ? 0.0f : 0.5f - halfh; + const float cminx = (lox < -0.5f) ? lox : -homew; + const float cmaxx = (hix > 0.5f) ? hix : homew; + const float cminy = (loy < -0.5f) ? loy : -homeh; + const float cmaxy = (hiy > 0.5f) ? hiy : homeh; + *zoom_x = CLAMP(*zoom_x, cminx, cmaxx); + *zoom_y = CLAMP(*zoom_y, cminy, cmaxy); +} + void dt_dev_zoom_move(dt_dev_viewport_t *port, dt_dev_zoom_t zoom, float scale, @@ -3230,8 +3356,9 @@ void dt_dev_zoom_move(dt_dev_viewport_t *port, zoom_y += mouse_off_y / cur_scale - mouse_off_y / new_scale; } - zoom_x = boxw > 1.0f ? 0.0f : CLAMP(zoom_x, boxw / 2 - .5, .5 - boxw / 2); - zoom_y = boxh > 1.0f ? 0.0f : CLAMP(zoom_y, boxh / 2 - .5, .5 - boxh / 2); + // While editing a mask, allow panning beyond the canvas to reach + // handles that lie outside the image (see helper for the details). + _clamp_zoom_to_mask(dev, boxw, boxh, &zoom_x, &zoom_y); } pts[0] = (zoom_x + 0.5f) * procw; diff --git a/src/develop/masks/masks.c b/src/develop/masks/masks.c index 031eb52e104e..37d816b53ec7 100644 --- a/src/develop/masks/masks.c +++ b/src/develop/masks/masks.c @@ -1456,6 +1456,13 @@ void dt_masks_set_edit_mode(dt_iop_module_t *module, gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(bd->masks_edit), value == DT_MASKS_EDIT_OFF ? FALSE : TRUE); + // Hiding (or changing) the mask overlay shrinks the editable canvas back to + // the image, so a viewport that was panned to reach off-image handles is now + // stale. Re-validate it: this no-op clamp snaps the pan back into bounds so + // the picture re-centres immediately instead of lingering off-centre (which + // also leaves the renderer churning) until the next manual pan. + dt_dev_zoom_move(&darktable.develop->full, DT_ZOOM_MOVE, 0.0f, 0, 0.0f, 0.0f, TRUE); + dt_control_queue_redraw_center(); } diff --git a/src/views/darkroom.c b/src/views/darkroom.c index dae3d0311977..b48aa6df6cb7 100644 --- a/src/views/darkroom.c +++ b/src/views/darkroom.c @@ -561,7 +561,14 @@ void expose(dt_view_t *self, float zoom_x, zoom_y, boxw, boxh; float zbound_x = FLT_MAX, zbound_y = 0.0f; if(!dt_dev_get_zoom_bounds(port, &zoom_x, &zoom_y, &boxw, &boxh)) + { + // get_zoom_bounds reports no scrollable bounds at fit / when the image + // fits the viewport, but the viewport may still be panned off-centre to + // reach mask nodes outside the image. Use the actual stored centre (0 + // when not panned) so that pan is rendered; the box spans the full image. + dt_dev_get_viewport_params(port, NULL, NULL, &zoom_x, &zoom_y); boxw = boxh = 1.0f; + } else { zbound_x = zoom_x; @@ -572,20 +579,36 @@ void expose(dt_view_t *self, because adding a slider will change the image area and might force a resizing in next expose. So we disable in cases close to full. + + This near-fit centring is applied only to the values handed to the + scrollbar; the real zoom_x/zoom_y are kept for rendering so the mask + overlay (and guides/pickers) follow the image exactly even when the + user has deliberately panned off-canvas below the fit zoom to reach + mask handles outside the image. Zeroing them here would pin the + overlay at the image centre while the image itself (and raster mask + overlay) follow the pan, breaking alignment below fit. */ + float sb_zoom_x = zoom_x, sb_zoom_y = zoom_y, sb_boxw = boxw, sb_boxh = boxh; if(boxw > 0.95f) { - zoom_x = .0f; - boxw = 1.01f; + sb_zoom_x = .0f; + sb_boxw = 1.01f; } if(boxh > 0.95f) { - zoom_y = .0f; - boxh = 1.01f; + sb_zoom_y = .0f; + sb_boxh = 1.01f; } - dt_view_set_scrollbar(self, zoom_x, -0.5 + boxw/2, 0.5, - boxw/2, zoom_y, -0.5+ boxh/2, 0.5, boxh/2); + dt_view_set_scrollbar(self, + sb_zoom_x, + -0.5 + sb_boxw / 2, + 0.5, + sb_boxw / 2, + sb_zoom_y, + -0.5 + sb_boxh / 2, + 0.5, + sb_boxh / 2); const gboolean expose_full = port->pipe->backbuf // do we have an image? @@ -776,11 +799,12 @@ void expose(dt_view_t *self, 0.0f, DT_DEV_TRANSFORM_DIR_ALL_GEOMETRY, prev_pts, 1); const float pp_wd = dev->preview_pipe->processed_width; const float pp_ht = dev->preview_pipe->processed_height; - float zoom_x_vp = pp_wd > 0 ? prev_pts[0] / pp_wd - 0.5f : 0.f; - float zoom_y_vp = pp_ht > 0 ? prev_pts[1] / pp_ht - 0.5f : 0.f; - // apply same clamping as zoom_x/zoom_y (image fits nearly entirely in view) - if(boxw > 0.95f) zoom_x_vp = 0.f; - if(boxh > 0.95f) zoom_y_vp = 0.f; + // Preview-pipe equivalent of the (real) full-pipe viewport centre. Kept + // in lock-step with zoom_x/zoom_y (not zeroed near fit) so the sub-pixel + // correction below stays small and the overlay tracks the image at every + // zoom level, including when panned off-canvas below the fit zoom. + const float zoom_x_vp = pp_wd > 0 ? prev_pts[0] / pp_wd - 0.5f : 0.f; + const float zoom_y_vp = pp_ht > 0 ? prev_pts[1] / pp_ht - 0.5f : 0.f; // don't draw guides and color pickers on image margins cairo_rectangle(cri, tb, tb, width - 2.0 * tb, height - 2.0 * tb); @@ -848,9 +872,18 @@ void expose(dt_view_t *self, cairo_rectangle(cri, vp_x, vp_y, vp_w, vp_h); cairo_clip(cri); // Mask overlay points are in preview-pipe output space; shift the - // coordinate origin by the sub-pixel difference between full-pipe - // and preview-pipe viewport centres so overlays land on the image. - cairo_translate(cri, (zoom_x - zoom_x_vp) * wd, (zoom_y - zoom_y_vp) * ht); + // coordinate origin by the difference between full-pipe and + // preview-pipe viewport centres so overlays land on the image. This + // correction is normally sub-pixel, but when panning beyond the canvas + // (to reach off-canvas handles) the preview-pipe forward transform can + // extrapolate a non-linear geometric distortion far outside the image + // and blow up; cap its magnitude so it can't drag the overlay off the + // image content (which would otherwise persist until the next + // reprocess). + const float max_corr = 1.5f; // pixels + const float corr_x = CLAMP((zoom_x - zoom_x_vp) * wd, -max_corr, max_corr); + const float corr_y = CLAMP((zoom_y - zoom_y_vp) * ht, -max_corr, max_corr); + cairo_translate(cri, corr_x, corr_y); dt_masks_events_post_expose(dmod, cri, width, height, 0.0f, 0.0f, zoom_scale); cairo_restore(cri); } diff --git a/src/views/view.c b/src/views/view.c index 4f6fa99f99f0..0372e5c6d43a 100644 --- a/src/views/view.c +++ b/src/views/view.c @@ -1946,34 +1946,66 @@ void dt_view_paint_surface(cairo_t *cr, const int maxw = MIN(port->width, backbuf_scale * processed_width * (1<height, backbuf_scale * processed_height * (1<color_assessment && window != DT_WINDOW_SLIDESHOW) { // draw the white frame around picture const double ratio = dt_conf_get_float("darkroom/ui/color_assessment_border_white_ratio"); - const double borw = maxw + 2.0 * tb * ratio; - const double borh = maxh + 2.0 * tb * ratio; - cairo_rectangle(cr, -0.5 * borw, -0.5 * borh, borw, borh); + const double borw = img_w + 2.0 * tb * ratio; + const double borh = img_h + 2.0 * tb * ratio; + cairo_rectangle(cr, -0.5 * borw - zoom_x * img_w, -0.5 * borh - zoom_y * img_h, borw, borh); dt_gui_gtk_set_source_rgb(cr, DT_GUI_COLOR_COLOR_ASSESSMENT_FG); cairo_fill(cr); } - cairo_rectangle(cr, -0.5 * maxw, -0.5 * maxh, maxw, maxh); + // clip to the image rectangle intersected with the viewport + const double clip_x0 = fmax(-0.5 * port->width, (-0.5 - zoom_x) * img_w); + const double clip_x1 = fmin(0.5 * port->width, (0.5 - zoom_x) * img_w); + const double clip_y0 = fmax(-0.5 * port->height, (-0.5 - zoom_y) * img_h); + const double clip_y1 = fmin(0.5 * port->height, (0.5 - zoom_y) * img_h); + cairo_rectangle(cr, clip_x0, clip_y0, fmax(0.0, clip_x1 - clip_x0), fmax(0.0, clip_y1 - clip_y0)); cairo_clip(cr); const double back_scale = (buf_scale == 0 ? 1.0 : backbuf_scale / buf_scale) * (1<= 0.5f ? 0.0f : CLAMP(zoom_x, -(0.5f - rhx), 0.5f - rhx); + const float reach_y = rhy >= 0.5f ? 0.0f : CLAMP(zoom_y, -(0.5f - rhy), 0.5f - rhy); + const double cov_trans_x = (offset_x - reach_x) * processed_width * buf_scale - 0.5 * buf_width; + const double cov_trans_y = (offset_y - reach_y) * processed_height * buf_scale - 0.5 * buf_height; + // Check if we should use the preview pipe for fallback rendering // This is only valid for the main develop (not for pinned images which have dev != darktable.develop) const gboolean use_preview_fallback = - (dev == darktable.develop) - && pp->output_imgid == dev->image_storage.id - && (port->pipe->output_imgid != dev->image_storage.id - || fabsf(backbuf_scale / buf_scale - 1.0f) > .09f - || floor(maxw / 2 / back_scale) - 1 > MIN(- trans_x, trans_x + buf_width) - || floor(maxh / 2 / back_scale) - 1 > MIN(- trans_y, trans_y + buf_height)) - && (port == &dev->full || port == &dev->preview2); + (dev == darktable.develop) && pp->output_imgid == dev->image_storage.id && + (port->pipe->output_imgid != dev->image_storage.id || + fabsf(backbuf_scale / buf_scale - 1.0f) > .09f || + floor(maxw / 2 / back_scale) - 1 > MIN(-cov_trans_x, cov_trans_x + buf_width) || + floor(maxh / 2 / back_scale) - 1 > MIN(-cov_trans_y, cov_trans_y + buf_height)) && + (port == &dev->full || port == &dev->preview2); if(use_preview_fallback) {