totte/Program.cs

1101 lines
37 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
2023-06-30 02:10:24 +00:00
using Image = SixLabors.ImageSharp.Image;
using SixLabors.Fonts;
using SixLabors.ImageSharp.Metadata.Profiles.Exif;
using SixLabors.ImageSharp.Metadata.Profiles.Xmp;
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;
using System.Runtime.CompilerServices;
using System.Xml.Linq;
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 readonly Vector2i Resolution;
private CameraInfo(Vector2i resolution) {
Resolution = resolution;
}
2023-07-08 04:33:15 +00:00
public static readonly CameraInfo NIKON_D7000 = new(new Vector2i(4928, 3264));
public static readonly CameraInfo CANON_EOS_R6M2 = new(new Vector2i(6000, 4000));
2023-07-08 04:33:15 +00:00
public static readonly CameraInfo IPHONE_12_MINI = new(new Vector2i(4032, 3024));
}
2023-07-25 18:28:57 +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-06-30 02:10:24 +00:00
public class Shader : IDisposable {
public int Handle;
private bool init = false;
public Shader() {}
2023-06-30 02:10:24 +00:00
public void Init() {
init = true;
2023-06-30 02:10:24 +00:00
int VertexShader;
int FragmentShader;
string VertexShaderSource = @"
#version 330
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec2 aTexCoord;
out vec2 texCoord;
uniform mat4 projection;
void main(void) {
texCoord = aTexCoord;
gl_Position = vec4(aPosition, 1.0) * projection;
}";
string FragmentShaderSource = @"
#version 330
out vec4 outputColor;
in vec2 texCoord;
uniform sampler2D texture0;
uniform vec4 color;
2023-06-30 02:10:24 +00:00
void main() {
outputColor = texture(texture0, texCoord) * color;
2023-06-30 02:10:24 +00:00
}";
VertexShader = GL.CreateShader(ShaderType.VertexShader);
GL.ShaderSource(VertexShader, VertexShaderSource);
FragmentShader = GL.CreateShader(ShaderType.FragmentShader);
GL.ShaderSource(FragmentShader, FragmentShaderSource);
GL.CompileShader(VertexShader);
int success;
GL.GetShader(VertexShader, ShaderParameter.CompileStatus, out success);
if (success == 0) {
string infoLog = GL.GetShaderInfoLog(VertexShader);
Console.WriteLine(infoLog);
}
GL.CompileShader(FragmentShader);
GL.GetShader(FragmentShader, ShaderParameter.CompileStatus, out success);
if (success == 0) {
string infoLog = GL.GetShaderInfoLog(FragmentShader);
Console.WriteLine(infoLog);
}
Handle = GL.CreateProgram();
GL.AttachShader(Handle, VertexShader);
GL.AttachShader(Handle, FragmentShader);
GL.LinkProgram(Handle);
GL.GetProgram(Handle, GetProgramParameterName.LinkStatus, out success);
if (success == 0) {
string infoLog = GL.GetProgramInfoLog(Handle);
Console.WriteLine(infoLog);
}
GL.DetachShader(Handle, VertexShader);
GL.DetachShader(Handle, FragmentShader);
GL.DeleteShader(FragmentShader);
GL.DeleteShader(VertexShader);
}
public void Use() {
if (!init) {
Console.WriteLine("Shader.Use(): must call Init() first");
}
2023-06-30 02:10:24 +00:00
GL.UseProgram(Handle);
}
private bool disposedValue = false;
protected virtual void Dispose(bool disposing) {
if (!disposedValue) {
GL.DeleteProgram(Handle);
disposedValue = true;
}
}
~Shader() {
if (disposedValue == false) {
Console.WriteLine("~Shader(): resource leak? Dispose() should be called manually.");
}
}
public void Dispose() {
Dispose(true);
GC.SuppressFinalize(this);
}
public int GetAttribLocation(string name) {
return GL.GetAttribLocation(Handle, name);
}
public int GetUniformLocation(string name) {
return GL.GetUniformLocation(Handle, name);
}
}
2023-07-17 06:44:34 +00:00
// FIXME: this should probably be IDisposable?
2023-07-16 23:23:03 +00:00
public class Photo {
2023-07-26 00:25:07 +00:00
public string Filename;
2023-07-24 20:07:17 +00:00
public bool Loaded = false;
2023-07-27 01:29:26 +00:00
public long LastTouch = 0;
public Vector2i Size;
2023-07-26 00:25:07 +00:00
public DateTime DateTimeOriginal;
public string CameraModel = "";
public string LensModel = "";
public string FocalLength = "<unk>";
2023-07-24 20:07:17 +00:00
public string FNumber = "<unk>";
public string ExposureTime = "<unk>";
public string IsoSpeed = "<unk>";
public int Rating = 0;
public ushort Orientation = 1;
2023-07-24 20:07:17 +00:00
2023-07-27 01:29:26 +00:00
private static long touchCounter = 0;
2023-07-17 03:45:50 +00:00
private Texture texture;
private Texture placeholder;
2023-07-17 06:44:34 +00:00
private Image<Rgba32>? image = null;
2023-07-16 23:23:03 +00:00
2023-07-26 00:25:07 +00:00
public Photo(string filename, Texture placeholder) {
Filename = filename;
2023-07-17 03:45:50 +00:00
this.placeholder = placeholder;
texture = placeholder;
2023-07-26 00:25:07 +00:00
DateTime creationTime = File.GetCreationTime(filename); // Local time.
DateTimeOriginal = creationTime;
ImageInfo info = Image.Identify(filename);
Size = new(info.Size.Width, info.Size.Height);
ParseExif(info.Metadata.ExifProfile);
TryParseRating(info.Metadata.XmpProfile, out Rating);
2023-07-17 06:44:34 +00:00
}
2023-06-30 02:21:41 +00:00
2023-07-26 01:24:20 +00:00
public async void LoadAsync() {
2023-07-25 18:28:57 +00:00
// We don't assign to this.image until Load() is done, because we might
// edit the image due to rotation (etc) and don't want to try generating
// a texture for it until that's already happened.
2023-07-27 01:29:26 +00:00
LastTouch = touchCounter++;
2023-07-26 00:25:07 +00:00
Image<Rgba32> tmp = await Image.LoadAsync<Rgba32>(Filename);
2023-07-25 21:10:51 +00:00
Util.RotateImageFromExif(tmp, Orientation);
image = tmp;
}
2023-07-17 03:45:50 +00:00
2023-07-28 16:27:53 +00:00
public void Unload() {
2023-07-26 02:00:43 +00:00
Loaded = false;
2023-07-26 01:24:20 +00:00
if (texture != placeholder) {
texture.Dispose();
2023-07-26 01:24:20 +00:00
texture = placeholder;
}
}
2023-07-28 20:28:18 +00:00
public async void SaveAsJpeg(string outputRoot, JpegEncoder encoder) {
// FIXME: if nothing was changed about this image, just copy the file bytes directly, possibly with metadata changed?
2023-07-28 19:52:36 +00:00
string directory = System.IO.Path.Combine(
outputRoot,
String.Format("{0:D4}", DateTimeOriginal.Year),
String.Format("{0:D2}", DateTimeOriginal.Month),
String.Format("{0:D2}", DateTimeOriginal.Day));
Directory.CreateDirectory(directory);
string filename = System.IO.Path.Combine(directory, System.IO.Path.GetFileName(Filename));
Console.WriteLine("saving " + filename);
2023-07-28 21:54:00 +00:00
// FIXME: update Rating data.
// FIXME: add comments / captions as ImageDescription?
// FIXME: strip some Exif tags for privacy reasons?
2023-07-28 20:28:18 +00:00
// FIXME: warn if the file already exists?
2023-07-28 20:32:54 +00:00
using (Image<Rgba32> image = await Image.LoadAsync<Rgba32>(Filename)) {
2023-07-28 21:54:00 +00:00
Util.RotateImageFromExif(image, Orientation);
ExifProfile exif = image.Metadata.ExifProfile ?? new();
exif.SetValue<ushort>(ExifTag.Orientation, 1);
exif.SetValue<string>(ExifTag.Artist, "Colin McMillen");
exif.SetValue<string>(ExifTag.Copyright, "Colin McMillen");
exif.SetValue<string>(ExifTag.Software, "Totte");
DateTime now = DateTime.Now;
string datetime = String.Format(
"{0:D4}:{1:D2}:{2:D2} {3:D2}:{4:D2}:{5:D2}",
now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second);
exif.SetValue<string>(ExifTag.DateTime, datetime);
2023-07-28 20:28:18 +00:00
await image.SaveAsync(filename, encoder);
2023-07-28 19:52:36 +00:00
}
}
2023-07-25 13:54:41 +00:00
private bool TryParseRating(XmpProfile? xmp, out int rating) {
rating = 0;
if (xmp == null) {
2023-07-25 13:54:41 +00:00
return false;
}
XDocument? doc = xmp.GetDocument();
if (doc == null) {
2023-07-25 13:54:41 +00:00
return false;
}
XElement? root = doc.Root;
if (root == null) {
2023-07-25 13:54:41 +00:00
return false;
}
foreach (XElement elt in root.Descendants()) {
if (elt.Name == "{http://ns.adobe.com/xap/1.0/}Rating") {
if (int.TryParse(elt.Value, out rating)) {
2023-07-25 13:54:41 +00:00
return true;
}
}
}
2023-07-25 13:54:41 +00:00
return false;
}
2023-07-26 00:25:07 +00:00
// Exif (and other image metadata) reference, from the now-defunct Metadata Working Group:
// https://web.archive.org/web/20180919181934/http://www.metadataworkinggroup.org/pdf/mwg_guidance.pdf
//
// Specifically:
//
// In general, date/time metadata is being used to describe the following scenarios:
// * Date/time original specifies when a photo was taken
// * Date/time digitized specifies when an image was digitized
// * Date/time modified specifies when a file was modified by the user
//
// Original Date/Time – Creation date of the intellectual content (e.g. the photograph), rather than the creation date of the content being shown
// Exif DateTimeOriginal (36867, 0x9003) and SubSecTimeOriginal (37521, 0x9291)
// IPTC DateCreated (IIM 2:55, 0x0237) and TimeCreated (IIM 2:60, 0x023C)
// XMP (photoshop:DateCreated)
//
// Digitized Date/Time – Creation date of the digital representation
// Exif DateTimeDigitized (36868, 0x9004) and SubSecTimeDigitized (37522, 0x9292)
// IPTC DigitalCreationDate (IIM 2:62, 0x023E) and DigitalCreationTime (IIM 2:63, 0x023F)
// XMP (xmp:CreateDate)
//
// Modification Date/Time – Modification date of the digital image file
// Exif DateTime (306, 0x132) and SubSecTime (37520, 0x9290)
// XMP (xmp:ModifyDate)
2023-07-28 21:54:00 +00:00
//
// See also: https://exiftool.org/TagNames/EXIF.html
private void ParseExif(ExifProfile? exifs) {
if (exifs == null) {
return;
}
IExifValue<ushort>? orientation;
if (exifs.TryGetValue(ExifTag.Orientation, out orientation)) {
Orientation = orientation.Value;
}
IExifValue<string>? model;
if (exifs.TryGetValue(ExifTag.Model, out model)) {
CameraModel = model.Value ?? "";
}
IExifValue<string>? lensModel;
if (exifs.TryGetValue(ExifTag.LensModel, out lensModel)) {
LensModel = lensModel.Value ?? "";
}
IExifValue<Rational>? focalLength;
if (exifs.TryGetValue(ExifTag.FocalLength, out focalLength)) {
Rational r = focalLength.Value;
FocalLength = $"{r.Numerator / r.Denominator}mm";
}
IExifValue<Rational>? fNumber;
if (exifs.TryGetValue(ExifTag.FNumber, out fNumber)) {
Rational r = fNumber.Value;
if (r.Numerator % r.Denominator == 0) {
FNumber = $"f/{r.Numerator / r.Denominator}";
} else {
int fTimesTen = (int) Math.Round(10f * r.Numerator / r.Denominator);
FNumber = $"f/{fTimesTen / 10}.{fTimesTen % 10}";
}
}
2023-07-28 21:54:00 +00:00
// FIXME: could also show ExposureBiasValue, ExposureMode, ExposureProgram?
IExifValue<Rational>? exposureTime;
if (exifs.TryGetValue(ExifTag.ExposureTime, out exposureTime)) {
Rational r = exposureTime.Value;
if (r.Numerator == 1) {
ExposureTime = $"1/{r.Denominator}";
} else if (r.Numerator == 10) {
ExposureTime = $"1/{r.Denominator / 10}";
} else if (r.Denominator == 1) {
ExposureTime = $"{r.Numerator }\"";
} else if (r.Denominator == 10) {
ExposureTime = $"{r.Numerator / 10}.{r.Numerator % 10}\"";
} else {
Console.WriteLine($"*** WARNING: unexpected ExposureTime: {r.Numerator}/{r.Denominator}");
ExposureTime = r.ToString();
}
}
IExifValue<ushort[]>? isoSpeed;
if (exifs.TryGetValue(ExifTag.ISOSpeedRatings, out isoSpeed)) {
ushort[]? iso = isoSpeed.Value;
if (iso != null) {
if (iso.Length != 1) {
Console.WriteLine($"*** WARNING: unexpected ISOSpeedRatings array length: {iso.Length}");
}
if (iso.Length >= 1) {
IsoSpeed = $"ISO {iso[0]}";
}
}
}
2023-07-26 00:25:07 +00:00
2023-07-28 16:21:38 +00:00
// FIXME: there is also a SubSecTimeOriginal tag we could use to get fractional seconds.
2023-07-26 00:25:07 +00:00
// FIXME: I think the iPhone stores time in UTC but other cameras report it in local time.
IExifValue<string>? dateTimeOriginal;
if (exifs.TryGetValue(ExifTag.DateTimeOriginal, out dateTimeOriginal)) {
DateTime date;
if (DateTime.TryParseExact(
dateTimeOriginal.Value ?? "",
"yyyy:MM:dd HH:mm:ss",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeLocal,
out date)) {
DateTimeOriginal = date;
} else {
Console.WriteLine($"*** WARNING: unexpected DateTimeOriginal value: {dateTimeOriginal.Value}");
}
}
}
2023-07-17 03:45:50 +00:00
public Texture Texture() {
2023-07-27 01:29:26 +00:00
LastTouch = touchCounter++;
if (texture == placeholder && image != null) {
// The texture needs to be created on the GL thread, so we instantiate
// it here (since this is called from OnRenderFrame), as long as the
// image is ready to go.
texture = new Texture(image);
image.Dispose();
image = null;
2023-07-24 16:36:44 +00:00
Loaded = true;
}
return texture;
}
2023-07-24 20:07:17 +00:00
public string Description() {
2023-07-26 00:35:12 +00:00
string date = DateTimeOriginal.ToString("yyyy-MM-dd HH:mm:ss");
string shootingInfo = $"{date} {FocalLength}, {FNumber} at {ExposureTime}, {IsoSpeed}";
return String.Format("{0,-60} {1,-50} {2}", shootingInfo, $"{CameraModel} {LensModel}", Filename);
2023-07-24 20:07:17 +00:00
}
}
public class Texture : IDisposable {
public int Handle;
public Vector2i Size;
2023-07-27 01:29:26 +00:00
private static int maxHandle = -1;
private bool disposedValue = false;
public Texture(Image<Rgba32> image) {
Size = new Vector2i(image.Width, image.Height);
2023-07-08 03:41:32 +00:00
byte[] pixelBytes = new byte[Size.X * Size.Y * Unsafe.SizeOf<Rgba32>()];
2023-06-30 02:10:24 +00:00
image.CopyPixelDataTo(pixelBytes);
Handle = GL.GenTexture();
if (Handle > maxHandle) {
// Console.WriteLine("GL.GenTexture #" + Handle);
maxHandle = Handle;
}
2023-06-30 02:10:24 +00:00
GL.ActiveTexture(TextureUnit.Texture0);
GL.BindTexture(TextureTarget.Texture2D, Handle);
2023-07-08 03:41:32 +00:00
GL.TexImage2D(TextureTarget.Texture2D, 0, PixelInternalFormat.Rgba, Size.X, Size.Y, 0, PixelFormat.Rgba, PixelType.UnsignedByte, pixelBytes);
//GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int) TextureMinFilter.LinearMipmapLinear);
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int) TextureMinFilter.Linear);
2023-06-30 02:10:24 +00:00
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int) TextureMagFilter.Nearest);
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int) TextureWrapMode.ClampToBorder);
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int) TextureWrapMode.ClampToBorder);
float[] borderColor = { 0.0f, 0.0f, 0.0f, 1.0f };
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureBorderColor, borderColor);
// FIXME: should we use mipmaps?
//GL.GenerateMipmap(GenerateMipmapTarget.Texture2D);
2023-06-30 02:10:24 +00:00
}
protected virtual void Dispose(bool disposing) {
if (!disposedValue) {
GL.DeleteTexture(Handle);
disposedValue = true;
}
}
~Texture() {
if (!disposedValue) {
Console.WriteLine("~Texture(): resource leak? Dispose() should be called manually.");
}
}
public void Dispose() {
Dispose(true);
GC.SuppressFinalize(this);
}
}
public class UiGeometry {
public static Vector2i MIN_WINDOW_SIZE = new(1024, 768);
private static CameraInfo activeCamera = CameraInfo.CANON_EOS_R6M2;
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;
2023-07-07 18:52:36 +00:00
int thumbnailWidth = (int) 1.0 * thumbnailHeight * activeCamera.Resolution.X / activeCamera.Resolution.Y;
2023-07-28 18:36:02 +00:00
Console.WriteLine($"thumbnail size: {thumbnailWidth} x {thumbnailHeight}");
for (int i = 0; i < numThumbnails; i++) {
Box2i box = Util.MakeBox(WindowSize.X - thumbnailWidth, i * thumbnailHeight, thumbnailWidth, thumbnailHeight);
2023-07-07 18:52:36 +00:00
ThumbnailBoxes.Add(box);
}
2023-07-24 17:14:07 +00:00
int statusBoxHeight = 20;
int statusBoxPadding = 4;
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++) {
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
}
}
public static class Util {
public const float PI = (float) Math.PI;
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
// 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-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) {
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-07-08 04:20:23 +00:00
public Game(GameWindowSettings gwSettings, NativeWindowSettings nwSettings) : base(gwSettings, nwSettings) {}
2023-06-30 02:10:24 +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)));
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();
2023-07-16 23:25:28 +00:00
int photoIndex = 0;
int ribbonIndex = 0;
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);
KeyboardState input = KeyboardState;
2023-07-31 04:58:21 +00:00
bool shiftIsDown = input.IsKeyDown(Keys.LeftShift) || input.IsKeyDown(Keys.RightShift);
bool ctrlIsDown = input.IsKeyDown(Keys.LeftControl) || input.IsKeyDown(Keys.RightControl);
// FIXME: add a confirm dialog before closing. (Also for the window-close button.)
// Close when Escape is pressed.
if (input.IsKeyPressed(Keys.Escape)) {
Close();
}
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
Vector2i click = (Vector2i) MouseState.Position;
for (int i = 0; i < geometry.StarBoxes.Count; i++) {
if (geometry.StarBoxes[i].ContainsInclusive(click)) {
photos[photoIndex].Rating = i + 1;
}
}
2023-07-08 04:18:31 +00:00
for (int i = 0; i < geometry.ThumbnailBoxes.Count; i++) {
2023-07-26 19:20:20 +00:00
if (geometry.ThumbnailBoxes[i].ContainsInclusive(click)) {
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)) {