totte/Program.cs

1009 lines
34 KiB
C#
Raw Normal View History

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
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;
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;
}
}
public class CameraInfo {
public static float AspectRatio = 6000f / 4000f;
}
2023-08-04 04:17:11 +00:00
public enum ToolStatus {
Active,
Done,
Canceled
}
2023-08-05 18:34:06 +00:00
public class Transform {
// FIXME: move scale and offset into Photo itself?
float activeScale;
Vector2i activeOffset;
Vector2i photoSize;
2023-08-05 18:34:06 +00:00
public Transform(float scale, Vector2i offset, Vector2i photoSize) {
2023-08-05 18:34:06 +00:00
activeScale = scale;
activeOffset = offset;
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);
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);
}
}
public interface ITool {
2023-08-05 18:34:06 +00:00
ToolStatus HandleInput(KeyboardState input, MouseState mouse, Transform transform);
string Status();
void Draw(UiGeometry geometry, Game game);
}
public class ViewTool : ITool {
2023-08-05 18:34:06 +00:00
public ToolStatus HandleInput(KeyboardState input, MouseState mouse, Transform transform) {
2023-08-04 04:17:11 +00:00
return ToolStatus.Active;
}
public string Status() {
2023-08-04 00:03:46 +00:00
return "";
}
public void Draw(UiGeometry geometry, Game game) {
}
}
public class CropTool : ITool {
2023-08-04 04:43:47 +00:00
Photo photo;
Vector2i mouseDragStart;
Vector2i mouseDragEnd;
2023-08-04 04:17:11 +00:00
bool dragging;
string status = "";
2023-08-04 00:03:46 +00:00
public CropTool(Photo photo) {
2023-08-04 04:43:47 +00:00
this.photo = photo;
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-08-05 18:34:06 +00:00
public ToolStatus HandleInput(KeyboardState input, MouseState mouse, Transform transform) {
Vector2i mousePosition = (Vector2i) mouse.Position;
2023-08-05 18:34:06 +00:00
Vector2i imagePosition = transform.ScreenToImage(mousePosition);
if (mouse.IsButtonPressed(MouseButton.Button1)) {
2023-08-04 04:43:47 +00:00
dragging = photo.CropRectangle.Contains(imagePosition.X, imagePosition.Y);
}
2023-08-04 04:17:11 +00:00
if (!dragging) {
if (mouse.IsButtonPressed(MouseButton.Button1)) {
mouseDragStart = imagePosition;
}
if (mouse.IsButtonDown(MouseButton.Button1)) {
mouseDragEnd = imagePosition;
}
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 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}";
if (input.IsKeyPressed(Keys.Enter)) {
2023-08-04 04:17:11 +00:00
return ToolStatus.Done;
}
if (input.IsKeyPressed(Keys.Escape)) {
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-04 04:17:11 +00:00
return ToolStatus.Active;
}
// left, right, top, bottom
(int, int, int, int) GetCrop() {
// FIXME: this expects the start point in the top left and the end point
// in the bottom right; some sign flipping needs to occur to make anchors
2023-08-04 04:17:11 +00:00
// in other directions work well.
Vector2i start = mouseDragStart;
Vector2i end = mouseDragEnd;
2023-08-04 00:03:46 +00:00
// FIXME: choose the aspect ratio based on the original image aspect ratio.
// FIXME: allow for unconstrained crop, 1:1, etc.
end.Y = Math.Min(end.Y, (int) (start.Y + (end.X - start.X) / CameraInfo.AspectRatio));
end.X = (int) (start.X + (end.Y - start.Y) * CameraInfo.AspectRatio);
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 void Draw(UiGeometry geometry, Game game) {
}
public string Status() {
return "[crop] " + status;
}
}
public class UiGeometry {
public static Vector2i MIN_WINDOW_SIZE = new(1024, 768);
public readonly Vector2i WindowSize;
public readonly Box2i ThumbnailBox;
2023-07-07 18:52:36 +00:00
public readonly List<Box2i> ThumbnailBoxes = new();
public readonly List<Box2i> StarBoxes = new();
public readonly Box2i PhotoBox;
2023-07-24 17:14:07 +00:00
public readonly Box2i StatusBox;
public UiGeometry() : this(MIN_WINDOW_SIZE, 0) {}
public UiGeometry(Vector2i windowSize, int starSize) {
WindowSize = windowSize;
2023-07-07 18:52:36 +00:00
int numThumbnails = Math.Max(WindowSize.Y / 100, 1);
int thumbnailHeight = WindowSize.Y / numThumbnails;
int thumbnailWidth = (int) (1.0 * thumbnailHeight * CameraInfo.AspectRatio);
2023-07-28 18:36:02 +00:00
Console.WriteLine($"thumbnail size: {thumbnailWidth} x {thumbnailHeight}");
for (int i = 0; i < numThumbnails; i++) {
2023-08-06 03:21:18 +00:00
Box2i box = Util.MakeBox(WindowSize.X - thumbnailWidth, i * thumbnailHeight,
thumbnailWidth, thumbnailHeight);
2023-07-07 18:52:36 +00:00
ThumbnailBoxes.Add(box);
}
int statusBoxHeight = 40;
2023-07-24 17:14:07 +00:00
int statusBoxPadding = 4;
2023-08-06 03:21:18 +00:00
PhotoBox = new Box2i(
0, 0, WindowSize.X - thumbnailWidth, WindowSize.Y - statusBoxHeight - statusBoxPadding);
StatusBox = new Box2i(
0, WindowSize.Y - statusBoxHeight, WindowSize.X - thumbnailWidth, WindowSize.Y);
ThumbnailBox = new Box2i(
ThumbnailBoxes[0].Min.X, ThumbnailBoxes[0].Min.Y, WindowSize.X, WindowSize.Y);
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);
StarBoxes.Add(box);
}
2023-07-07 18:52:36 +00:00
}
}
2023-07-07 18:52:36 +00:00
public static class Util {
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);
}
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);
}
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-25 18:28:57 +00:00
// https://sirv.com/help/articles/rotate-photos-to-be-upright/
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 19:00:30 +00:00
// FIXME: I'm not convinced that all of these are correct, especially the
// cases that involve flipping (because whether you're flipping before or
2023-08-04 00:03:46 +00:00
// after rotation matters.)
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) },
{ 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
}
public static Texture RenderText(string text) {
return RenderText(text, 16);
2023-07-24 16:36:44 +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);
TextOptions options = new(font);
2023-07-24 16:36:44 +00:00
FontRectangle rect = TextMeasurer.Measure(text, new TextOptions(font));
Image<Rgba32> image = MakeImage(rect.Width, rect.Height);
IBrush brush = Brushes.Solid(Color.White);
2023-07-24 16:36:44 +00:00
image.Mutate(x => x.DrawText(options, text, brush));
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;
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);
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));
}
image.Mutate(x => x.Draw(white, path));
Texture texture = new Texture(image);
image.Dispose();
return texture;
}
}
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) {
activeTool = viewTool;
}
2023-06-30 02:10:24 +00:00
private static string outputRoot = @"c:\users\colin\desktop\totte-output";
// private static string outputRoot = @"c:\users\colin\pictures\photos";
2023-07-08 04:33:15 +00:00
private static Texture TEXTURE_WHITE = new(new Image<Rgba32>(1, 1, new Rgba32(255, 255, 255)));
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-07 18:52:36 +00:00
UiGeometry geometry = new();
2023-07-26 02:19:18 +00:00
FpsCounter fpsCounter = new();
2023-07-07 18:52:36 +00:00
// Four points, each consisting of (x, y, z, tex_x, tex_y).
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-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();
HashSet<Photo> loadingImages = new();
readonly object loadedImagesLock = new();
readonly ViewTool viewTool = new ViewTool();
ITool activeTool;
2023-07-16 23:25:28 +00:00
int photoIndex = 0;
int ribbonIndex = 0;
Vector2i mousePosition;
2023-08-02 03:14:00 +00:00
float activeScale = 1f;
Vector2i activeOffset;
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-06-30 02:10:24 +00:00
protected override void OnUpdateFrame(FrameEventArgs e) {
base.OnUpdateFrame(e);
Photo previousPhoto = photos[photoIndex];
2023-06-30 02:10:24 +00:00
KeyboardState input = KeyboardState;
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);
// FIXME: add a confirm dialog before closing. (Also for the window-close button.)
// 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) {
Close();
}
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.
//
// 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)) {
photoIndex = ribbonIndex + i;
2023-07-08 04:18:31 +00:00
}
}
}
if (MouseState.IsButtonPressed(MouseButton.Button4)) {
photoIndex--;
2023-06-30 02:10:24 +00:00
}
if (MouseState.IsButtonPressed(MouseButton.Button5)) {
photoIndex++;
}
if (MouseState.ScrollDelta.Y < 0) {
photoIndex++;
}
if (MouseState.ScrollDelta.Y > 0) {
photoIndex--;
}
if (input.IsKeyPressed(Keys.Down)) {
photoIndex++;
}
if (input.IsKeyPressed(Keys.Up)) {
photoIndex--;
}
if (input.IsKeyPressed(Keys.Home)) {
photoIndex = 0;
}
if (input.IsKeyPressed(Keys.End)) {
photoIndex = photos.Count - 1;
}
if (input.IsKeyPressed(Keys.PageDown)) {
photoIndex += 5;
}
if (input.IsKeyPressed(Keys.PageUp)) {
photoIndex -= 5;
}
2023-08-06 02:56:40 +00:00
if (input.IsKeyPressed(Keys.P) && ctrlIsDown) {
2023-07-28 19:52:36 +00:00
ExportPhotos();
}
// Make sure the photoIndex is actually valid.
if (photos.Count == 0) {
photoIndex = 0;
} else {
photoIndex = Math.Clamp(photoIndex, 0, photos.Count - 1);
}
2023-07-26 21:29:59 +00:00
// Handle presses of the "rating" keys -- 0-5 and `.
// A normal press just sets the rating of the current photo.
2023-08-06 03:21:18 +00:00
// If the user is holding "shift", we instead filter to only show photos
// of that rating or higher.
2023-07-26 21:29:59 +00:00
int rating = -1;
if (input.IsKeyPressed(Keys.D0) || input.IsKeyPressed(Keys.GraveAccent)) {
2023-07-26 21:29:59 +00:00
rating = 0;
2023-07-18 05:42:59 +00:00
}
if (input.IsKeyPressed(Keys.D1)) {
2023-07-26 21:29:59 +00:00
rating = 1;
2023-07-18 05:42:59 +00:00
}
if (input.IsKeyPressed(Keys.D2)) {
2023-07-26 21:29:59 +00:00
rating = 2;
2023-07-18 05:42:59 +00:00
}
if (input.IsKeyPressed(Keys.D3)) {
2023-07-26 21:29:59 +00:00
rating = 3;
2023-07-18 05:42:59 +00:00
}
if (input.IsKeyPressed(Keys.D4)) {
2023-07-26 21:29:59 +00:00
rating = 4;
2023-07-18 05:42:59 +00:00
}
if (input.IsKeyPressed(Keys.D5)) {
2023-07-26 21:29:59 +00:00
rating = 5;
}
if (rating >= 0) {
2023-07-31 04:58:21 +00:00
if (shiftIsDown) {
2023-07-26 21:29:59 +00:00
FilterByRating(rating);
} else {
if (photos.Count > 0) {
photos[photoIndex].Rating = rating;
}
2023-07-26 21:29:59 +00:00
}
2023-07-26 19:41:53 +00:00
}
if (input.IsKeyPressed(Keys.Q)) {
2023-07-26 19:41:53 +00:00
zoomLevel = 0f;
}
if (input.IsKeyPressed(Keys.W)) {
2023-07-26 19:41:53 +00:00
zoomLevel = 1f;
}
if (input.IsKeyPressed(Keys.E)) {
2023-07-26 19:41:53 +00:00
zoomLevel = 2f;
}
if (input.IsKeyPressed(Keys.R)) {
2023-07-26 19:41:53 +00:00
zoomLevel = 4f;
}
if (input.IsKeyPressed(Keys.T)) {
2023-07-26 19:41:53 +00:00
zoomLevel = 8f;
}
if (input.IsKeyPressed(Keys.Y)) {
2023-07-18 05:42:59 +00:00
zoomLevel = 16f;
}
// Handle tool switching.
if (photos[photoIndex] != previousPhoto) {
activeTool = viewTool;
}
if (activeTool == viewTool) {
if (input.IsKeyPressed(Keys.C)) {
2023-08-04 00:03:46 +00:00
activeTool = new CropTool(photos[photoIndex]);
}
}
// Delegate input to the active tool.
2023-08-05 18:34:06 +00:00
ToolStatus status = activeTool.HandleInput(KeyboardState, MouseState, transform);
// Change back to the default tool if the active tool is done.
2023-08-04 04:17:11 +00:00
if (status != ToolStatus.Active) {
activeTool = viewTool;
}
2023-06-30 02:10:24 +00:00
}
2023-07-26 21:29:59 +00:00
void FilterByRating(int rating) {
Console.WriteLine("filter to " + rating);
Photo previouslyActive = photos.Count > 0 ? photos[photoIndex] : allPhotos[0];
photos = allPhotos.Where(p => p.Rating >= rating).ToList();
// Move photoIndex to wherever the previously active photo was, or if it
// was filtered out, to whichever unfiltered photo comes before it. This
// is O(n) in the length of allPhotos, but how bad can it be? :)
photoIndex = -1;
for (int i = 0; i < allPhotos.Count; i++) {
Photo candidate = allPhotos[i];
if (candidate.Rating >= rating) {
photoIndex++;
}
if (candidate == previouslyActive) {
break;
}
}
photoIndex = Math.Max(0, photoIndex);
2023-07-26 21:29:59 +00:00
}
2023-08-03 23:32:53 +00:00
// FIXME: switch to immediate mode??
// https://gamedev.stackexchange.com/questions/198805/opentk-immediate-mode-on-net-core-doesnt-work
// https://www.youtube.com/watch?v=Q23Kf9QEaO4
2023-07-26 01:24:20 +00:00
protected override void OnLoad() {
2023-06-30 02:10:24 +00:00
base.OnLoad();
2023-07-07 05:44:51 +00:00
GL.ClearColor(0f, 0f, 0f, 1f);
2023-06-30 02:10:24 +00:00
GL.Enable(EnableCap.Blend);
GL.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
2023-06-30 02:10:24 +00:00
VertexArrayObject = GL.GenVertexArray();
GL.BindVertexArray(VertexArrayObject);
VertexBufferObject = GL.GenBuffer();
ElementBufferObject = GL.GenBuffer();
GL.BindBuffer(BufferTarget.ArrayBuffer, VertexBufferObject);
2023-08-06 03:21:18 +00:00
GL.BufferData(BufferTarget.ArrayBuffer, vertices.Length * sizeof(float),
vertices, BufferUsageHint.DynamicDraw);
2023-06-30 02:10:24 +00:00
GL.BindBuffer(BufferTarget.ElementArrayBuffer, ElementBufferObject);
2023-08-06 03:21:18 +00:00
GL.BufferData(BufferTarget.ElementArrayBuffer, indices.Length * sizeof(uint),
indices, BufferUsageHint.DynamicDraw);
2023-06-30 02:10:24 +00:00
shader.Init();
2023-06-30 02:10:24 +00:00
shader.Use();
2023-07-07 18:52:36 +00:00
// Because there's 5 floats between the start of the first vertex and the start of the second,
// the stride is 5 * sizeof(float).
2023-06-30 02:10:24 +00:00
// This will now pass the new vertex array to the buffer.
var vertexLocation = shader.GetAttribLocation("aPosition");
GL.EnableVertexAttribArray(vertexLocation);
2023-08-06 03:21:18 +00:00
GL.VertexAttribPointer(vertexLocation, 3, VertexAttribPointerType.Float,
false, 5 * sizeof(float), 0);
2023-06-30 02:10:24 +00:00
// Next, we also setup texture coordinates. It works in much the same way.
// We add an offset of 3, since the texture coordinates comes after the position data.
// We also change the amount of data to 2 because there's only 2 floats for texture coordinates.
var texCoordLocation = shader.GetAttribLocation("aTexCoord");
GL.EnableVertexAttribArray(texCoordLocation);
2023-08-06 03:21:18 +00:00
GL.VertexAttribPointer(texCoordLocation, 2, VertexAttribPointerType.Float,
false, 5 * sizeof(float), 3 * sizeof(float));
2023-06-30 02:10:24 +00:00
2023-07-26 01:24:20 +00:00
// Load photos from a directory.
2023-08-23 06:30:01 +00:00
// string[] files = Directory.GetFiles(@"c:\users\colin\desktop\photos-test\");
// string[] files = Directory.GetFiles(@"c:\users\colin\pictures\photos\2023\07\14\");
2023-08-05 18:34:06 +00:00
// string[] files = Directory.GetFiles(@"c:\users\colin\pictures\photos\2023\07\23\");
2023-08-03 23:38:53 +00:00
// string[] files = Directory.GetFiles(@"G:\DCIM\100EOSR6\");
// string[] files = Directory.GetFiles(@"c:\users\colin\desktop\totte-output\2023\07\31");
// string[] files = Directory.GetFiles(@"c:\users\colin\desktop\import");
2023-07-25 18:28:57 +00:00
// string[] files = Directory.GetFiles(@"C:\Users\colin\Pictures\photos\2018\06\23");
string[] files = Directory.GetFiles(@"C:\Users\colin\Desktop\Germany all\104D7000");
// string[] files = Directory.GetFiles(@"C:\Users\colin\Desktop\many-birds\");
2023-07-17 03:45:50 +00:00
for (int i = 0; i < files.Count(); i++) {
string file = files[i];
2023-06-30 02:10:24 +00:00
if (file.ToLower().EndsWith(".jpg")) {
Photo photo = new Photo(file, TEXTURE_BLACK);
2023-07-26 21:29:59 +00:00
allPhotos.Add(photo);
2023-06-30 02:10:24 +00:00
}
}
2023-07-26 21:29:59 +00:00
allPhotos.Sort(ComparePhotosByDate);
photos = allPhotos;
LoadThumbnailsAsync();
2023-06-30 02:10:24 +00:00
}
2023-07-26 00:25:07 +00:00
private static int ComparePhotosByDate(Photo x, Photo y) {
int compare = x.DateTimeOriginal.CompareTo(y.DateTimeOriginal);
if (compare != 0) {
return compare;
}
// If the photos have the same seconds value, sort by filename
// (since cameras usually increment the filename for successive shots.)
return x.Filename.CompareTo(y.Filename);
}
2023-06-30 02:10:24 +00:00
protected override void OnUnload() {
base.OnUnload();
}
private void UnloadImages() {
// Unload images that haven't been touched in a while.
lock (loadedImagesLock) {
while (loadedImages.Count > 30) {
long earliestTime = long.MaxValue;
Photo? earliest = null;
foreach (Photo photo in loadedImages) {
if (photo.LastTouch < earliestTime) {
earliest = photo;
earliestTime = photo.LastTouch;
}
}
if (earliest != null) {
2023-08-06 03:21:18 +00:00
Console.WriteLine($"loadedImages.Count: {loadedImages.Count}, " +
$"evicting {earliest.Filename} @ {earliestTime}");
// TODO: we have to free textures on the GL thread, but could we do
// that async'ly to keep the UI responsive?
earliest.Unload();
loadedImages.Remove(earliest);
2023-07-27 01:29:26 +00:00
}
}
}
}
private async void LoadImagesAsync() {
foreach (Photo p in loadingImages) {
if (p.Loaded) {
lock (loadedImagesLock) {
loadedImages.Add(p);
loadingImages.Remove(p);
}
2023-07-26 01:24:20 +00:00
}
}
// Start loading any images that are in our window but not yet loaded.
int minLoadedImage = Math.Max(0, photoIndex - 10);
int maxLoadedImage = Math.Min(photoIndex + 10, photos.Count - 1);
List<Photo> toLoad = new();
2023-07-26 01:24:20 +00:00
for (int i = minLoadedImage; i <= maxLoadedImage; i++) {
lock (loadedImagesLock) {
if (!loadedImages.Contains(photos[i]) && !loadingImages.Contains(photos[i])) {
Console.WriteLine("loading " + i);
loadingImages.Add(photos[i]);
toLoad.Add(photos[i]);
}
2023-07-26 01:24:20 +00:00
}
}
foreach (Photo p in toLoad) {
await Task.Run( () => { p.LoadAsync(); });
}
2023-07-26 01:24:20 +00:00
}
private async void LoadThumbnailsAsync() {
foreach (Photo p in allPhotos) {
await Task.Run( () => { p.LoadThumbnailAsync(); });
}
}
2023-07-28 19:52:36 +00:00
// To find the JPEG compression level of a file from the command line:
// $ identify -verbose image.jpg | grep Quality:
2023-07-28 20:28:18 +00:00
// FIXME: don't ExportPhotos() if another export is already active.
// FIXME: show a progress bar or something.
2023-08-23 06:30:01 +00:00
// FIXME: if there's a photo missing GPS information, copy the information
// from the previous photo in the export.
2023-07-28 20:28:18 +00:00
private async void ExportPhotos() {
JpegEncoder encoder = new JpegEncoder() { Quality = 100 };
2023-07-28 19:52:36 +00:00
foreach (Photo p in photos) {
await Task.Run( () => { p.SaveAsJpegAsync(outputRoot, encoder); });
2023-07-28 19:52:36 +00:00
}
}
2023-06-30 02:10:24 +00:00
protected override void OnRenderFrame(FrameEventArgs e) {
base.OnRenderFrame(e);
2023-07-26 02:19:18 +00:00
fpsCounter.Update();
2023-07-17 03:45:50 +00:00
UnloadImages();
LoadImagesAsync();
2023-07-26 01:24:20 +00:00
2023-06-30 02:10:24 +00:00
GL.Clear(ClearBufferMask.ColorBufferBit);
GL.BindBuffer(BufferTarget.ArrayBuffer, VertexBufferObject);
GL.ActiveTexture(TextureUnit.Texture0);
2023-06-30 02:10:24 +00:00
if (photos.Count > 0) {
DrawPhotos();
} else {
DrawText("No photos found.", 10, 10);
}
activeTool.Draw(geometry, this);
2023-08-01 18:12:52 +00:00
SwapBuffers();
}
void DrawPhotos() {
Photo activePhoto = photos[photoIndex];
Texture active = activePhoto.Texture();
// FIXME: make a function for scaling & centering one box on another.
float scaleX = 1f * geometry.PhotoBox.Size.X / active.Size.X;
float scaleY = 1f * geometry.PhotoBox.Size.Y / active.Size.Y;
float scale = Math.Min(scaleX, scaleY);
2023-07-18 05:42:59 +00:00
if (zoomLevel > 0f) {
scale = zoomLevel;
}
2023-08-02 03:14:00 +00:00
activeScale = scale;
Vector2i renderSize = (Vector2i) (((Vector2) active.Size) * scale);
Vector2i center = (Vector2i) geometry.PhotoBox.Center;
2023-08-06 03:12:18 +00:00
Box2i photoBox = Util.MakeBox(center.X - renderSize.X / 2, center.Y - renderSize.Y / 2,
renderSize.X, renderSize.Y);
activeOffset = new(photoBox.Min.X, photoBox.Min.Y);
transform = new Transform(activeScale, activeOffset, activePhoto.Size);
2023-07-26 16:23:58 +00:00
DrawTexture(active, photoBox);
for (int i = 0; i < 5; i++) {
Texture star = (activePhoto.Rating > i) ? STAR_FILLED : STAR_EMPTY;
DrawTexture(star, geometry.StarBoxes[i].Min.X, geometry.StarBoxes[i].Min.Y);
2023-07-26 16:23:58 +00:00
}
bool cropActive = activeTool is CropTool;
DrawCropRectangle(cropActive);
// Draw thumbnail boxes.
2023-08-06 03:12:18 +00:00
ribbonIndex = Math.Clamp(photoIndex - (geometry.ThumbnailBoxes.Count - 1) / 2,
0, Math.Max(0, photos.Count - geometry.ThumbnailBoxes.Count));
DrawFilledBox(geometry.ThumbnailBox, Color4.Black);
for (int i = 0; i < geometry.ThumbnailBoxes.Count; i++) {
if (ribbonIndex + i >= photos.Count) {
break;
}
2023-07-26 17:10:19 +00:00
Photo photo = photos[ribbonIndex + i];
2023-07-07 18:52:36 +00:00
Box2i box = geometry.ThumbnailBoxes[i];
2023-07-26 17:10:19 +00:00
DrawTexture(photo.Texture(), box);
for (int j = 0; j < photo.Rating; j++) {
DrawTexture(STAR_SMALL, box.Min.X + 8 + ((STAR_SMALL.Size.X + 2) * j), box.Min.Y + 8);
2023-07-26 17:10:19 +00:00
}
if (ribbonIndex + i == photoIndex) {
2023-07-07 06:33:01 +00:00
DrawBox(box, 5, Color4.Black);
DrawBox(box, 3, Color4.White);
}
2023-06-30 02:10:24 +00:00
}
2023-07-24 17:14:07 +00:00
// Draw status box.
2023-07-26 15:24:56 +00:00
int statusPadding = 2;
2023-07-25 13:45:40 +00:00
DrawFilledBox(geometry.StatusBox, Color4.Black);
// First line.
int y = geometry.StatusBox.Min.Y + statusPadding;
2023-08-06 03:21:18 +00:00
DrawText(String.Format("{0,4}/{1,-4}", photoIndex + 1, photos.Count),
geometry.StatusBox.Min.X, y);
DrawText(activePhoto.Description(), geometry.StatusBox.Min.X + 88, y);
// Second line.
y += 20;
2023-08-03 23:38:53 +00:00
DrawText(activeTool.Status(), geometry.StatusBox.Min.X, y);
DrawText(String.Format("FPS: {0,2}", fpsCounter.Fps), geometry.StatusBox.Max.X - 66, y);
if (activePhoto.Loaded) {
2023-08-03 23:38:53 +00:00
DrawText($"{(scale * 100):F1}%", geometry.StatusBox.Max.X - 136, y);
2023-07-24 16:36:44 +00:00
}
}
void DrawCropRectangle(bool active) {
Photo activePhoto = photos[photoIndex];
if (activePhoto.CropRectangle == Rectangle.Empty) {
return;
}
2023-08-06 03:21:18 +00:00
Vector2i leftTop = transform.ImageToScreen(activePhoto.CropRectangle.Left,
activePhoto.CropRectangle.Top);
Vector2i rightBottom = transform.ImageToScreen(activePhoto.CropRectangle.Right,
activePhoto.CropRectangle.Bottom);
var (left, top) = leftTop;
var (right, bottom) = rightBottom;
Color4 shadeColor = new Color4(0, 0, 0, 0.75f);
DrawFilledBox(new Box2i(0, 0, left, geometry.PhotoBox.Max.Y), shadeColor);
DrawFilledBox(new Box2i(left, 0, geometry.PhotoBox.Max.X, top), shadeColor);
2023-08-06 03:21:18 +00:00
DrawFilledBox(new Box2i(left, bottom, geometry.PhotoBox.Max.X, geometry.PhotoBox.Max.Y),
shadeColor);
DrawFilledBox(new Box2i(right, top, geometry.PhotoBox.Max.X, bottom), shadeColor);
DrawBox(new Box2i(left - 1, top - 1, right + 1, bottom + 1), 1, Color4.White);
if (active) {
// handles
int handleThickness = 3;
int handleLength = 16;
// top-left
DrawFilledBox(new Box2i(left - handleThickness, top - handleThickness,
left + handleLength, top), Color4.White);
DrawFilledBox(new Box2i(left - handleThickness, top - handleThickness,
left, top + handleLength), Color4.White);
// top-right
DrawFilledBox(new Box2i(right - handleLength, top - handleThickness,
right + handleThickness, top), Color4.White);
DrawFilledBox(new Box2i(right, top - handleThickness,
right + handleThickness, top + handleLength), Color4.White);
// bottom-left
DrawFilledBox(new Box2i(left - handleThickness, bottom,
left + handleLength, bottom + handleThickness), Color4.White);
DrawFilledBox(new Box2i(left - handleThickness, bottom - handleLength,
left, bottom + handleThickness), Color4.White);
// bottom-right
DrawFilledBox(new Box2i(right - handleLength, bottom,
right + handleThickness, bottom + handleThickness), Color4.White);
DrawFilledBox(new Box2i(right + handleThickness, bottom - handleLength,
right, bottom + handleThickness), Color4.White);
// thirds
DrawHorizontalLine(left, Util.Lerp(top, bottom, 1.0 / 3), right, Color4.White);
DrawHorizontalLine(left, Util.Lerp(top, bottom, 2.0 / 3), right, Color4.White);
DrawVerticalLine(Util.Lerp(left, right, 1.0 / 3), top, bottom, Color4.White);
DrawVerticalLine(Util.Lerp(left, right, 2.0 / 3), top, bottom, Color4.White);
}
}
public void DrawTexture(Texture texture, int x, int y) {
DrawTexture(texture, Util.MakeBox(x, y, texture.Size.X, texture.Size.Y));
}
public void DrawTexture(Texture texture, Box2i box) {
2023-07-07 03:36:55 +00:00
DrawTexture(texture, box, Color4.White);
}
public void DrawTexture(Texture texture, Box2i box, Color4 color) {
2023-07-07 05:07:58 +00:00
GL.Uniform4(shader.GetUniformLocation("color"), color);
2023-07-07 03:48:12 +00:00
SetVertices(box.Min.X, box.Min.Y, box.Size.X, box.Size.Y);
2023-08-06 03:21:18 +00:00
GL.BufferData(BufferTarget.ArrayBuffer, vertices.Length * sizeof(float), vertices,
BufferUsageHint.DynamicDraw);
GL.BindTexture(TextureTarget.Texture2D, texture.Handle);
GL.DrawElements(PrimitiveType.Triangles, indices.Length, DrawElementsType.UnsignedInt, 0);
}
public void DrawHorizontalLine(int left, int top, int right, Color4 color) {
2023-08-01 18:12:52 +00:00
DrawTexture(TEXTURE_WHITE, Util.MakeBox(left, top, right - left, 1), color);
}
public void DrawVerticalLine(int left, int top, int bottom, Color4 color) {
2023-08-01 18:12:52 +00:00
DrawTexture(TEXTURE_WHITE, Util.MakeBox(left, top, 1, bottom - top), color);
}
public void DrawBox(Box2i box, int thickness, Color4 color) {
2023-08-06 03:21:18 +00:00
DrawTexture(TEXTURE_WHITE,
Util.MakeBox(box.Min.X, box.Min.Y, box.Size.X, thickness), color);
DrawTexture(TEXTURE_WHITE,
Util.MakeBox(box.Min.X, box.Min.Y, thickness, box.Size.Y), color);
DrawTexture(TEXTURE_WHITE,
Util.MakeBox(box.Min.X, box.Max.Y - thickness, box.Size.X, thickness), color);
DrawTexture(TEXTURE_WHITE,
Util.MakeBox(box.Max.X - thickness, box.Min.Y, thickness, box.Size.Y), color);
}
public void DrawFilledBox(Box2i box, Color4 color) {
2023-07-25 13:45:40 +00:00
DrawTexture(TEXTURE_WHITE, Util.MakeBox(box.Min.X, box.Min.Y, box.Size.X, box.Size.Y), color);
}
public void DrawText(string text, int x, int y) {
Texture label = Util.RenderText(text);
DrawTexture(label, x, y);
label.Dispose();
}
2023-06-30 02:10:24 +00:00
protected override void OnResize(ResizeEventArgs e) {
base.OnResize(e);
Console.WriteLine($"OnResize: {e.Width}x{e.Height}");
geometry = new UiGeometry(e.Size, STAR_FILLED.Size.X);
projection = Matrix4.CreateOrthographicOffCenter(0f, e.Width, e.Height, 0f, -1f, 1f);
2023-06-30 02:10:24 +00:00
GL.UniformMatrix4(shader.GetUniformLocation("projection"), true, ref projection);
GL.Viewport(0, 0, e.Width, e.Height);
2023-06-30 02:10:24 +00:00
}
private void SetVertices(float left, float top, float width, float height) {
2023-06-30 02:10:24 +00:00
// top left
vertices[0] = left;
vertices[1] = top;
vertices[2] = 0f;
vertices[3] = 0f;
vertices[4] = 0f;
// top right
vertices[5] = left + width;
2023-06-30 02:10:24 +00:00
vertices[6] = top;
vertices[7] = 0f;
vertices[8] = 1f;
vertices[9] = 0f;
// bottom right
vertices[10] = left + width;
vertices[11] = top + height;
2023-06-30 02:10:24 +00:00
vertices[12] = 0f;
vertices[13] = 1f;
vertices[14] = 1f;
// bottom left
vertices[15] = left;
vertices[16] = top + height;
2023-06-30 02:10:24 +00:00
vertices[17] = 0f;
vertices[18] = 0f;
vertices[19] = 1f;
}
}
2023-06-30 02:10:24 +00:00
static class Program {
static void Main(string[] args) {
List<MonitorInfo> monitors = Monitors.GetMonitors();
MonitorInfo bestMonitor = monitors[0];
int bestResolution = bestMonitor.HorizontalResolution * bestMonitor.VerticalResolution;
for (int i = 1; i < monitors.Count; i++) {
MonitorInfo monitor = monitors[i];
int resolution = monitor.HorizontalResolution * monitor.VerticalResolution;
if (resolution > bestResolution) {
bestResolution = resolution;
bestMonitor = monitor;
}
}
2023-08-06 03:21:18 +00:00
Console.WriteLine(
$"best monitor: {bestMonitor.HorizontalResolution}x{bestMonitor.VerticalResolution}");
2023-07-07 18:52:36 +00:00
GameWindowSettings gwSettings = new();
2023-07-26 02:19:18 +00:00
gwSettings.UpdateFrequency = 30.0;
gwSettings.RenderFrequency = 30.0;
2023-06-30 02:10:24 +00:00
2023-07-07 18:52:36 +00:00
NativeWindowSettings nwSettings = new();
2023-06-30 02:10:24 +00:00
nwSettings.WindowState = WindowState.Normal;
nwSettings.CurrentMonitor = bestMonitor.Handle;
2023-08-06 03:21:18 +00:00
nwSettings.Location = new Vector2i(bestMonitor.WorkArea.Min.X + 1,
bestMonitor.WorkArea.Min.Y + 31);
nwSettings.Size = new Vector2i(bestMonitor.WorkArea.Size.X - 2,
bestMonitor.WorkArea.Size.Y - 32);
// nwSettings.Size = new Vector2i(1600, 900);
nwSettings.MinimumSize = UiGeometry.MIN_WINDOW_SIZE;
2023-06-30 02:10:24 +00:00
nwSettings.Title = "Totte";
2023-08-23 06:30:01 +00:00
nwSettings.IsEventDriven = true;
2023-07-26 14:00:46 +00:00
nwSettings.Icon = new WindowIcon(Util.RenderAppIcon());
2023-06-30 02:10:24 +00:00
2023-07-08 04:33:15 +00:00
using (Game game = new(gwSettings, nwSettings)) {
2023-06-30 02:10:24 +00:00
game.Run();
}
}
}