Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 129 additions & 2 deletions src/develop/develop.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions src/develop/masks/masks.c
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
61 changes: 47 additions & 14 deletions src/views/darkroom.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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?
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down
54 changes: 43 additions & 11 deletions src/views/view.c
Original file line number Diff line number Diff line change
Expand Up @@ -1946,34 +1946,66 @@ void dt_view_paint_surface(cairo_t *cr,
const int maxw = MIN(port->width, backbuf_scale * processed_width * (1<<closeup) / ppd);
const int maxh = MIN(port->height, backbuf_scale * processed_height * (1<<closeup) / ppd);

// Position the clip (and assessment frame) from the viewport centre (zoom) and
// the IMAGE's own geometry, NOT from the backbuf ROI centre (offset). The ROI
// centre is clamped to the image, and while the viewport is panned outside the
// picture to reach off-image mask handles it lags the live zoom in discrete
// re-render steps. Driving the clip from it made the clip sawtooth back and
// forth on every re-render, even though the image content -- scaled from the
// backbuf to the live zoom by back_scale below -- stays put. img_w/img_h are
// the full image in screen pixels; on screen the image spans
// [(-0.5 - zoom) * img .. (0.5 - zoom) * img] about the viewport centre.
const double img_w = processed_width * backbuf_scale * (1 << closeup) / ppd;
const double img_h = processed_height * backbuf_scale * (1 << closeup) / ppd;

if(port->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<<closeup) / ppd;
const double trans_x = (offset_x - zoom_x) * processed_width * buf_scale - 0.5 * buf_width;
const double trans_y = (offset_y - zoom_y) * processed_height * buf_scale - 0.5 * buf_height;

// The coverage test below must measure the pan the renderer can still fix, not
// the raw one. The render ROI is clamped to the image (see dt_dev_process_image),
// so when the viewport is panned outside the picture to reach off-image mask
// handles, the backbuf centre (offset) can get no closer to the viewport centre
// (zoom) than the image edge. Measuring against the raw zoom centre would keep
// reporting the off-image (background) margin as "not covered" and re-trigger
// the pipe every frame, making the clip jitter. So measure against reach_*, the
// closest centre the renderer can actually reach: when offset already sits there
// a re-render cannot improve coverage, and the residual is harmless background.
const float rhx = 0.5f * buf_width / fmaxf(1.0f, (float)processed_width * buf_scale);
const float rhy = 0.5f * buf_height / fmaxf(1.0f, (float)processed_height * buf_scale);
const float reach_x = rhx >= 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)
{
Expand Down
Loading