Skip to content

Commit

Permalink
Add the "align object" tool (#961)
Browse files Browse the repository at this point in the history
* Move bound detection to a dedicated utility method: GetObjectBounds

* Basic implementation of align object

* Use a grid for the dialog

* Add support for aligning within a selection

* Move align object to the effects menu, under a new section "object"

* Add align object tests

* move feather effect to the object category

* use background color from selection
  • Loading branch information
Matthieu-LAURENT39 authored Sep 7, 2024
1 parent c4bb350 commit 23d11ec
Show file tree
Hide file tree
Showing 14 changed files with 382 additions and 62 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Thanks to the following contributors who worked on this release:
- Added "Voronoi Diagram" effect (#692)
- Ported "Vignette" and "Dents" effects from Paint.NET 3.36 (#881, #885)
- Added "Feather" effect (#886, #953)
- Added "Align object" effect (#936, #961)
- Added support for exporting to portable pixmap (`.ppm`) files (#549)
- Importing is not supported yet.
- Added a nearest-neighbor resampling mode when resizing images (#596)
Expand Down
60 changes: 1 addition & 59 deletions Pinta.Core/Actions/ImageActions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -212,73 +212,15 @@ private void HandlePintaCoreActionsImageCropToSelectionActivated (object sender,
CropImageToRectangle (doc, rect, doc.Selection.SelectionPath);
}

/// <summary>
/// Checks if all of the pixels in the row match the specified color.
/// </summary>
private static bool IsConstantRow (ImageSurface surf, Cairo.Color color, int y)
{
for (int x = 0; x < surf.Width; ++x) {
if (!color.Equals (surf.GetColorBgra (new (x, y)).ToCairoColor ()))
return false;
}

return true;
}

/// <summary>
/// Checks if all of the pixels in the column (within the bounds of the rectangle) match the specified color.
/// </summary>
private static bool IsConstantColumn (ImageSurface surf, Cairo.Color color, RectangleI rect, int x)
{
for (int y = rect.Top; y < rect.Bottom; ++y) {
if (!color.Equals (surf.GetColorBgra (new (x, y)).ToCairoColor ()))
return false;
}

return true;
}

private void HandlePintaCoreActionsImageAutoCropActivated (object sender, EventArgs e)
{
Document doc = workspace.ActiveDocument;

tools.Commit ();

var image = doc.GetFlattenedImage ();
RectangleI rect = image.GetBounds ();
Cairo.Color border_color = image.GetColorBgra (PointI.Zero).ToCairoColor ();

// Top down.
for (int y = 0; y < image.Height; ++y) {
if (!IsConstantRow (image, border_color, y))
break;

rect = rect with { Y = rect.Y + 1, Height = rect.Height - 1 };
}

// Bottom up.
for (int y = rect.Bottom; y >= rect.Top; --y) {
if (!IsConstantRow (image, border_color, y))
break;

rect = rect with { Height = rect.Height - 1 };
}

// Left side.
for (int x = 0; x < image.Width; ++x) {
if (!IsConstantColumn (image, border_color, rect, x))
break;

rect = rect with { X = rect.X + 1, Width = rect.Width - 1 };
}

// Right side.
for (int x = rect.Right; x >= rect.Left; --x) {
if (!IsConstantColumn (image, border_color, rect, x))
break;

rect = rect with { Width = rect.Width - 1 };
}
var rect = Utility.GetObjectBounds (image);

// Ignore the current selection when auto-cropping.
CropImageToRectangle (doc, rect, /*selection*/ null);
Expand Down
69 changes: 69 additions & 0 deletions Pinta.Core/Effects/Utility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using System.Collections.Generic;
using System.Numerics;
using System.Reflection;
using Cairo;

namespace Pinta.Core;

Expand Down Expand Up @@ -480,4 +481,72 @@ public static RadiansAngle GetNearestStepAngle (RadiansAngle angle, int steps)

return new (sector);
}

/// <summary>
/// Checks if all of the pixels in the row match the specified color.
/// </summary>
private static bool IsConstantRow (ImageSurface surf, Cairo.Color color, RectangleI rect, int y)
{
for (int x = rect.Left; x < rect.Right; ++x) {
if (!color.Equals (surf.GetColorBgra (new (x, y)).ToCairoColor ()))
return false;
}

return true;
}

/// <summary>
/// Checks if all of the pixels in the column (within the bounds of the rectangle) match the specified color.
/// </summary>
private static bool IsConstantColumn (ImageSurface surf, Cairo.Color color, RectangleI rect, int x)
{
for (int y = rect.Top; y < rect.Bottom; ++y) {
if (!color.Equals (surf.GetColorBgra (new (x, y)).ToCairoColor ()))
return false;
}

return true;
}

public static RectangleI GetObjectBounds (ImageSurface image, RectangleI? searchArea = null)
{
// Use the entire image bounds by default, or restrict to the provided search area.
RectangleI rect = searchArea ?? image.GetBounds ();
// Get the background color from the top-left pixel of the rectangle
Cairo.Color borderColor = image.GetColorBgra (new PointI (rect.Left, rect.Top)).ToCairoColor ();

// Top down.
for (int y = rect.Top; y < rect.Bottom; ++y) {
if (!IsConstantRow (image, borderColor, rect, y))
break;

rect = rect with { Y = rect.Y + 1, Height = rect.Height - 1 };
}

// Bottom up.
for (int y = rect.Bottom - 1; y >= rect.Top; --y) {
if (!IsConstantRow (image, borderColor, rect, y))
break;

rect = rect with { Height = rect.Height - 1 };
}

// Left side.
for (int x = rect.Left; x < rect.Right; ++x) {
if (!IsConstantColumn (image, borderColor, rect, x))
break;

rect = rect with { X = rect.X + 1, Width = rect.Width - 1 };
}

// Right side.
for (int x = rect.Right - 1; x >= rect.Left; --x) {
if (!IsConstantColumn (image, borderColor, rect, x))
break;

rect = rect with { Width = rect.Width - 1 };
}

return rect;
}
}
2 changes: 2 additions & 0 deletions Pinta.Effects/CoreEffectsExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ public void Initialize ()

// Add the effects
PintaCore.Effects.RegisterEffect (new AddNoiseEffect (services));
PintaCore.Effects.RegisterEffect (new AlignObjectEffect (services));
PintaCore.Effects.RegisterEffect (new BulgeEffect (services));
PintaCore.Effects.RegisterEffect (new CloudsEffect (services));
PintaCore.Effects.RegisterEffect (new DentsEffect (services));
Expand Down Expand Up @@ -107,6 +108,7 @@ public void Uninitialize ()

// Remove the effects
PintaCore.Effects.UnregisterInstanceOfEffect (typeof (AddNoiseEffect));
PintaCore.Effects.UnregisterInstanceOfEffect (typeof (AlignObjectEffect));
PintaCore.Effects.UnregisterInstanceOfEffect (typeof (BulgeEffect));
PintaCore.Effects.UnregisterInstanceOfEffect (typeof (CloudsEffect));
PintaCore.Effects.UnregisterInstanceOfEffect (typeof (DentsEffect));
Expand Down
117 changes: 117 additions & 0 deletions Pinta.Effects/Dialogs/Effects.AlignmentDialog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
using System;
using Gtk;
using Pinta.Core;

namespace Pinta.Effects;

public sealed class AlignmentDialog : Gtk.Dialog
{
private readonly Gtk.ToggleButton topLeft;
private readonly Gtk.ToggleButton topCenter;
private readonly Gtk.ToggleButton topRight;
private readonly Gtk.ToggleButton centerLeft;
private readonly Gtk.ToggleButton center;
private readonly Gtk.ToggleButton centerRight;
private readonly Gtk.ToggleButton bottomLeft;
private readonly Gtk.ToggleButton bottomCenter;
private readonly Gtk.ToggleButton bottomRight;

public AlignPosition SelectedPosition { get; private set; }

public event EventHandler? PositionChanged;

public AlignmentDialog (IChromeService chrome)
{
const int spacing = 6;

var grid = new Gtk.Grid {
RowSpacing = spacing,
ColumnSpacing = spacing,
RowHomogeneous = true,
ColumnHomogeneous = true,
MarginStart = 12,
MarginEnd = 12,
MarginTop = 12,
MarginBottom = 12
};

topLeft = CreateIconButton (Translations.GetString ("Top Left"), Resources.Icons.ResizeCanvasNW, AlignPosition.TopLeft);
topCenter = CreateIconButton (Translations.GetString ("Top Center"), Resources.Icons.ResizeCanvasUp, AlignPosition.TopCenter);
topRight = CreateIconButton (Translations.GetString ("Top Right"), Resources.Icons.ResizeCanvasNE, AlignPosition.TopRight);
centerLeft = CreateIconButton (Translations.GetString ("Center Left"), Resources.Icons.ResizeCanvasLeft, AlignPosition.CenterLeft);
center = CreateIconButton (Translations.GetString ("Center"), Resources.Icons.ResizeCanvasBase, AlignPosition.Center);
centerRight = CreateIconButton (Translations.GetString ("Center Right"), Resources.Icons.ResizeCanvasRight, AlignPosition.CenterRight);
bottomLeft = CreateIconButton (Translations.GetString ("Bottom Left"), Resources.Icons.ResizeCanvasSW, AlignPosition.BottomLeft);
bottomCenter = CreateIconButton (Translations.GetString ("Bottom Center"), Resources.Icons.ResizeCanvasDown, AlignPosition.BottomCenter);
bottomRight = CreateIconButton (Translations.GetString ("Bottom Right"), Resources.Icons.ResizeCanvasSE, AlignPosition.BottomRight);

// Add buttons to the grid
grid.Attach (topLeft, 0, 0, 1, 1);
grid.Attach (topCenter, 1, 0, 1, 1);
grid.Attach (topRight, 2, 0, 1, 1);
grid.Attach (centerLeft, 0, 1, 1, 1);
grid.Attach (center, 1, 1, 1, 1);
grid.Attach (centerRight, 2, 1, 1, 1);
grid.Attach (bottomLeft, 0, 2, 1, 1);
grid.Attach (bottomCenter, 1, 2, 1, 1);
grid.Attach (bottomRight, 2, 2, 1, 1);

// Set the default selection
SetSelectedPosition (AlignPosition.Center);

var content_area = this.GetContentAreaBox ();
content_area.Append (grid);

Title = Translations.GetString ("Align Object");

TransientFor = chrome.MainWindow;

Modal = true;

Resizable = false;

this.AddCancelOkButtons ();
this.SetDefaultResponse (Gtk.ResponseType.Ok);

Show ();
}

private Gtk.ToggleButton CreateIconButton (string tooltip, string iconName, AlignPosition position)
{
var button = new Gtk.ToggleButton ();
button.SetIconName (iconName);

button.TooltipText = tooltip;

button.OnClicked += (sender, args) => {
SetSelectedPosition (position);
PositionChanged?.Invoke (this, EventArgs.Empty);
};

return button;
}

private void SetSelectedPosition (AlignPosition position)
{
SelectedPosition = position;

topLeft.SetActive (position == AlignPosition.TopLeft);
topCenter.SetActive (position == AlignPosition.TopCenter);
topRight.SetActive (position == AlignPosition.TopRight);
centerLeft.SetActive (position == AlignPosition.CenterLeft);
center.SetActive (position == AlignPosition.Center);
centerRight.SetActive (position == AlignPosition.CenterRight);
bottomLeft.SetActive (position == AlignPosition.BottomLeft);
bottomCenter.SetActive (position == AlignPosition.BottomCenter);
bottomRight.SetActive (position == AlignPosition.BottomRight);
}

public void RunDialog ()
{
Present ();

this.OnResponse += (sender, args) => {
Destroy ();
};
}
}
Loading

0 comments on commit 23d11ec

Please sign in to comment.