2023-06-30 02:10:24 +00:00
|
|
|
using OpenTK.Graphics.OpenGL4;
|
|
|
|
using OpenTK.Mathematics;
|
|
|
|
using OpenTK.Windowing.Common;
|
2023-07-26 14:00:46 +00:00
|
|
|
using OpenTK.Windowing.Common.Input;
|
2023-06-30 02:10:24 +00:00
|
|
|
using OpenTK.Windowing.Desktop;
|
|
|
|
using OpenTK.Windowing.GraphicsLibraryFramework;
|
2023-07-08 03:41:32 +00:00
|
|
|
// https://docs.sixlabors.com/api/ImageSharp/SixLabors.ImageSharp.Image.html
|
2023-07-23 22:42:08 +00:00
|
|
|
using SixLabors.Fonts;
|
|
|
|
using SixLabors.ImageSharp.Drawing.Processing;
|
|
|
|
using SixLabors.ImageSharp.Drawing;
|
2023-07-28 19:52:36 +00:00
|
|
|
using SixLabors.ImageSharp.Formats.Jpeg;
|
2023-07-26 02:19:18 +00:00
|
|
|
using System;
|
2023-07-26 01:44:22 +00:00
|
|
|
using System.Diagnostics;
|
2023-06-30 02:10:24 +00:00
|
|
|
|
|
|
|
namespace SemiColinGames;
|
|
|
|
|
2023-07-26 02:19:18 +00:00
|
|
|
public class FpsCounter {
|
2023-07-26 15:25:14 +00:00
|
|
|
private readonly int[] frameTimes = new int[30];
|
2023-07-26 02:19:18 +00:00
|
|
|
private double fps = 0;
|
|
|
|
private int idx = 0;
|
|
|
|
|
|
|
|
public int Fps {
|
|
|
|
get => (int) Math.Ceiling(fps);
|
|
|
|
}
|
|
|
|
|
|
|
|
public void Update() {
|
|
|
|
var now = Environment.TickCount; // ms
|
|
|
|
if (frameTimes[idx] != 0) {
|
|
|
|
var timeElapsed = now - frameTimes[idx];
|
|
|
|
fps = 1000.0 * frameTimes.Length / timeElapsed;
|
|
|
|
}
|
|
|
|
frameTimes[idx] = now;
|
|
|
|
idx = (idx + 1) % frameTimes.Length;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-08-04 01:33:28 +00:00
|
|
|
|
2023-07-06 22:31:50 +00:00
|
|
|
public class CameraInfo {
|
2023-08-05 19:10:10 +00:00
|
|
|
public static float AspectRatio = 6000f / 4000f;
|
2023-07-06 22:31:50 +00:00
|
|
|
}
|
|
|
|
|
2023-08-04 01:33:28 +00:00
|
|
|
|
2023-08-04 04:17:11 +00:00
|
|
|
public enum ToolStatus {
|
2023-08-03 22:38:21 +00:00
|
|
|
Active,
|
|
|
|
Done,
|
|
|
|
Canceled
|
|
|
|
}
|
|
|
|
|
2023-08-04 01:33:28 +00:00
|
|
|
|
2023-08-05 18:34:06 +00:00
|
|
|
public class Transform {
|
|
|
|
float activeScale;
|
|
|
|
Vector2i activeOffset;
|
2023-08-05 19:10:10 +00:00
|
|
|
Vector2i photoSize;
|
2023-08-05 18:34:06 +00:00
|
|
|
|
2023-08-05 19:10:10 +00:00
|
|
|
public Transform(float scale, Vector2i offset, Vector2i photoSize) {
|
2023-08-05 18:34:06 +00:00
|
|
|
activeScale = scale;
|
|
|
|
activeOffset = offset;
|
2023-08-05 19:10:10 +00:00
|
|
|
this.photoSize = photoSize;
|
2023-08-05 18:34:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public Vector2i ScreenToImageDelta(int x, int y) {
|
|
|
|
return new((int) (x / activeScale), (int) (y / activeScale));
|
|
|
|
}
|
|
|
|
|
|
|
|
public Vector2i ScreenToImage(int x, int y) {
|
|
|
|
int rx = (int) ((x - activeOffset.X) / activeScale);
|
|
|
|
int ry = (int) ((y - activeOffset.Y) / activeScale);
|
2023-08-05 19:10:10 +00:00
|
|
|
rx = Math.Clamp(rx, 0, photoSize.X);
|
|
|
|
ry = Math.Clamp(ry, 0, photoSize.Y);
|
2023-08-05 18:34:06 +00:00
|
|
|
return new(rx, ry);
|
|
|
|
}
|
|
|
|
|
|
|
|
public Vector2i ScreenToImage(Vector2i position) {
|
|
|
|
return ScreenToImage(position.X, position.Y);
|
|
|
|
}
|
|
|
|
|
|
|
|
public Vector2i ImageToScreen(int x, int y) {
|
|
|
|
int rx = (int) ((x * activeScale) + activeOffset.X);
|
|
|
|
int ry = (int) ((y * activeScale) + activeOffset.Y);
|
|
|
|
return new(rx, ry);
|
|
|
|
}
|
|
|
|
|
|
|
|
public Vector2i ImageToScreen(Vector2i position) {
|
|
|
|
return ImageToScreen(position.X, position.Y);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-08-03 22:14:19 +00:00
|
|
|
public interface ITool {
|
2023-09-01 03:40:35 +00:00
|
|
|
ToolStatus HandleInput(KeyboardState input, MouseState mouse, Transform transform, Game game, Photo photo, UiGeometry geometry);
|
2023-08-03 22:14:19 +00:00
|
|
|
string Status();
|
|
|
|
}
|
|
|
|
|
|
|
|
public class ViewTool : ITool {
|
2023-09-01 03:40:35 +00:00
|
|
|
private bool dragging = false;
|
|
|
|
|
|
|
|
public ToolStatus HandleInput(KeyboardState input, MouseState mouse, Transform transform, Game game, Photo photo, UiGeometry geometry) {
|
2023-09-01 03:07:15 +00:00
|
|
|
Vector2i mousePosition = (Vector2i) mouse.Position;
|
2023-09-01 03:40:35 +00:00
|
|
|
if (mouse.IsButtonPressed(MouseButton.Button1) && geometry.PhotoBox.ContainsInclusive(mousePosition)) {
|
|
|
|
dragging = true;
|
|
|
|
}
|
|
|
|
if (!mouse.IsButtonDown(MouseButton.Button1)) {
|
|
|
|
dragging = false;
|
|
|
|
}
|
|
|
|
if (dragging) {
|
2023-09-01 03:07:15 +00:00
|
|
|
Vector2 delta = mouse.Delta;
|
|
|
|
Vector2i imageDelta = transform.ScreenToImageDelta((int) delta.X, (int) delta.Y);
|
|
|
|
photo.ViewOffset = Vector2i.Add(photo.ViewOffset, imageDelta);
|
|
|
|
}
|
|
|
|
|
2023-08-04 04:17:11 +00:00
|
|
|
return ToolStatus.Active;
|
2023-08-03 22:14:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public string Status() {
|
2023-08-04 00:03:46 +00:00
|
|
|
return "";
|
2023-08-03 22:14:19 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-08-03 22:38:21 +00:00
|
|
|
|
2023-08-03 22:14:19 +00:00
|
|
|
public class CropTool : ITool {
|
|
|
|
|
2023-09-15 05:42:01 +00:00
|
|
|
enum Mode { Sizing, Translating };
|
|
|
|
|
2023-08-04 04:43:47 +00:00
|
|
|
Photo photo;
|
2023-08-03 22:14:19 +00:00
|
|
|
Vector2i mouseDragStart;
|
|
|
|
Vector2i mouseDragEnd;
|
2023-09-15 05:42:01 +00:00
|
|
|
Mode mode;
|
2023-08-03 22:38:21 +00:00
|
|
|
string status = "";
|
2023-08-03 22:14:19 +00:00
|
|
|
|
2023-08-04 00:03:46 +00:00
|
|
|
public CropTool(Photo photo) {
|
2023-08-04 04:43:47 +00:00
|
|
|
this.photo = photo;
|
2023-08-04 03:46:46 +00:00
|
|
|
mouseDragStart = new(photo.CropRectangle.Left, photo.CropRectangle.Top);
|
|
|
|
mouseDragEnd = new(photo.CropRectangle.Right, photo.CropRectangle.Bottom);
|
2023-08-04 00:03:46 +00:00
|
|
|
}
|
|
|
|
|
2023-09-01 03:40:35 +00:00
|
|
|
public ToolStatus HandleInput(KeyboardState input, MouseState mouse, Transform transform, Game game, Photo photo, UiGeometry geometry) {
|
2023-08-03 22:14:19 +00:00
|
|
|
Vector2i mousePosition = (Vector2i) mouse.Position;
|
2023-08-05 18:34:06 +00:00
|
|
|
Vector2i imagePosition = transform.ScreenToImage(mousePosition);
|
2023-08-03 22:14:19 +00:00
|
|
|
|
2023-09-15 05:42:01 +00:00
|
|
|
List<Vector2i> corners = Util.RectangleCorners(photo.CropRectangle);
|
|
|
|
Vector2i? oppositeCorner = null;
|
|
|
|
bool mouseNearHandle = false;
|
|
|
|
for (int i = 0; i < 4; i++) {
|
|
|
|
Vector2i corner = corners[i];
|
|
|
|
Vector2i handlePosition = transform.ImageToScreen(corner.X, corner.Y);
|
|
|
|
if (Vector2i.Subtract(mousePosition, handlePosition).ManhattanLength < 10) {
|
|
|
|
mouseNearHandle = true;
|
|
|
|
oppositeCorner = corners[(i + 2) % 4];
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-08-26 19:37:57 +00:00
|
|
|
bool mouseInRectangle = photo.CropRectangle.Contains(imagePosition.X, imagePosition.Y);
|
2023-08-03 22:14:19 +00:00
|
|
|
if (mouse.IsButtonPressed(MouseButton.Button1)) {
|
2023-09-15 05:42:01 +00:00
|
|
|
if (mouseNearHandle || !mouseInRectangle) {
|
|
|
|
mode = Mode.Sizing;
|
|
|
|
} else {
|
|
|
|
mode = Mode.Translating;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (mouseNearHandle) {
|
|
|
|
game.Cursor = MouseCursor.Hand;
|
|
|
|
} else if (mouseInRectangle) {
|
|
|
|
game.Cursor = MouseCursor.Default;
|
|
|
|
} else {
|
|
|
|
game.Cursor = MouseCursor.Crosshair;
|
2023-08-03 22:14:19 +00:00
|
|
|
}
|
|
|
|
|
2023-09-15 05:42:01 +00:00
|
|
|
if (mode == Mode.Sizing) {
|
2023-08-04 04:17:11 +00:00
|
|
|
if (mouse.IsButtonPressed(MouseButton.Button1)) {
|
2023-09-15 05:42:01 +00:00
|
|
|
mouseDragStart = oppositeCorner ?? imagePosition;
|
2023-08-04 04:17:11 +00:00
|
|
|
}
|
|
|
|
if (mouse.IsButtonDown(MouseButton.Button1)) {
|
2023-08-04 01:33:28 +00:00
|
|
|
mouseDragEnd = imagePosition;
|
2023-08-03 22:14:19 +00:00
|
|
|
}
|
2023-08-04 03:21:10 +00:00
|
|
|
|
2023-08-04 04:17:11 +00:00
|
|
|
var (left, right, top, bottom) = GetCrop();
|
|
|
|
if (left != right && top != bottom) {
|
2023-08-04 04:43:47 +00:00
|
|
|
photo.CropRectangle = Rectangle.FromLTRB(left, top, right, bottom);
|
2023-08-04 04:17:11 +00:00
|
|
|
} else {
|
2023-08-04 04:43:47 +00:00
|
|
|
photo.CropRectangle = Rectangle.Empty;
|
2023-08-04 04:17:11 +00:00
|
|
|
}
|
|
|
|
} else {
|
2023-08-04 04:43:47 +00:00
|
|
|
if (mouse.IsButtonDown(MouseButton.Button1)) {
|
|
|
|
Vector2 delta = mouse.Delta;
|
2023-08-05 18:34:06 +00:00
|
|
|
Vector2i imageDelta = transform.ScreenToImageDelta((int) delta.X, (int) delta.Y);
|
2023-08-04 04:43:47 +00:00
|
|
|
photo.CropRectangle.Offset(imageDelta.X, imageDelta.Y);
|
|
|
|
if (photo.CropRectangle.Left < 0) {
|
|
|
|
photo.CropRectangle.Offset(-photo.CropRectangle.Left, 0);
|
|
|
|
}
|
|
|
|
if (photo.CropRectangle.Right > photo.Size.X) {
|
|
|
|
int overshoot = photo.CropRectangle.Right - photo.Size.X;
|
|
|
|
photo.CropRectangle.Offset(-overshoot, 0);
|
|
|
|
}
|
|
|
|
if (photo.CropRectangle.Top < 0) {
|
|
|
|
photo.CropRectangle.Offset(0, -photo.CropRectangle.Top);
|
|
|
|
}
|
|
|
|
if (photo.CropRectangle.Bottom > photo.Size.Y) {
|
|
|
|
int overshoot = photo.CropRectangle.Bottom - photo.Size.Y;
|
|
|
|
photo.CropRectangle.Offset(0, -overshoot);
|
|
|
|
}
|
|
|
|
}
|
2023-08-04 01:33:28 +00:00
|
|
|
}
|
2023-08-04 04:17:11 +00:00
|
|
|
|
2023-08-04 04:43:47 +00:00
|
|
|
Rectangle r = photo.CropRectangle;
|
|
|
|
status = $"({r.Left}, {r.Top}, {r.Right}, {r.Bottom}) {r.Width}x{r.Height}";
|
2023-08-03 22:14:19 +00:00
|
|
|
|
|
|
|
if (input.IsKeyPressed(Keys.Enter)) {
|
2023-08-26 19:37:57 +00:00
|
|
|
game.Cursor = MouseCursor.Default;
|
2023-09-01 14:27:39 +00:00
|
|
|
if (photo.Rating < 1) {
|
|
|
|
photo.Rating = 1;
|
|
|
|
}
|
2023-09-01 04:00:55 +00:00
|
|
|
photo.ViewOffset = new(photo.Size.X / 2 - Rectangle.Center(r).X,
|
|
|
|
photo.Size.Y / 2 - Rectangle.Center(r).Y);
|
2023-08-04 04:17:11 +00:00
|
|
|
return ToolStatus.Done;
|
2023-08-03 22:38:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if (input.IsKeyPressed(Keys.Escape)) {
|
2023-08-26 19:37:57 +00:00
|
|
|
game.Cursor = MouseCursor.Default;
|
2023-08-04 04:43:47 +00:00
|
|
|
photo.CropRectangle = Rectangle.Empty;
|
2023-08-04 04:17:11 +00:00
|
|
|
return ToolStatus.Canceled;
|
2023-08-03 22:14:19 +00:00
|
|
|
}
|
2023-08-03 22:38:21 +00:00
|
|
|
|
2023-08-04 04:17:11 +00:00
|
|
|
return ToolStatus.Active;
|
2023-08-03 22:14:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// left, right, top, bottom
|
|
|
|
(int, int, int, int) GetCrop() {
|
2023-08-30 19:39:04 +00:00
|
|
|
// FIXME: allow for unconstrained crop, 1:1, etc.
|
|
|
|
float aspectRatio = 1f * photo.Size.X / photo.Size.Y;
|
2023-08-03 22:14:19 +00:00
|
|
|
Vector2i start = mouseDragStart;
|
|
|
|
Vector2i end = mouseDragEnd;
|
2023-08-26 19:57:47 +00:00
|
|
|
int width = Math.Abs(end.X - start.X);
|
|
|
|
int height = Math.Abs(end.Y - start.Y);
|
2023-08-30 19:39:04 +00:00
|
|
|
int heightChange = Math.Min(height, (int) (width / aspectRatio));
|
|
|
|
int widthChange = (int) (heightChange * aspectRatio);
|
2023-08-26 19:57:47 +00:00
|
|
|
if (end.X < start.X) {
|
|
|
|
widthChange *= -1;
|
|
|
|
}
|
|
|
|
if (end.Y < start.Y) {
|
|
|
|
heightChange *= -1;
|
|
|
|
}
|
|
|
|
end.Y = start.Y + heightChange;
|
|
|
|
end.X = start.X + widthChange;
|
2023-08-03 22:14:19 +00:00
|
|
|
int left = Math.Min(start.X, end.X);
|
|
|
|
int right = Math.Max(start.X, end.X);
|
|
|
|
int top = Math.Min(start.Y, end.Y);
|
|
|
|
int bottom = Math.Max(start.Y, end.Y);
|
|
|
|
return (left, right, top, bottom);
|
|
|
|
}
|
|
|
|
|
|
|
|
public string Status() {
|
2023-08-03 22:38:21 +00:00
|
|
|
return "[crop] " + status;
|
2023-08-03 22:14:19 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-08-04 01:33:28 +00:00
|
|
|
|
2023-09-15 14:28:24 +00:00
|
|
|
public class StraightenTool : ITool {
|
|
|
|
|
|
|
|
Photo photo;
|
2023-09-15 17:17:10 +00:00
|
|
|
int initialRotation;
|
2023-09-15 14:28:24 +00:00
|
|
|
|
|
|
|
public StraightenTool(Photo photo) {
|
|
|
|
this.photo = photo;
|
2023-09-15 17:17:10 +00:00
|
|
|
initialRotation = photo.RotationDegreeHundredths;
|
2023-09-15 14:28:24 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public ToolStatus HandleInput(KeyboardState input, MouseState mouse, Transform transform, Game game, Photo photo, UiGeometry geometry) {
|
2023-09-15 17:10:41 +00:00
|
|
|
if (input.IsKeyPressed(Keys.D0)) {
|
2023-09-15 17:17:10 +00:00
|
|
|
photo.RotationDegreeHundredths = 0;
|
2023-09-15 17:10:41 +00:00
|
|
|
}
|
|
|
|
|
2023-09-15 14:28:24 +00:00
|
|
|
if (input.IsKeyPressed(Keys.Left)) {
|
2023-09-15 17:54:03 +00:00
|
|
|
if (input.IsKeyDown(Keys.LeftControl)) {
|
|
|
|
photo.RotationDegreeHundredths += 100;
|
|
|
|
} else if (input.IsKeyDown(Keys.LeftShift)) {
|
|
|
|
photo.RotationDegreeHundredths += 1;
|
|
|
|
} else {
|
|
|
|
photo.RotationDegreeHundredths += 10;
|
|
|
|
}
|
2023-09-15 14:28:24 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if (input.IsKeyPressed(Keys.Right)) {
|
2023-09-15 17:54:03 +00:00
|
|
|
if (input.IsKeyDown(Keys.LeftControl)) {
|
|
|
|
photo.RotationDegreeHundredths -= 100;
|
|
|
|
} else if (input.IsKeyDown(Keys.LeftShift)) {
|
|
|
|
photo.RotationDegreeHundredths -= 1;
|
|
|
|
} else {
|
|
|
|
photo.RotationDegreeHundredths -= 10;
|
|
|
|
}
|
2023-09-15 14:28:24 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if (input.IsKeyPressed(Keys.Enter)) {
|
2023-09-15 19:04:45 +00:00
|
|
|
Matrix2 rotation = Matrix2.CreateRotation(MathHelper.DegreesToRadians(photo.RotationDegreeHundredths / 100f));
|
|
|
|
Vector2 center = new(photo.Size.X / 2f, photo.Size.Y / 2f);
|
|
|
|
|
|
|
|
float[] xCoords = new float[4];
|
|
|
|
float[] yCoords = new float[4];
|
|
|
|
List<Vector2i> corners = Util.RectangleCorners(new Rectangle(0, 0, photo.Size.X, photo.Size.Y));
|
|
|
|
for (int i = 0; i < 4; i++) {
|
|
|
|
Vector2i corner = corners[i];
|
|
|
|
Vector2 rotated = Util.RotateAboutCenter(corner, center, rotation);
|
|
|
|
xCoords[i] = rotated.X;
|
|
|
|
yCoords[i] = rotated.Y;
|
|
|
|
}
|
|
|
|
Array.Sort(xCoords);
|
|
|
|
Array.Sort(yCoords);
|
|
|
|
for (int i = 3; i >= 0; i--) {
|
|
|
|
xCoords[i] -= xCoords[0];
|
|
|
|
yCoords[i] -= yCoords[0];
|
|
|
|
}
|
|
|
|
// FIXME: we can get a bigger crop using a better algorithm -- this is
|
|
|
|
// too conservative. Also, preserve aspect ratio? Maybe we need to
|
|
|
|
// commit this as a separate crop immediately so that further crops are
|
|
|
|
// working on an image that isn't weirdly rotated. Or keep the
|
|
|
|
// straighten-crop as a separate operation from an ordinary crop.
|
|
|
|
int left = (int) Math.Ceiling(xCoords[1]);
|
|
|
|
int right = (int) Math.Floor(xCoords[2]);
|
|
|
|
int top = (int) Math.Ceiling(yCoords[1]);
|
|
|
|
int bottom = (int) Math.Floor(yCoords[2]);
|
|
|
|
|
|
|
|
photo.CropRectangle = Rectangle.FromLTRB(left, top, right, bottom);
|
|
|
|
|
2023-09-15 14:28:24 +00:00
|
|
|
return ToolStatus.Done;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (input.IsKeyPressed(Keys.Escape)) {
|
2023-09-15 17:17:10 +00:00
|
|
|
photo.RotationDegreeHundredths = initialRotation;
|
2023-09-15 14:28:24 +00:00
|
|
|
return ToolStatus.Canceled;
|
|
|
|
}
|
|
|
|
|
|
|
|
return ToolStatus.Active;
|
|
|
|
}
|
|
|
|
|
|
|
|
public string Status() {
|
2023-09-15 17:17:10 +00:00
|
|
|
return String.Format("[straighten] {0:F2}", photo.RotationDegreeHundredths / 100f);
|
2023-09-15 14:28:24 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2023-07-07 18:24:43 +00:00
|
|
|
public class UiGeometry {
|
2023-07-26 19:09:19 +00:00
|
|
|
public static Vector2i MIN_WINDOW_SIZE = new(1024, 768);
|
2023-07-07 18:24:43 +00:00
|
|
|
|
|
|
|
public readonly Vector2i WindowSize;
|
2023-09-20 02:18:12 +00:00
|
|
|
public readonly Vector2i ThumbnailSize;
|
2023-07-25 21:18:11 +00:00
|
|
|
public readonly Box2i ThumbnailBox;
|
2023-07-07 18:52:36 +00:00
|
|
|
public readonly List<Box2i> ThumbnailBoxes = new();
|
2023-07-26 19:09:19 +00:00
|
|
|
public readonly List<Box2i> StarBoxes = new();
|
2023-07-07 20:00:43 +00:00
|
|
|
public readonly Box2i PhotoBox;
|
2023-07-24 17:14:07 +00:00
|
|
|
public readonly Box2i StatusBox;
|
2023-07-07 18:24:43 +00:00
|
|
|
|
2023-07-26 19:09:19 +00:00
|
|
|
public UiGeometry() : this(MIN_WINDOW_SIZE, 0) {}
|
2023-07-07 18:24:43 +00:00
|
|
|
|
2023-07-26 19:09:19 +00:00
|
|
|
public UiGeometry(Vector2i windowSize, int starSize) {
|
2023-07-07 18:24:43 +00:00
|
|
|
WindowSize = windowSize;
|
2023-07-07 18:52:36 +00:00
|
|
|
|
2023-12-29 03:19:46 +00:00
|
|
|
int numThumbnailsPerColumn = Math.Max(WindowSize.Y / 120, 1);
|
2023-09-01 05:06:04 +00:00
|
|
|
int thumbnailHeight = WindowSize.Y / numThumbnailsPerColumn;
|
2023-08-05 19:10:10 +00:00
|
|
|
int thumbnailWidth = (int) (1.0 * thumbnailHeight * CameraInfo.AspectRatio);
|
2023-09-20 02:18:12 +00:00
|
|
|
ThumbnailSize = new(thumbnailWidth, thumbnailHeight);
|
2023-07-28 18:36:02 +00:00
|
|
|
|
2023-09-01 05:06:04 +00:00
|
|
|
int thumbnailColumns = 3;
|
|
|
|
for (int j = thumbnailColumns; j > 0; j--) {
|
|
|
|
for (int i = 0; i < numThumbnailsPerColumn; i++) {
|
|
|
|
Box2i box = Util.MakeBox(WindowSize.X - thumbnailWidth * j, i * thumbnailHeight,
|
|
|
|
thumbnailWidth, thumbnailHeight);
|
|
|
|
ThumbnailBoxes.Add(box);
|
|
|
|
}
|
2023-07-07 18:52:36 +00:00
|
|
|
}
|
2023-07-07 20:00:43 +00:00
|
|
|
|
2023-08-02 05:05:10 +00:00
|
|
|
int statusBoxHeight = 40;
|
2023-08-06 03:21:18 +00:00
|
|
|
PhotoBox = new Box2i(
|
2023-09-15 05:48:12 +00:00
|
|
|
0, 0, WindowSize.X - thumbnailWidth * thumbnailColumns, WindowSize.Y - statusBoxHeight);
|
2023-08-06 03:21:18 +00:00
|
|
|
StatusBox = new Box2i(
|
2023-09-01 05:06:04 +00:00
|
|
|
0, WindowSize.Y - statusBoxHeight, WindowSize.X - thumbnailWidth * thumbnailColumns, WindowSize.Y);
|
2023-08-06 03:21:18 +00:00
|
|
|
ThumbnailBox = new Box2i(
|
|
|
|
ThumbnailBoxes[0].Min.X, ThumbnailBoxes[0].Min.Y, WindowSize.X, WindowSize.Y);
|
2023-07-26 19:09:19 +00:00
|
|
|
|
|
|
|
int starSpacing = 10;
|
|
|
|
int starBoxLeft = (int) (PhotoBox.Center.X - 2.5 * starSize - starSpacing * 2);
|
|
|
|
for (int i = 0; i < 5; i++) {
|
2023-08-06 03:21:18 +00:00
|
|
|
Box2i box = Util.MakeBox(
|
|
|
|
starBoxLeft + i * (starSize + starSpacing), PhotoBox.Max.Y - starSize - 10,
|
|
|
|
starSize, starSize);
|
2023-07-26 19:09:19 +00:00
|
|
|
StarBoxes.Add(box);
|
|
|
|
}
|
2023-07-07 18:52:36 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-08-04 01:33:28 +00:00
|
|
|
|
2023-07-07 18:52:36 +00:00
|
|
|
public static class Util {
|
2023-07-23 22:42:08 +00:00
|
|
|
public const float PI = (float) Math.PI;
|
|
|
|
|
2023-08-01 18:12:52 +00:00
|
|
|
public static int Lerp(int start, int end, double fraction) {
|
|
|
|
return start + (int) ((end - start) * fraction);
|
|
|
|
}
|
|
|
|
|
2023-07-24 16:43:38 +00:00
|
|
|
public static Box2i MakeBox(int left, int top, int width, int height) {
|
2023-07-07 18:52:36 +00:00
|
|
|
return new Box2i(left, top, left + width, top + height);
|
2023-07-07 18:24:43 +00:00
|
|
|
}
|
2023-07-24 16:13:13 +00:00
|
|
|
|
2023-09-15 05:42:01 +00:00
|
|
|
// resulting items are ordered such that a corner's opposite is 2 indexes away.
|
|
|
|
public static List<Vector2i> RectangleCorners(Rectangle r) {
|
|
|
|
List<Vector2i> result = new(4);
|
|
|
|
result.Add(new(r.Left, r.Top));
|
|
|
|
result.Add(new(r.Right, r.Top));
|
|
|
|
result.Add(new(r.Right, r.Bottom));
|
|
|
|
result.Add(new(r.Left, r.Bottom));
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
2023-09-15 19:04:45 +00:00
|
|
|
public static Vector2 RotateAboutCenter(Vector2 vec, Vector2 center, Matrix2 transform) {
|
|
|
|
Vector2 centerRelative = vec - center;
|
|
|
|
centerRelative *= transform;
|
|
|
|
return centerRelative + center;
|
|
|
|
}
|
|
|
|
|
2023-07-24 16:43:38 +00:00
|
|
|
public static Image<Rgba32> MakeImage(float width, float height) {
|
2023-07-24 16:36:44 +00:00
|
|
|
return new((int) Math.Ceiling(width), (int) Math.Ceiling(height));
|
2023-07-24 16:13:13 +00:00
|
|
|
}
|
|
|
|
|
2023-07-25 18:28:57 +00:00
|
|
|
// https://sirv.com/help/articles/rotate-photos-to-be-upright/
|
2023-09-15 17:11:44 +00:00
|
|
|
// FIXME: could use AutoOrientProcessor instead?
|
2023-07-25 18:28:57 +00:00
|
|
|
public static void RotateImageFromExif(Image<Rgba32> image, ushort orientation) {
|
2023-07-25 18:48:02 +00:00
|
|
|
if (orientation <= 1) {
|
2023-07-25 18:28:57 +00:00
|
|
|
return;
|
|
|
|
}
|
2023-07-25 18:48:02 +00:00
|
|
|
var operations = new Dictionary<ushort, (RotateMode, FlipMode)> {
|
|
|
|
{ 2, (RotateMode.None, FlipMode.Horizontal) },
|
|
|
|
{ 3, (RotateMode.Rotate180, FlipMode.None) },
|
2023-07-25 19:02:40 +00:00
|
|
|
{ 4, (RotateMode.None, FlipMode.Vertical) },
|
2023-07-25 18:48:02 +00:00
|
|
|
{ 5, (RotateMode.Rotate90, FlipMode.Vertical) },
|
|
|
|
{ 6, (RotateMode.Rotate90, FlipMode.None) },
|
|
|
|
{ 7, (RotateMode.Rotate270, FlipMode.Vertical) },
|
|
|
|
{ 8, (RotateMode.Rotate270, FlipMode.None) },
|
|
|
|
};
|
|
|
|
var (rotate, flip) = operations[orientation];
|
|
|
|
image.Mutate(x => x.RotateFlip(rotate, flip));
|
2023-07-25 18:28:57 +00:00
|
|
|
}
|
|
|
|
|
2023-07-24 16:43:38 +00:00
|
|
|
public static Texture RenderText(string text) {
|
|
|
|
return RenderText(text, 16);
|
2023-07-24 16:36:44 +00:00
|
|
|
}
|
|
|
|
|
2023-07-24 16:43:38 +00:00
|
|
|
public static Texture RenderText(string text, int size) {
|
2023-08-04 00:03:46 +00:00
|
|
|
// Make sure that 0-length text doesn't end up as a 0-size texture, which
|
|
|
|
// might cause problems.
|
|
|
|
if (text.Length == 0) {
|
|
|
|
text = " ";
|
|
|
|
}
|
2023-07-24 16:36:44 +00:00
|
|
|
Font font = SystemFonts.CreateFont("Consolas", size, FontStyle.Bold);
|
2023-07-24 16:13:13 +00:00
|
|
|
TextOptions options = new(font);
|
2023-07-24 16:36:44 +00:00
|
|
|
FontRectangle rect = TextMeasurer.Measure(text, new TextOptions(font));
|
2023-07-24 16:43:38 +00:00
|
|
|
Image<Rgba32> image = MakeImage(rect.Width, rect.Height);
|
2023-07-24 16:13:13 +00:00
|
|
|
IBrush brush = Brushes.Solid(Color.White);
|
2023-07-24 16:36:44 +00:00
|
|
|
image.Mutate(x => x.DrawText(options, text, brush));
|
2023-07-24 16:13:13 +00:00
|
|
|
Texture texture = new Texture(image);
|
|
|
|
image.Dispose();
|
|
|
|
return texture;
|
|
|
|
}
|
|
|
|
|
2023-07-26 15:24:56 +00:00
|
|
|
// FIXME: make a real icon stored as a PNG...
|
2023-07-26 14:00:46 +00:00
|
|
|
public static OpenTK.Windowing.Common.Input.Image[] RenderAppIcon() {
|
2023-07-26 15:11:06 +00:00
|
|
|
int size = 64;
|
2023-07-26 15:14:23 +00:00
|
|
|
Font font = SystemFonts.CreateFont("MS Mincho", size, FontStyle.Bold);
|
2023-07-26 14:00:46 +00:00
|
|
|
TextOptions options = new(font);
|
2023-07-26 15:11:06 +00:00
|
|
|
Image<Rgba32> image = MakeImage(size, size);
|
2023-07-26 14:00:46 +00:00
|
|
|
IBrush brush = Brushes.Solid(Color.Black);
|
|
|
|
image.Mutate(x => x.DrawText(options, "æ’®", brush));
|
2023-07-26 15:11:06 +00:00
|
|
|
byte[] pixelBytes = new byte[size * size * 4];
|
2023-07-26 14:00:46 +00:00
|
|
|
image.CopyPixelDataTo(pixelBytes);
|
2023-07-26 15:11:06 +00:00
|
|
|
image.Dispose();
|
|
|
|
OpenTK.Windowing.Common.Input.Image opentkImage = new(size, size, pixelBytes);
|
2023-07-26 14:00:46 +00:00
|
|
|
return new OpenTK.Windowing.Common.Input.Image[]{ opentkImage };
|
|
|
|
}
|
|
|
|
|
2023-07-26 16:23:58 +00:00
|
|
|
public static Texture RenderStar(float radius, bool filled) {
|
2023-08-06 03:21:18 +00:00
|
|
|
IPath path = new Star(x: radius, y: radius + 1, prongs: 5,
|
|
|
|
innerRadii: radius * 0.4f, outerRadii: radius, angle: Util.PI);
|
2023-07-26 17:10:19 +00:00
|
|
|
// We add a little bit to the width & height because the reported
|
|
|
|
// path.Bounds are often a little tighter than they should be & a couple
|
|
|
|
// pixels end up obviously missing...
|
2023-07-26 16:23:58 +00:00
|
|
|
Image<Rgba32> image = MakeImage(path.Bounds.Width + 2, path.Bounds.Height + 2);
|
|
|
|
IBrush brush = Brushes.Solid(Color.White);
|
2023-07-26 19:09:19 +00:00
|
|
|
IPen white = Pens.Solid(Color.White, 1.5f);
|
|
|
|
IPen black = Pens.Solid(Color.Black, 3f);
|
|
|
|
image.Mutate(x => x.Draw(black, path));
|
2023-07-26 16:23:58 +00:00
|
|
|
if (filled) {
|
|
|
|
image.Mutate(x => x.Fill(brush, path));
|
|
|
|
}
|
2023-07-26 19:09:19 +00:00
|
|
|
image.Mutate(x => x.Draw(white, path));
|
2023-07-24 16:13:13 +00:00
|
|
|
Texture texture = new Texture(image);
|
|
|
|
image.Dispose();
|
|
|
|
return texture;
|
|
|
|
}
|
2023-07-07 18:24:43 +00:00
|
|
|
}
|
|
|
|
|
2023-08-26 16:40:36 +00:00
|
|
|
public class Toast {
|
|
|
|
private string message = "";
|
|
|
|
private double time;
|
|
|
|
private double expiryTime;
|
|
|
|
|
|
|
|
public void Set(string message) {
|
|
|
|
this.message = message;
|
|
|
|
this.expiryTime = time + 5.0;
|
|
|
|
}
|
|
|
|
|
|
|
|
public string Get() {
|
|
|
|
return message;
|
|
|
|
}
|
|
|
|
|
|
|
|
public void Update(double elapsed) {
|
|
|
|
time += elapsed;
|
|
|
|
if (time > expiryTime) {
|
|
|
|
message = "";
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2023-08-04 01:33:28 +00:00
|
|
|
|
2023-06-30 02:10:24 +00:00
|
|
|
public class Game : GameWindow {
|
2023-08-06 03:21:18 +00:00
|
|
|
public Game(GameWindowSettings gwSettings, NativeWindowSettings nwSettings) :
|
|
|
|
base(gwSettings, nwSettings) {
|
2023-08-03 22:38:21 +00:00
|
|
|
activeTool = viewTool;
|
2023-08-25 03:38:25 +00:00
|
|
|
geometry = new UiGeometry(nwSettings.Size, STAR_FILLED.Size.X);
|
2023-08-03 22:38:21 +00:00
|
|
|
}
|
2023-06-30 02:10:24 +00:00
|
|
|
|
2023-12-29 03:19:46 +00:00
|
|
|
// private static string outputRoot = @"c:\users\colin\desktop\totte-output";
|
|
|
|
private static string outputRoot = @"d:\photos";
|
2023-08-02 05:05:10 +00:00
|
|
|
|
2023-07-08 04:33:15 +00:00
|
|
|
private static Texture TEXTURE_WHITE = new(new Image<Rgba32>(1, 1, new Rgba32(255, 255, 255)));
|
2023-07-24 21:13:19 +00:00
|
|
|
private static Texture TEXTURE_BLACK = new(new Image<Rgba32>(1, 1, new Rgba32(0, 0, 0)));
|
2023-07-26 16:23:58 +00:00
|
|
|
private static Texture STAR_FILLED = Util.RenderStar(20, true);
|
2023-07-26 17:10:19 +00:00
|
|
|
private static Texture STAR_EMPTY = Util.RenderStar(20, false);
|
|
|
|
private static Texture STAR_SMALL = Util.RenderStar(6, true);
|
2023-07-06 22:31:50 +00:00
|
|
|
|
2023-08-25 03:38:25 +00:00
|
|
|
UiGeometry geometry;
|
2023-07-26 02:19:18 +00:00
|
|
|
FpsCounter fpsCounter = new();
|
2023-07-07 18:24:43 +00:00
|
|
|
|
2023-07-07 18:52:36 +00:00
|
|
|
// Four points, each consisting of (x, y, z, tex_x, tex_y).
|
2023-07-07 18:24:43 +00:00
|
|
|
float[] vertices = new float[20];
|
2023-06-30 02:10:24 +00:00
|
|
|
|
2023-07-07 18:52:36 +00:00
|
|
|
// Indices to draw a rectangle from two triangles.
|
2023-06-30 02:10:24 +00:00
|
|
|
uint[] indices = {
|
|
|
|
0, 1, 3, // first triangle
|
|
|
|
1, 2, 3 // second triangle
|
|
|
|
};
|
|
|
|
|
|
|
|
int VertexBufferObject;
|
|
|
|
int ElementBufferObject;
|
|
|
|
int VertexArrayObject;
|
2023-08-30 21:57:01 +00:00
|
|
|
|
2023-07-26 21:29:59 +00:00
|
|
|
List<Photo> allPhotos = new();
|
2023-07-16 23:23:03 +00:00
|
|
|
List<Photo> photos = new();
|
2023-07-27 01:29:26 +00:00
|
|
|
HashSet<Photo> loadedImages = new();
|
2023-07-28 17:07:47 +00:00
|
|
|
HashSet<Photo> loadingImages = new();
|
|
|
|
readonly object loadedImagesLock = new();
|
2023-08-03 22:38:21 +00:00
|
|
|
readonly ViewTool viewTool = new ViewTool();
|
2023-08-26 16:40:36 +00:00
|
|
|
Toast toast = new();
|
2023-08-03 22:38:21 +00:00
|
|
|
ITool activeTool;
|
2023-07-16 23:25:28 +00:00
|
|
|
int photoIndex = 0;
|
2023-07-25 22:26:46 +00:00
|
|
|
int ribbonIndex = 0;
|
2023-08-02 05:05:10 +00:00
|
|
|
Vector2i mousePosition;
|
2023-08-02 03:14:00 +00:00
|
|
|
float activeScale = 1f;
|
2023-08-02 05:05:10 +00:00
|
|
|
Vector2i activeOffset;
|
2023-08-05 19:10:10 +00:00
|
|
|
Transform transform = new(1f, Vector2i.Zero, Vector2i.Zero);
|
2023-07-08 04:33:15 +00:00
|
|
|
Shader shader = new();
|
2023-06-30 02:10:24 +00:00
|
|
|
Matrix4 projection;
|
2023-07-18 05:42:59 +00:00
|
|
|
float zoomLevel = 0f;
|
2023-08-26 16:55:06 +00:00
|
|
|
double timeSinceEvent = 0;
|
2023-06-30 02:10:24 +00:00
|
|
|
|
2023-08-30 21:57:01 +00:00
|
|
|
// Variables that are protected by locks:
|
|
|
|
readonly object numThumbnailsLoadedLock = new();
|
|
|
|
int numThumbnailsLoaded = 0;
|
|
|
|
|
|
|
|
readonly object exportPhotosLock = new(); // locks the entire ExportPhotos() function.
|
|
|
|
int numPhotosToExport = 0;
|
|
|
|
readonly object numPhotosExportedLock = new();
|
|
|
|
int numPhotosExported = 0;
|
|
|
|
|
2023-06-30 02:10:24 +00:00
|
|
|
protected override void OnUpdateFrame(FrameEventArgs e) {
|
|
|
|
base.OnUpdateFrame(e);
|
2023-08-26 16:40:36 +00:00
|
|
|
toast.Update(e.Time);
|
2023-06-30 02:10:24 +00:00
|
|
|
|
|
|
|
KeyboardState input = KeyboardState;
|
2023-08-26 17:14:24 +00:00
|
|
|
bool mouseInWindow = ClientRectangle.ContainsInclusive((Vector2i) MouseState.Position);
|
|
|
|
if (input.IsAnyKeyDown ||
|
|
|
|
MouseState.IsAnyButtonDown ||
|
|
|
|
(mouseInWindow && MouseState.Delta != Vector2i.Zero)) {
|
2023-08-26 16:55:06 +00:00
|
|
|
timeSinceEvent = 0;
|
|
|
|
} else {
|
|
|
|
timeSinceEvent += e.Time;
|
|
|
|
}
|
2023-12-29 03:19:46 +00:00
|
|
|
if (IsFocused) { // && timeSinceEvent < 1
|
|
|
|
RenderFrequency = 60;
|
|
|
|
UpdateFrequency = 60;
|
2023-08-26 16:55:06 +00:00
|
|
|
} else {
|
2023-08-26 19:37:57 +00:00
|
|
|
RenderFrequency = 5;
|
|
|
|
UpdateFrequency = 5;
|
2023-08-26 16:55:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
Photo previousPhoto = photos[photoIndex];
|
2023-06-30 02:10:24 +00:00
|
|
|
|
2023-07-31 04:58:21 +00:00
|
|
|
bool shiftIsDown = input.IsKeyDown(Keys.LeftShift) || input.IsKeyDown(Keys.RightShift);
|
2023-07-31 21:01:08 +00:00
|
|
|
bool altIsDown = input.IsKeyDown(Keys.LeftAlt) || input.IsKeyDown(Keys.RightAlt);
|
2023-07-31 04:58:21 +00:00
|
|
|
bool ctrlIsDown = input.IsKeyDown(Keys.LeftControl) || input.IsKeyDown(Keys.RightControl);
|
|
|
|
|
2023-07-25 22:26:46 +00:00
|
|
|
// FIXME: add a confirm dialog before closing. (Also for the window-close button.)
|
2023-08-01 00:50:53 +00:00
|
|
|
// FIXME: don't quit if there's pending file-write operations.
|
2023-08-02 02:24:25 +00:00
|
|
|
// Close when Ctrl-Q is pressed.
|
|
|
|
if (input.IsKeyPressed(Keys.Q) && ctrlIsDown) {
|
2023-07-16 23:13:08 +00:00
|
|
|
Close();
|
|
|
|
}
|
|
|
|
|
2023-08-02 05:05:10 +00:00
|
|
|
mousePosition = (Vector2i) MouseState.Position;
|
2023-08-02 02:24:25 +00:00
|
|
|
|
2023-07-26 19:20:20 +00:00
|
|
|
// Look for mouse clicks on thumbnails or stars.
|
2023-07-25 22:38:21 +00:00
|
|
|
//
|
|
|
|
// Note that we don't bounds-check photoIndex until after all the possible
|
|
|
|
// inputs that might affect it. That simplifies this logic significantly.
|
|
|
|
if (MouseState.IsButtonPressed(MouseButton.Button1)) {
|
2023-07-26 19:20:20 +00:00
|
|
|
|
|
|
|
for (int i = 0; i < geometry.StarBoxes.Count; i++) {
|
2023-08-02 02:24:25 +00:00
|
|
|
if (geometry.StarBoxes[i].ContainsInclusive(mousePosition)) {
|
2023-07-26 19:20:20 +00:00
|
|
|
photos[photoIndex].Rating = i + 1;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-07-08 04:18:31 +00:00
|
|
|
for (int i = 0; i < geometry.ThumbnailBoxes.Count; i++) {
|
2023-08-02 02:24:25 +00:00
|
|
|
if (geometry.ThumbnailBoxes[i].ContainsInclusive(mousePosition)) {
|
2023-07-25 22:38:21 +00:00
|
|
|
photoIndex = ribbonIndex + i;
|
2023-07-08 04:18:31 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-07-25 22:38:21 +00:00
|
|
|
if (MouseState.IsButtonPressed(MouseButton.Button4)) {
|
|
|
|
photoIndex--;
|
2023-06-30 02:10:24 +00:00
|
|
|
}
|
|
|
|
|
2023-07-25 22:38:21 +00:00
|
|
|
if (MouseState.IsButtonPressed(MouseButton.Button5)) {
|
|
|
|
photoIndex++;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (MouseState.ScrollDelta.Y < 0) {
|
|
|
|
photoIndex++;
|
2023-07-16 23:13:08 +00:00
|
|
|
}
|
|
|
|
|
2023-07-25 22:38:21 +00:00
|
|
|
if (MouseState.ScrollDelta.Y > 0) {
|
|
|
|
photoIndex--;
|
|
|
|
}
|
|
|
|
|
2023-07-26 20:57:32 +00:00
|
|
|
if (input.IsKeyPressed(Keys.Down)) {
|
2023-07-25 22:38:21 +00:00
|
|
|
photoIndex++;
|
|
|
|
}
|
2023-07-26 20:57:32 +00:00
|
|
|
if (input.IsKeyPressed(Keys.Up)) {
|
2023-07-25 22:38:21 +00:00
|
|
|
photoIndex--;
|
|
|
|
}
|
|
|
|
|
2023-07-26 01:44:22 +00:00
|
|
|
if (input.IsKeyPressed(Keys.Home)) {
|
|
|
|
photoIndex = 0;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (input.IsKeyPressed(Keys.End)) {
|
|
|
|
photoIndex = photos.Count - 1;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (input.IsKeyPressed(Keys.PageDown)) {
|
2023-09-01 02:15:05 +00:00
|
|
|
photoIndex += 10;
|
2023-07-26 01:44:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if (input.IsKeyPressed(Keys.PageUp)) {
|
|