diff --git a/Brio.sln b/Brio.sln index acfcb1d8..ee494f70 100644 --- a/Brio.sln +++ b/Brio.sln @@ -5,16 +5,29 @@ VisualStudioVersion = 17.8.34316.72 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Brio", "Brio\Brio.csproj", "{6E14631E-8223-427D-8A03-550EEE66B842}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WpfRemote", "WpfRemote\WpfRemote.csproj", "{7A54ABF2-1053-4446-AA79-4ADB666184D5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU Debug|x64 = Debug|x64 + Release|Any CPU = Release|Any CPU Release|x64 = Release|x64 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6E14631E-8223-427D-8A03-550EEE66B842}.Debug|Any CPU.ActiveCfg = Debug|x64 + {6E14631E-8223-427D-8A03-550EEE66B842}.Debug|Any CPU.Build.0 = Debug|x64 {6E14631E-8223-427D-8A03-550EEE66B842}.Debug|x64.ActiveCfg = Debug|x64 {6E14631E-8223-427D-8A03-550EEE66B842}.Debug|x64.Build.0 = Debug|x64 + {6E14631E-8223-427D-8A03-550EEE66B842}.Release|Any CPU.ActiveCfg = Release|x64 + {6E14631E-8223-427D-8A03-550EEE66B842}.Release|Any CPU.Build.0 = Release|x64 {6E14631E-8223-427D-8A03-550EEE66B842}.Release|x64.ActiveCfg = Release|x64 {6E14631E-8223-427D-8A03-550EEE66B842}.Release|x64.Build.0 = Release|x64 + {7A54ABF2-1053-4446-AA79-4ADB666184D5}.Debug|Any CPU.ActiveCfg = Debug|x64 + {7A54ABF2-1053-4446-AA79-4ADB666184D5}.Debug|x64.ActiveCfg = Debug|x64 + {7A54ABF2-1053-4446-AA79-4ADB666184D5}.Debug|x64.Build.0 = Debug|x64 + {7A54ABF2-1053-4446-AA79-4ADB666184D5}.Release|Any CPU.ActiveCfg = Release|x64 + {7A54ABF2-1053-4446-AA79-4ADB666184D5}.Release|x64.ActiveCfg = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Brio/Brio.cs b/Brio/Brio.cs index 2c2f3e08..ad23010b 100644 --- a/Brio/Brio.cs +++ b/Brio/Brio.cs @@ -9,6 +9,7 @@ using Brio.Game.Posing; using Brio.Game.World; using Brio.IPC; +using Brio.Remote; using Brio.Resources; using Brio.UI; using Brio.UI.Windows; @@ -137,6 +138,9 @@ private IServiceCollection SetupServices(DalamudServices dalamudServices) serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + // Remote + serviceCollection.AddSingleton(); + // UI serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); diff --git a/Brio/Brio.csproj b/Brio/Brio.csproj index cccbe352..145badf3 100644 --- a/Brio/Brio.csproj +++ b/Brio/Brio.csproj @@ -26,7 +26,9 @@ + + diff --git a/Brio/Remote/BoneMessage.cs b/Brio/Remote/BoneMessage.cs new file mode 100644 index 00000000..8f1b2de7 --- /dev/null +++ b/Brio/Remote/BoneMessage.cs @@ -0,0 +1,38 @@ +using Brio.Game.Posing.Skeletons; +using MessagePack; + +namespace Brio.Remote; + +[MessagePackObject] +public class BoneMessage +{ + [Key(00)] public string? Name { get; set; } + [Key(01)] public string? DisplayName { get; set; } + [Key(02)] public float PositionX { get; set; } + [Key(03)] public float PositionY { get; set; } + [Key(04)] public float PositionZ { get; set; } + [Key(05)] public float ScaleX { get; set; } + [Key(06)] public float ScaleY { get; set; } + [Key(07)] public float ScaleZ { get; set; } + [Key(08)] public float RotationX { get; set; } + [Key(09)] public float RotationY { get; set; } + [Key(10)] public float RotationZ { get; set; } + [Key(11)] public float RotationW { get; set; } + + internal void FromBone(Bone bone) + { + this.Name = bone.Name; + this.DisplayName = bone.FriendlyName; + + this.PositionX = bone.LastTransform.Position.X; + this.PositionY = bone.LastTransform.Position.Y; + this.PositionZ = bone.LastTransform.Position.Z; + this.ScaleX = bone.LastTransform.Scale.X; + this.ScaleY = bone.LastTransform.Scale.Y; + this.ScaleZ = bone.LastTransform.Scale.Z; + this.RotationX = bone.LastTransform.Rotation.X; + this.RotationY = bone.LastTransform.Rotation.Y; + this.RotationZ = bone.LastTransform.Rotation.Z; + this.RotationW = bone.LastTransform.Rotation.W; + } +} diff --git a/Brio/Remote/Configuration.cs b/Brio/Remote/Configuration.cs new file mode 100644 index 00000000..f7c9bc64 --- /dev/null +++ b/Brio/Remote/Configuration.cs @@ -0,0 +1,6 @@ +namespace Brio.Remote; + +public static class Configuration +{ + public const int Port = 1200; +} diff --git a/Brio/Remote/Heartbeat.cs b/Brio/Remote/Heartbeat.cs new file mode 100644 index 00000000..682a5ef0 --- /dev/null +++ b/Brio/Remote/Heartbeat.cs @@ -0,0 +1,9 @@ +using MessagePack; + +namespace Brio.Remote; + +[MessagePackObject] +public class Heartbeat +{ + [Key(00)] public int Count { get; set; } +} diff --git a/Brio/Remote/RemoteService.cs b/Brio/Remote/RemoteService.cs new file mode 100644 index 00000000..f0e7e778 --- /dev/null +++ b/Brio/Remote/RemoteService.cs @@ -0,0 +1,103 @@ +using Brio.Capabilities.Posing; +using Brio.Entities; +using Brio.Entities.Actor; +using Brio.Game.Posing; +using Brio.Game.Posing.Skeletons; +using EasyTcp4; +using EasyTcp4.ServerUtils; +using MessagePack; +using System; +using System.Threading.Tasks; + +namespace Brio.Remote; +internal class RemoteService : IDisposable +{ + public const int SyncMs = 33; + + private readonly EntityManager _entityManager; + private EasyTcpServer? _server; + + public RemoteService(EntityManager entityManager) + { + _entityManager = entityManager; + + StartServer(); + } + + public bool StartServer() + { + if(_server != null) + throw new Exception("Attempt to start IPC server while it is already running"); + + _server = new(); + _server.EnableServerKeepAlive(); + _server.OnDataReceive += OnDataReceived; + _server.OnError += (s, e) => Brio.Log.Error(e, "Remote error"); + _server.OnConnect += (s, e) => Brio.Log.Info("Remote client connected"); + _server.OnDisconnect += (s, e) => Brio.Log.Info("Remote client disconnected"); + + _server.Start(Configuration.Port); + + Task.Run(Synchronizer); + + return _server.IsRunning; + } + + public void Dispose() + { + _server?.Dispose(); + _server = null; + } + + public void Send(object obj) + { + byte[] data = MessagePackSerializer.Typeless.Serialize(obj); + _server.SendAll(data); + } + + private void OnDataReceived(object? sender, Message e) + { + object? obj = MessagePackSerializer.Typeless.Deserialize(e.Data); + } + + private async Task Synchronizer() + { + while(_server != null) + { + await Task.Delay(SyncMs); + + if (_entityManager.SelectedEntity is ActorEntity actor) + { + PosingCapability? posing; + if (actor.TryGetCapability(out posing)) + { + SynchronizePosing(posing); + } + } + } + } + + private void SynchronizePosing(PosingCapability posing) + { + posing.Selected.Switch( + bone => + { + Bone? realBone = posing.SkeletonPosing.GetBone(bone); + if(realBone != null && realBone.Skeleton.IsValid) + { + BoneMessage boneMessage = new(); + boneMessage.FromBone(realBone); + Send(boneMessage); + } + }, + _ => + { + // Model + }, + _ => + { + // Model + } + ); + } +} diff --git a/WpfRemote/3D/Cylinder.cs b/WpfRemote/3D/Cylinder.cs new file mode 100644 index 00000000..95c83d58 --- /dev/null +++ b/WpfRemote/3D/Cylinder.cs @@ -0,0 +1,183 @@ +namespace WpfRemote.Meida3D; + +using System; +using System.Windows.Media.Media3D; + +public class Cylinder : ModelVisual3D +{ + private double radius; + private GeometryModel3D model; + private int slices = 32; + private double length = 1; + + public Cylinder() + { + this.model = new GeometryModel3D(); + this.model.Geometry = this.CalculateMesh(); + this.Content = this.model; + } + + public double Radius + { + get + { + return this.radius; + } + set + { + this.radius = value; + this.model.Geometry = this.CalculateMesh(); + } + } + + public int Slices + { + get + { + return this.slices; + } + set + { + this.slices = value; + this.model.Geometry = this.CalculateMesh(); + } + } + + public double Length + { + get + { + return this.length; + } + set + { + this.length = value; + this.model.Geometry = this.CalculateMesh(); + } + } + + public Material Material + { + get + { + return this.model.Material; + } + + set + { + this.model.Material = value; + } + } + + private MeshGeometry3D CalculateMesh() + { + MeshGeometry3D mesh = new MeshGeometry3D(); + + Vector3D axis = new Vector3D(0, this.length, 0); + Point3D endPoint = new Point3D(0, -(this.Length / 2), 0); + + // Get two vectors perpendicular to the axis. + Vector3D v1; + if ((axis.Z < -0.01) || (axis.Z > 0.01)) + { + v1 = new Vector3D(axis.Z, axis.Z, -axis.X - axis.Y); + } + else + { + v1 = new Vector3D(-axis.Y - axis.Z, axis.X, axis.X); + } + + Vector3D v2 = Vector3D.CrossProduct(v1, axis); + + // Make the vectors have length radius. + v1 *= this.Radius / v1.Length; + v2 *= this.Radius / v2.Length; + + // Make the top end cap. + // Make the end point. + int pt0 = mesh.Positions.Count; // Index of end_point. + mesh.Positions.Add(endPoint); + + // Make the top points. + double theta = 0; + double dtheta = 2 * Math.PI / this.Slices; + for (int i = 0; i < this.Slices; i++) + { + mesh.Positions.Add(endPoint + (Math.Cos(theta) * v1) + (Math.Sin(theta) * v2)); + theta += dtheta; + } + + // Make the top triangles. + int pt1 = mesh.Positions.Count - 1; // Index of last point. + int pt2 = pt0 + 1; // Index of first point. + for (int i = 0; i < this.Slices; i++) + { + mesh.TriangleIndices.Add(pt0); + mesh.TriangleIndices.Add(pt1); + mesh.TriangleIndices.Add(pt2); + pt1 = pt2++; + } + + // Make the bottom end cap. + // Make the end point. + pt0 = mesh.Positions.Count; // Index of end_point2. + Point3D end_point2 = endPoint + axis; + mesh.Positions.Add(end_point2); + + // Make the bottom points. + theta = 0; + for (int i = 0; i < this.Slices; i++) + { + mesh.Positions.Add(end_point2 + (Math.Cos(theta) * v1) + (Math.Sin(theta) * v2)); + theta += dtheta; + } + + // Make the bottom triangles. + theta = 0; + pt1 = mesh.Positions.Count - 1; // Index of last point. + pt2 = pt0 + 1; // Index of first point. + for (int i = 0; i < this.Slices; i++) + { + mesh.TriangleIndices.Add(this.Slices + 1); // end_point2 + mesh.TriangleIndices.Add(pt2); + mesh.TriangleIndices.Add(pt1); + pt1 = pt2++; + } + + // Make the sides. + // Add the points to the mesh. + int first_side_point = mesh.Positions.Count; + theta = 0; + for (int i = 0; i < this.Slices; i++) + { + Point3D p1 = endPoint + (Math.Cos(theta) * v1) + (Math.Sin(theta) * v2); + mesh.Positions.Add(p1); + Point3D p2 = p1 + axis; + mesh.Positions.Add(p2); + theta += dtheta; + } + + // Make the side triangles. + pt1 = mesh.Positions.Count - 2; + pt2 = pt1 + 1; + int pt3 = first_side_point; + int pt4 = pt3 + 1; + for (int i = 0; i < this.Slices; i++) + { + mesh.TriangleIndices.Add(pt1); + mesh.TriangleIndices.Add(pt2); + mesh.TriangleIndices.Add(pt4); + + mesh.TriangleIndices.Add(pt1); + mesh.TriangleIndices.Add(pt4); + mesh.TriangleIndices.Add(pt3); + + pt1 = pt3; + pt3 += 2; + pt2 = pt4; + pt4 += 2; + } + + return mesh; + } +} diff --git a/WpfRemote/3D/Extensions/EulerExtensions.cs b/WpfRemote/3D/Extensions/EulerExtensions.cs new file mode 100644 index 00000000..589033e3 --- /dev/null +++ b/WpfRemote/3D/Extensions/EulerExtensions.cs @@ -0,0 +1,53 @@ +namespace System.Windows.Media.Media3D; + +public static class EulerExtensions +{ + private static double deg2Rad = (Math.PI * 2) / 360; + + /// + /// Convert into a Quaternion assuming the Vector3D represents euler angles. + /// + /// Quaternion from Euler angles. + public static Quaternion ToQuaternion(this Vector3D self) + { + double yaw = self.Y * deg2Rad; + double pitch = self.X * deg2Rad; + double roll = self.Z * deg2Rad; + + double c1 = Math.Cos(yaw / 2); + double s1 = Math.Sin(yaw / 2); + double c2 = Math.Cos(pitch / 2); + double s2 = Math.Sin(pitch / 2); + double c3 = Math.Cos(roll / 2); + double s3 = Math.Sin(roll / 2); + + double c1c2 = c1 * c2; + double s1s2 = s1 * s2; + + double x = (c1c2 * s3) + (s1s2 * c3); + double y = (s1 * c2 * c3) + (c1 * s2 * s3); + double z = (c1 * s2 * c3) - (s1 * c2 * s3); + double w = (c1c2 * c3) - (s1s2 * s3); + + return new Quaternion(x, y, z, w); + } + + public static Vector3D NormalizeAngles(this Vector3D self) + { + self.X = NormalizeAngle(self.X); + self.Y = NormalizeAngle(self.Y); + self.Z = NormalizeAngle(self.Z); + return self; + } + + private static double NormalizeAngle(double angle) + { + while (angle > 360) + angle -= 360; + + while (angle < 0) + angle += 360; + + return angle; + } +} diff --git a/WpfRemote/3D/Extensions/HkQuaternionExtensions.cs b/WpfRemote/3D/Extensions/HkQuaternionExtensions.cs new file mode 100644 index 00000000..2606def4 --- /dev/null +++ b/WpfRemote/3D/Extensions/HkQuaternionExtensions.cs @@ -0,0 +1,109 @@ +namespace FFXIVClientStructs.Havok; + +using System; +using System.Numerics; + +public static class HkQuaternionExtensions +{ + private static readonly float Deg2Rad = ((float)Math.PI * 2) / 360; + private static readonly float Rad2Deg = 360 / ((float)Math.PI * 2); + + public static hkQuaternionf New(float x, float y, float z, float w) + { + hkQuaternionf v = default; + v.X = x; + v.Y = y; + v.Z = z; + v.W = w; + return v; + } + + public static Quaternion ToQuaternion(this hkQuaternionf q) => new Quaternion(q.X, q.Y, q.Z, q.W); + public static hkQuaternionf ToHavok(this Quaternion q) => new hkQuaternionf + { + X = q.X, + Y = q.Y, + Z = q.Z, + W = q.W, + }; + + public static hkQuaternionf FromQuaternion(this hkQuaternionf tar, Quaternion q) + { + tar.X = q.X; + tar.Y = q.Y; + tar.Z = q.Z; + tar.W = q.W; + return tar; + } + + public static hkQuaternionf FromEuler(hkVector4f euler) + { + float yaw = euler.Y * Deg2Rad; + float pitch = euler.X * Deg2Rad; + float roll = euler.Z * Deg2Rad; + + float c1 = MathF.Cos(yaw / 2); + float s1 = MathF.Sin(yaw / 2); + float c2 = MathF.Cos(pitch / 2); + float s2 = MathF.Sin(pitch / 2); + float c3 = MathF.Cos(roll / 2); + float s3 = MathF.Sin(roll / 2); + + float c1c2 = c1 * c2; + float s1s2 = s1 * s2; + + hkQuaternionf v = default; + v.X = (c1c2 * s3) + (s1s2 * c3); + v.Y = (s1 * c2 * c3) + (c1 * s2 * s3); + v.Z = (c1 * s2 * c3) - (s1 * c2 * s3); + v.W = (c1c2 * c3) - (s1s2 * s3); + return v; + } + + public static hkVector4f ToEuler(this hkQuaternionf self) + { + hkVector4f v = default; + + double test = (self.X * self.Y) + (self.Z * self.W); + + if (test > 0.4995f) + { + v.Y = 2f * (float)Math.Atan2(self.X, self.Y); + v.X = (float)Math.PI / 2; + v.Z = 0; + } + else if (test < -0.4995f) + { + v.Y = -2f * (float)Math.Atan2(self.X, self.W); + v.X = -(float)Math.PI / 2; + v.Z = 0; + } + else + { + double sqx = self.X * self.X; + double sqy = self.Y * self.Y; + double sqz = self.Z * self.Z; + + v.Y = (float)Math.Atan2((2 * self.Y * self.W) - (2 * self.X * self.Z), 1 - (2 * sqy) - (2 * sqz)); + v.X = (float)Math.Asin(2 * test); + v.Z = (float)Math.Atan2((2 * self.X * self.W) - (2 * self.Y * self.Z), 1 - (2 * sqx) - (2 * sqz)); + } + + v.X = NormalizeAngle(v.X * Rad2Deg); + v.Y = NormalizeAngle(v.Y * Rad2Deg); + v.Z = NormalizeAngle(v.Z * Rad2Deg); + + return v; + } + + private static float NormalizeAngle(float angle) + { + while (angle > 360) + angle -= 360; + + while (angle < 0) + angle += 360; + + return angle; + } +} diff --git a/WpfRemote/3D/Extensions/HkVectorExtensions.cs b/WpfRemote/3D/Extensions/HkVectorExtensions.cs new file mode 100644 index 00000000..2c5b3a6b --- /dev/null +++ b/WpfRemote/3D/Extensions/HkVectorExtensions.cs @@ -0,0 +1,19 @@ +namespace FFXIVClientStructs.Havok; + +using System.Numerics; + +public static class HkVectorExtensions +{ + public static Vector3 ToVector3(this hkVector4f vec) => new Vector3(vec.X, vec.Y, vec.Z); + public static Vector4 ToVector4(this hkVector4f vec) => new Vector4(vec.X, vec.Y, vec.Z, vec.W); + + public static hkVector4f ToHavok(this Vector3 v) => new hkVector4f { X = v.X, Y = v.Y, Z = v.Z, W = 1 }; + + public static hkVector4f SetFromVector3(this hkVector4f tar, Vector3 vec) + { + tar.X = vec.X; + tar.Y = vec.Y; + tar.Z = vec.Z; + return tar; + } +} diff --git a/WpfRemote/3D/Extensions/QuaternionExtensions.cs b/WpfRemote/3D/Extensions/QuaternionExtensions.cs new file mode 100644 index 00000000..f7ca956b --- /dev/null +++ b/WpfRemote/3D/Extensions/QuaternionExtensions.cs @@ -0,0 +1,61 @@ +namespace System.Windows.Media.Media3D; + +public static class QuaternionExtensions +{ + private static readonly double Rad2Deg = 360 / (Math.PI * 2); + + /// + /// Converts quaternion to euler angles. + /// + /// Quaternion to convert. + /// Vector3D as euler angles. + public static Vector3D ToEulerAngles(this Quaternion q1) + { + Vector3D v = default; + + double test = (q1.X * q1.Y) + (q1.Z * q1.W); + + if (test > 0.4995f) + { + v.Y = 2f * Math.Atan2(q1.X, q1.Y); + v.X = Math.PI / 2; + v.Z = 0; + return NormalizeAngles(v * Rad2Deg); + } + + if (test < -0.4995f) + { + v.Y = -2f * Math.Atan2(q1.X, q1.W); + v.X = -Math.PI / 2; + v.Z = 0; + return NormalizeAngles(v * Rad2Deg); + } + + double sqx = q1.X * q1.X; + double sqy = q1.Y * q1.Y; + double sqz = q1.Z * q1.Z; + + v.Y = Math.Atan2((2 * q1.Y * q1.W) - (2 * q1.X * q1.Z), 1 - (2 * sqy) - (2 * sqz)); + v.X = Math.Asin(2 * test); + v.Z = Math.Atan2((2 * q1.X * q1.W) - (2 * q1.Y * q1.Z), 1 - (2 * sqx) - (2 * sqz)); + + return NormalizeAngles(v * Rad2Deg); + } + + private static Vector3D NormalizeAngles(Vector3D angles) + { + angles.X = NormalizeAngle(angles.X); + angles.Y = NormalizeAngle(angles.Y); + angles.Z = NormalizeAngle(angles.Z); + return angles; + } + + private static double NormalizeAngle(double angle) + { + while (angle > 360) + angle -= 360; + while (angle < 0) + angle += 360; + return angle; + } +} diff --git a/WpfRemote/3D/Lines/Circle.cs b/WpfRemote/3D/Lines/Circle.cs new file mode 100644 index 00000000..0f10aa2c --- /dev/null +++ b/WpfRemote/3D/Lines/Circle.cs @@ -0,0 +1,39 @@ +namespace WpfRemote.Meida3D.Lines; + +using System; +using System.Windows.Media.Media3D; + +public class Circle : Line +{ + private double radius; + + public Circle() + { + this.Generate(); + } + + public double Radius + { + get + { + return this.radius; + } + set + { + this.radius = value; + this.Generate(); + } + } + + public void Generate() + { + this.Points.Clear(); + + double angleStep = MathUtils.DegreesToRadians(1); + for (int i = 0; i < 360; i++) + { + this.Points.Add(new Point3D(Math.Cos(angleStep * i) * this.Radius, 0.0, Math.Sin(angleStep * i) * this.Radius)); + this.Points.Add(new Point3D(Math.Cos(angleStep * (i + 1)) * this.Radius, 0.0, Math.Sin(angleStep * (i + 1)) * this.Radius)); + } + } +} diff --git a/WpfRemote/3D/Lines/Line.cs b/WpfRemote/3D/Lines/Line.cs new file mode 100644 index 00000000..0cb9d1fe --- /dev/null +++ b/WpfRemote/3D/Lines/Line.cs @@ -0,0 +1,358 @@ +namespace WpfRemote.Meida3D; + +using System; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Media3D; + +public class Line : ModelVisual3D, IDisposable +{ + public static readonly DependencyProperty ColorProperty = DependencyProperty.Register(nameof(Color), typeof(Color), typeof(Line), new PropertyMetadata(Colors.White, OnColorChanged)); + public static readonly DependencyProperty ThicknessProperty = DependencyProperty.Register(nameof(Thickness), typeof(double), typeof(Line), new PropertyMetadata(1.0, OnThicknessChanged)); + public static readonly DependencyProperty PointsProperty = DependencyProperty.Register(nameof(Points), typeof(Point3DCollection), typeof(Line), new PropertyMetadata(null, OnPointsChanged)); + + private readonly GeometryModel3D model; + private readonly MeshGeometry3D mesh; + + private Matrix3D visualToScreen; + private Matrix3D screenToVisual; + + public Line() + { + this.mesh = new MeshGeometry3D(); + this.model = new GeometryModel3D(); + this.model.Geometry = this.mesh; + this.SetColor(this.Color); + + this.Content = this.model; + this.Points = new Point3DCollection(); + + CompositionTarget.Rendering += this.OnRender; + } + + public Color Color + { + get { return (Color)this.GetValue(ColorProperty); } + set { this.SetValue(ColorProperty, value); } + } + + public double Thickness + { + get { return (double)this.GetValue(ThicknessProperty); } + set { this.SetValue(ThicknessProperty, value); } + } + + public Point3DCollection Points + { + get { return (Point3DCollection)this.GetValue(PointsProperty); } + set { this.SetValue(PointsProperty, value); } + } + + public void Dispose() + { + CompositionTarget.Rendering -= this.OnRender; + + this.Points.Clear(); + this.Children.Clear(); + this.Content = null; + } + + public void MakeWireframe(Model3D model) + { + this.Points.Clear(); + + if (model == null) + { + return; + } + + Matrix3DStack transform = new Matrix3DStack(); + transform.Push(Matrix3D.Identity); + + this.WireframeHelper(model, transform); + } + + public Point3D? NearestPoint2D(Point3D cameraPoint) + { + double closest = double.MaxValue; + Point3D? closestPoint = null; + + Matrix3D matrix; + if (!MathUtils.ToViewportTransform(this, out matrix)) + return null; + + MatrixTransform3D transform = new MatrixTransform3D(matrix); + + foreach (Point3D point in this.Points) + { + Point3D cameraSpacePoint = transform.Transform(point); + cameraSpacePoint.Z = cameraSpacePoint.Z * 100; + + Vector3D dir = cameraPoint - cameraSpacePoint; + if (dir.Length < closest) + { + closest = dir.Length; + closestPoint = point; + } + } + + return closestPoint; + } + + private static void OnColorChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args) + { + ((Line)sender).SetColor((Color)args.NewValue); + } + + private static void OnThicknessChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args) + { + ((Line)sender).GeometryDirty(); + } + + private static void OnPointsChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args) + { + ((Line)sender).GeometryDirty(); + } + + private void SetColor(Color color) + { + MaterialGroup unlitMaterial = new MaterialGroup(); + unlitMaterial.Children.Add(new DiffuseMaterial(new SolidColorBrush(Colors.Black))); + unlitMaterial.Children.Add(new EmissiveMaterial(new SolidColorBrush(color))); + unlitMaterial.Freeze(); + + this.model.Material = unlitMaterial; + this.model.BackMaterial = unlitMaterial; + } + + private void OnRender(object? sender, EventArgs e) + { + if (this.Points.Count == 0 && this.mesh.Positions.Count == 0) + return; + + if (this.UpdateTransforms()) + { + this.RebuildGeometry(); + } + } + + private void GeometryDirty() + { + // Force next call to UpdateTransforms() to return true. + this.visualToScreen = MathUtils.ZeroMatrix; + } + + private void RebuildGeometry() + { + double halfThickness = this.Thickness / 2.0; + int numLines = this.Points.Count / 2; + + Point3DCollection positions = new Point3DCollection(numLines * 4); + + for (int i = 0; i < numLines; i++) + { + int startIndex = i * 2; + + Point3D startPoint = this.Points[startIndex]; + Point3D endPoint = this.Points[startIndex + 1]; + + this.AddSegment(positions, startPoint, endPoint, halfThickness); + } + + positions.Freeze(); + this.mesh.Positions = positions; + + Int32Collection indices = new Int32Collection(this.Points.Count * 3); + + for (int i = 0; i < this.Points.Count / 2; i++) + { + indices.Add((i * 4) + 2); + indices.Add((i * 4) + 1); + indices.Add((i * 4) + 0); + + indices.Add((i * 4) + 2); + indices.Add((i * 4) + 3); + indices.Add((i * 4) + 1); + } + + indices.Freeze(); + this.mesh.TriangleIndices = indices; + } + + private void AddSegment(Point3DCollection positions, Point3D startPoint, Point3D endPoint, double halfThickness) + { + // NOTE: We want the vector below to be perpendicular post projection so + // we need to compute the line direction in post-projective space. + Vector3D lineDirection = (endPoint * this.visualToScreen) - (startPoint * this.visualToScreen); + lineDirection.Z = 0; + lineDirection.Normalize(); + + // NOTE: Implicit Rot(90) during construction to get a perpendicular vector. + Vector delta = new Vector(-lineDirection.Y, lineDirection.X); + delta *= halfThickness; + + Point3D pOut1, pOut2; + + this.Widen(startPoint, delta, out pOut1, out pOut2); + + positions.Add(pOut1); + positions.Add(pOut2); + + this.Widen(endPoint, delta, out pOut1, out pOut2); + + positions.Add(pOut1); + positions.Add(pOut2); + } + + private void Widen(Point3D pIn, Vector delta, out Point3D pOut1, out Point3D pOut2) + { + Point4D pIn4 = (Point4D)pIn; + Point4D pOut41 = pIn4 * this.visualToScreen; + Point4D pOut42 = pOut41; + + pOut41.X += delta.X * pOut41.W; + pOut41.Y += delta.Y * pOut41.W; + + pOut42.X -= delta.X * pOut42.W; + pOut42.Y -= delta.Y * pOut42.W; + + pOut41 *= this.screenToVisual; + pOut42 *= this.screenToVisual; + + // NOTE: Z is not modified above, so we use the original Z below. + pOut1 = new Point3D( + pOut41.X / pOut41.W, + pOut41.Y / pOut41.W, + pOut41.Z / pOut41.W); + + pOut2 = new Point3D( + pOut42.X / pOut42.W, + pOut42.Y / pOut42.W, + pOut42.Z / pOut42.W); + } + + private bool UpdateTransforms() + { + Viewport3DVisual? viewport; + bool success; + + Matrix3D visualToScreen = MathUtils.TryTransformTo2DAncestor(this, out viewport, out success); + + if (!success || !visualToScreen.HasInverse) + { + this.mesh.Positions = null; + return false; + } + + if (visualToScreen == this.visualToScreen) + { + return false; + } + + this.visualToScreen = this.screenToVisual = visualToScreen; + this.screenToVisual.Invert(); + + return true; + } + + private void WireframeHelper(Model3D model, Matrix3DStack matrixStack) + { + Transform3D transform = model.Transform; + + if (transform != null && transform != Transform3D.Identity) + { + matrixStack.Prepend(model.Transform.Value); + } + + try + { + Model3DGroup? group = model as Model3DGroup; + + if (group != null) + { + this.WireframeHelper(group, matrixStack); + return; + } + + GeometryModel3D? geometry = model as GeometryModel3D; + + if (geometry != null) + { + this.WireframeHelper(geometry, matrixStack); + return; + } + } + finally + { + if (transform != null && transform != Transform3D.Identity) + { + matrixStack.Pop(); + } + } + } + + private void WireframeHelper(Model3DGroup group, Matrix3DStack matrixStack) + { + foreach (Model3D child in group.Children) + { + this.WireframeHelper(child, matrixStack); + } + } + + private void WireframeHelper(GeometryModel3D model, Matrix3DStack matrixStack) + { + Geometry3D geometry = model.Geometry; + MeshGeometry3D? mesh = geometry as MeshGeometry3D; + + if (mesh != null) + { + Point3D[] positions = new Point3D[mesh.Positions.Count]; + mesh.Positions.CopyTo(positions, 0); + matrixStack.Peek().Transform(positions); + + Int32Collection indices = mesh.TriangleIndices; + + if (indices.Count > 0) + { + int limit = positions.Length - 1; + + for (int i = 2, count = indices.Count; i < count; i += 3) + { + int i0 = indices[i - 2]; + int i1 = indices[i - 1]; + int i2 = indices[i]; + + // WPF halts rendering on the first deformed triangle. We should + // do the same. + if ((i0 < 0 || i0 > limit) || (i1 < 0 || i1 > limit) || (i2 < 0 || i2 > limit)) + { + break; + } + + this.AddTriangle(positions, i0, i1, i2); + } + } + else + { + for (int i = 2, count = positions.Length; i < count; i += 3) + { + int i0 = i - 2; + int i1 = i - 1; + int i2 = i; + + this.AddTriangle(positions, i0, i1, i2); + } + } + } + } + + private void AddTriangle(Point3D[] positions, int i0, int i1, int i2) + { + this.Points.Add(positions[i0]); + this.Points.Add(positions[i1]); + this.Points.Add(positions[i1]); + this.Points.Add(positions[i2]); + this.Points.Add(positions[i2]); + this.Points.Add(positions[i0]); + } +} diff --git a/WpfRemote/3D/MathUtils.cs b/WpfRemote/3D/MathUtils.cs new file mode 100644 index 00000000..61d0d198 --- /dev/null +++ b/WpfRemote/3D/MathUtils.cs @@ -0,0 +1,445 @@ +namespace WpfRemote.Meida3D; + +using System; +using System.Diagnostics; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Media3D; + +public static class MathUtils +{ + public static readonly Matrix3D ZeroMatrix = new Matrix3D(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + public static readonly Vector3D XAxis = new Vector3D(1, 0, 0); + public static readonly Vector3D YAxis = new Vector3D(0, 1, 0); + public static readonly Vector3D ZAxis = new Vector3D(0, 0, 1); + + public static double GetAspectRatio(Size size) + { + return size.Width / size.Height; + } + + public static double DegreesToRadians(double degrees) + { + return degrees * (Math.PI / 180.0); + } + + public static double RadiansToDegrees(double radians) + { + return radians * (180 / Math.PI); + } + + /// + /// Computes the effective view matrix for the given + /// camera. + /// + public static Matrix3D GetViewMatrix(Camera camera) + { + if (camera == null) + { + throw new ArgumentNullException("camera"); + } + + ProjectionCamera? projectionCamera = camera as ProjectionCamera; + + if (projectionCamera != null) + { + return GetViewMatrix(projectionCamera); + } + + MatrixCamera? matrixCamera = camera as MatrixCamera; + + if (matrixCamera != null) + { + return matrixCamera.ViewMatrix; + } + + throw new ArgumentException(string.Format("Unsupported camera type '{0}'.", camera.GetType().FullName), "camera"); + } + + /// + /// Computes the effective projection matrix for the given + /// camera. + /// + public static Matrix3D GetProjectionMatrix(Camera camera, double aspectRatio) + { + if (camera == null) + { + throw new ArgumentNullException("camera"); + } + + PerspectiveCamera? perspectiveCamera = camera as PerspectiveCamera; + + if (perspectiveCamera != null) + { + return GetProjectionMatrix(perspectiveCamera, aspectRatio); + } + + OrthographicCamera? orthographicCamera = camera as OrthographicCamera; + + if (orthographicCamera != null) + { + return GetProjectionMatrix(orthographicCamera, aspectRatio); + } + + MatrixCamera? matrixCamera = camera as MatrixCamera; + + if (matrixCamera != null) + { + return matrixCamera.ProjectionMatrix; + } + + throw new ArgumentException(string.Format("Unsupported camera type '{0}'.", camera.GetType().FullName), "camera"); + } + + public static bool ToViewportTransform(DependencyObject visual, out Matrix3D matrix) + { + matrix = Matrix3D.Identity; + + Viewport3DVisual? viewportVisual; + Matrix3D toWorld = GetWorldTransformationMatrix(visual, out viewportVisual); + + bool success; + Matrix3D toViewport = TryWorldToViewportTransform(viewportVisual, out success); + + if (!success) + return false; + + toWorld.Append(toViewport); + matrix = toWorld; + return true; + } + + /// + /// Computes the transform from world space to the Viewport3DVisual's + /// inner 2D space. + /// This method can fail if Camera.Transform is non-invertable + /// in which case the camera clip planes will be coincident and + /// nothing will render. In this case success will be false. + /// + public static Matrix3D TryWorldToViewportTransform(Viewport3DVisual? visual, out bool success) + { + success = false; + Matrix3D result = TryWorldToCameraTransform(visual, out success); + + if (visual != null && success) + { + result.Append(GetProjectionMatrix(visual.Camera, MathUtils.GetAspectRatio(visual.Viewport.Size))); + result.Append(GetHomogeneousToViewportTransform(visual.Viewport)); + success = true; + } + + return result; + } + + /// + /// Computes the transform from world space to camera space + /// This method can fail if Camera.Transform is non-invertable + /// in which case the camera clip planes will be coincident and + /// nothing will render. In this case success will be false. + /// + public static Matrix3D TryWorldToCameraTransform(Viewport3DVisual? visual, out bool success) + { + success = false; + + if (visual == null) + return ZeroMatrix; + + Matrix3D result = Matrix3D.Identity; + + Camera camera = visual.Camera; + + if (camera == null) + { + return ZeroMatrix; + } + + Rect viewport = visual.Viewport; + + if (viewport == Rect.Empty) + { + return ZeroMatrix; + } + + Transform3D cameraTransform = camera.Transform; + + if (cameraTransform != null) + { + Matrix3D m = cameraTransform.Value; + + if (!m.HasInverse) + { + return ZeroMatrix; + } + + m.Invert(); + result.Append(m); + } + + result.Append(GetViewMatrix(camera)); + + success = true; + return result; + } + + /// + /// Computes the transform from the inner space of the given + /// Visual3D to the 2D space of the Viewport3DVisual which + /// contains it. + /// The result will contain the transform of the given visual. + /// This method can fail if Camera.Transform is non-invertable + /// in which case the camera clip planes will be coincident and + /// nothing will render. In this case success will be false. + /// + public static Matrix3D TryTransformTo2DAncestor(DependencyObject visual, out Viewport3DVisual? viewport, out bool success) + { + Matrix3D to2D = GetWorldTransformationMatrix(visual, out viewport); + + if (viewport == null) + { + success = false; + return ZeroMatrix; + } + + to2D.Append(MathUtils.TryWorldToViewportTransform(viewport, out success)); + + if (!success) + { + return ZeroMatrix; + } + + return to2D; + } + + /// + /// Computes the transform from the inner space of the given + /// Visual3D to the camera coordinate space + /// The result will contain the transform of the given visual. + /// This method can fail if Camera.Transform is non-invertable + /// in which case the camera clip planes will be coincident and + /// nothing will render. In this case success will be false. + /// + public static Matrix3D TryTransformToCameraSpace(DependencyObject visual, out Viewport3DVisual? viewport, out bool success) + { + Matrix3D toViewSpace = GetWorldTransformationMatrix(visual, out viewport); + toViewSpace.Append(MathUtils.TryWorldToCameraTransform(viewport, out success)); + + if (!success) + { + return ZeroMatrix; + } + + return toViewSpace; + } + + /// + /// Transforms the axis-aligned bounding box 'bounds' by + /// 'transform'. + /// + /// The AABB to transform. + /// the transform. + /// Transformed AABB. + public static Rect3D TransformBounds(Rect3D bounds, Matrix3D transform) + { + double x1 = bounds.X; + double y1 = bounds.Y; + double z1 = bounds.Z; + double x2 = bounds.X + bounds.SizeX; + double y2 = bounds.Y + bounds.SizeY; + double z2 = bounds.Z + bounds.SizeZ; + + Point3D[] points = new Point3D[] + { + new Point3D(x1, y1, z1), + new Point3D(x1, y1, z2), + new Point3D(x1, y2, z1), + new Point3D(x1, y2, z2), + new Point3D(x2, y1, z1), + new Point3D(x2, y1, z2), + new Point3D(x2, y2, z1), + new Point3D(x2, y2, z2), + }; + + transform.Transform(points); + + // reuse the 1 and 2 variables to stand for smallest and largest + Point3D p = points[0]; + x1 = x2 = p.X; + y1 = y2 = p.Y; + z1 = z2 = p.Z; + + for (int i = 1; i < points.Length; i++) + { + p = points[i]; + + x1 = Math.Min(x1, p.X); + y1 = Math.Min(y1, p.Y); + z1 = Math.Min(z1, p.Z); + x2 = Math.Max(x2, p.X); + y2 = Math.Max(y2, p.Y); + z2 = Math.Max(z2, p.Z); + } + + return new Rect3D(x1, y1, z1, x2 - x1, y2 - y1, z2 - z1); + } + + /// + /// Normalizes v if |v| > 0. + /// This normalization is slightly different from Vector3D.Normalize. Here + /// we just divide by the length but Vector3D.Normalize tries to avoid + /// overflow when finding the length. + /// + /// The vector to normalize. + /// 'true' if v was normalized. + public static bool TryNormalize(ref Vector3D v) + { + double length = v.Length; + + if (length != 0) + { + v /= length; + return true; + } + + return false; + } + + /// + /// Computes the center of 'box'. + /// + /// The Rect3D we want the center of. + /// The center point. + public static Point3D GetCenter(Rect3D box) + { + return new Point3D(box.X + (box.SizeX / 2), box.Y + (box.SizeY / 2), box.Z + (box.SizeZ / 2)); + } + + public static Point3D NearestPointOnRay(Point3D rayOrigin, Vector3D rayDirection, Point3D pnt) + { + rayDirection.Normalize(); + Vector3D v = pnt - rayOrigin; + double d = Vector3D.DotProduct(v, rayDirection); + return rayOrigin + (rayDirection * d); + } + + private static Matrix3D GetViewMatrix(ProjectionCamera camera) + { + Debug.Assert(camera != null, "Caller needs to ensure camera is non-null."); + + // This math is identical to what you find documented for + // D3DXMatrixLookAtRH with the exception that WPF uses a + // LookDirection vector rather than a LookAt point. + Vector3D zAxis = -camera.LookDirection; + zAxis.Normalize(); + + Vector3D xAxis = Vector3D.CrossProduct(camera.UpDirection, zAxis); + xAxis.Normalize(); + + Vector3D yAxis = Vector3D.CrossProduct(zAxis, xAxis); + + Vector3D position = (Vector3D)camera.Position; + double offsetX = -Vector3D.DotProduct(xAxis, position); + double offsetY = -Vector3D.DotProduct(yAxis, position); + double offsetZ = -Vector3D.DotProduct(zAxis, position); + + return new Matrix3D(xAxis.X, yAxis.X, zAxis.X, 0, xAxis.Y, yAxis.Y, zAxis.Y, 0, xAxis.Z, yAxis.Z, zAxis.Z, 0, offsetX, offsetY, offsetZ, 1); + } + + private static Matrix3D GetProjectionMatrix(OrthographicCamera camera, double aspectRatio) + { + Debug.Assert(camera != null, "Caller needs to ensure camera is non-null."); + + // This math is identical to what you find documented for + // D3DXMatrixOrthoRH with the exception that in WPF only + // the camera's width is specified. Height is calculated + // from width and the aspect ratio. + double w = camera.Width; + double h = w / aspectRatio; + double zn = camera.NearPlaneDistance; + double zf = camera.FarPlaneDistance; + + double m33 = 1 / (zn - zf); + double m43 = zn * m33; + + return new Matrix3D(2 / w, 0, 0, 0, 0, 2 / h, 0, 0, 0, 0, m33, 0, 0, 0, m43, 1); + } + + private static Matrix3D GetProjectionMatrix(PerspectiveCamera camera, double aspectRatio) + { + Debug.Assert(camera != null, "Caller needs to ensure camera is non-null."); + + // This math is identical to what you find documented for + // D3DXMatrixPerspectiveFovRH with the exception that in + // WPF the camera's horizontal rather the vertical + // field-of-view is specified. + double hFoV = MathUtils.DegreesToRadians(camera.FieldOfView); + double zn = camera.NearPlaneDistance; + double zf = camera.FarPlaneDistance; + + double xScale = 1 / Math.Tan(hFoV / 2); + double yScale = aspectRatio * xScale; + double m33 = (zf == double.PositiveInfinity) ? -1 : (zf / (zn - zf)); + double m43 = zn * m33; + + return new Matrix3D(xScale, 0, 0, 0, 0, yScale, 0, 0, 0, 0, m33, -1, 0, 0, m43, 0); + } + + private static Matrix3D GetHomogeneousToViewportTransform(Rect viewport) + { + double scaleX = viewport.Width / 2; + double scaleY = viewport.Height / 2; + double offsetX = viewport.X + scaleX; + double offsetY = viewport.Y + scaleY; + + return new Matrix3D(scaleX, 0, 0, 0, 0, -scaleY, 0, 0, 0, 0, 1, 0, offsetX, offsetY, 0, 1); + } + + /// + /// Gets the object space to world space transformation for the given DependencyObject. + /// + /// The visual whose world space transform should be found. + /// The Viewport3DVisual the Visual is contained within. + /// The world space transformation. + private static Matrix3D GetWorldTransformationMatrix(DependencyObject visual, out Viewport3DVisual? viewport) + { + Matrix3D worldTransform = Matrix3D.Identity; + viewport = null; + + if (!(visual is Visual3D)) + { + throw new ArgumentException("Must be of type Visual3D.", "visual"); + } + + while (visual != null) + { + if (!(visual is ModelVisual3D)) + { + break; + } + + Transform3D transform = (Transform3D)visual.GetValue(ModelVisual3D.TransformProperty); + + if (transform != null) + { + worldTransform.Append(transform.Value); + } + + visual = VisualTreeHelper.GetParent(visual); + } + + viewport = visual as Viewport3DVisual; + + if (viewport == null) + { + if (visual != null) + { + // In WPF 3D v1 the only possible configuration is a chain of + // ModelVisual3Ds leading up to a Viewport3DVisual. + throw new ApplicationException(string.Format("Unsupported type: '{0}'. Expected tree of ModelVisual3Ds leading up to a Viewport3DVisual.", visual.GetType().FullName)); + } + + return ZeroMatrix; + } + + return worldTransform; + } +} diff --git a/WpfRemote/3D/Matrix3DStack.cs b/WpfRemote/3D/Matrix3DStack.cs new file mode 100644 index 00000000..a0d400e7 --- /dev/null +++ b/WpfRemote/3D/Matrix3DStack.cs @@ -0,0 +1,103 @@ +namespace WpfRemote.Meida3D; + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Windows.Media.Media3D; + +/// +/// Matrix3DStack is a stack of Matrix3Ds. +/// +public class Matrix3DStack : IEnumerable, ICollection +{ + private readonly List storage = new List(); + + public int Count + { + get { return this.storage.Count; } + } + + bool ICollection.IsSynchronized + { + get { return ((ICollection)this.storage).IsSynchronized; } + } + + object ICollection.SyncRoot + { + get { return ((ICollection)this.storage).SyncRoot; } + } + + public Matrix3D Peek() + { + return this.storage[this.storage.Count - 1]; + } + + public void Push(Matrix3D item) + { + this.storage.Add(item); + } + + public void Append(Matrix3D item) + { + if (this.Count > 0) + { + Matrix3D top = this.Peek(); + top.Append(item); + this.Push(top); + } + else + { + this.Push(item); + } + } + + public void Prepend(Matrix3D item) + { + if (this.Count > 0) + { + Matrix3D top = this.Peek(); + top.Prepend(item); + this.Push(top); + } + else + { + this.Push(item); + } + } + + public Matrix3D Pop() + { + Matrix3D result = this.Peek(); + this.storage.RemoveAt(this.storage.Count - 1); + + return result; + } + + public void Clear() + { + this.storage.Clear(); + } + + public bool Contains(Matrix3D item) + { + return this.storage.Contains(item); + } + + void ICollection.CopyTo(Array array, int index) + { + ((ICollection)this.storage).CopyTo(array, index); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable)this).GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + for (int i = this.storage.Count - 1; i >= 0; i--) + { + yield return this.storage[i]; + } + } +} diff --git a/WpfRemote/3D/PrsTransform.cs b/WpfRemote/3D/PrsTransform.cs new file mode 100644 index 00000000..5eb28ce6 --- /dev/null +++ b/WpfRemote/3D/PrsTransform.cs @@ -0,0 +1,90 @@ +namespace WpfRemote.Meida3D; + +using System.Windows.Media.Media3D; + +public class PrsTransform +{ + private readonly Transform3DGroup transform = new Transform3DGroup(); + private readonly TranslateTransform3D position = new TranslateTransform3D(); + private readonly QuaternionRotation3D rotation = new QuaternionRotation3D(); + private readonly ScaleTransform3D scale = new ScaleTransform3D(); + + public PrsTransform() + { + this.transform.Children.Add(this.position); + + RotateTransform3D rotation = new RotateTransform3D(); + rotation.Rotation = this.rotation; + this.transform.Children.Add(rotation); + + this.transform.Children.Add(this.scale); + } + + public Transform3DGroup Transform => this.transform; + public bool IsAffine => this.transform.IsAffine; + public Matrix3D Value => this.transform.Value; + + public Vector3D Scale3D + { + get + { + return new Vector3D(this.scale.ScaleX, this.scale.ScaleY, this.scale.ScaleZ); + } + + set + { + this.scale.ScaleX = value.X; + this.scale.ScaleY = value.Y; + this.scale.ScaleZ = value.Z; + } + } + + public double UniformScale + { + get + { + double scale = this.scale.ScaleX; + this.UniformScale = scale; + return scale; + } + + set + { + this.scale.ScaleX = value; + this.scale.ScaleY = value; + this.scale.ScaleZ = value; + } + } + + public Quaternion Rotation + { + get => this.rotation.Quaternion; + set => this.rotation.Quaternion = value; + } + + public Vector3D Position + { + get + { + return new Vector3D(this.position.OffsetX, this.position.OffsetY, this.position.OffsetZ); + } + + set + { + this.position.OffsetX = value.X; + this.position.OffsetY = value.Y; + this.position.OffsetZ = value.Z; + + /*this.scale.CenterX = value.X; + this.scale.CenterY = value.Y; + this.scale.CenterZ = value.Z;*/ + } + } + + public void Reset() + { + this.Position = new Vector3D(0, 0, 0); + this.Rotation = Quaternion.Identity; + this.UniformScale = 1; + } +} diff --git a/WpfRemote/3D/Sphere.cs b/WpfRemote/3D/Sphere.cs new file mode 100644 index 00000000..cdd32177 --- /dev/null +++ b/WpfRemote/3D/Sphere.cs @@ -0,0 +1,122 @@ +namespace WpfRemote.Meida3D; + +using System; +using System.Windows; +using System.Windows.Media.Media3D; + +public class Sphere : ModelVisual3D +{ + private readonly GeometryModel3D model; + private int slices = 32; + private int stacks = 16; + private double radius = 1; + private Point3D center = default; + + public Sphere() + { + this.model = new GeometryModel3D(); + this.model.Geometry = this.CalculateMesh(); + this.Content = this.model; + } + + public int Slices + { + get + { + return this.slices; + } + set + { + this.slices = value; + this.model.Geometry = this.CalculateMesh(); + } + } + + public int Stacks + { + get + { + return this.stacks; + } + set + { + this.stacks = value; + this.model.Geometry = this.CalculateMesh(); + } + } + + public double Radius + { + get + { + return this.radius; + } + set + { + this.radius = value; + this.model.Geometry = this.CalculateMesh(); + } + } + + public Material Material + { + get + { + return this.model.Material; + } + + set + { + this.model.Material = value; + } + } + + private MeshGeometry3D CalculateMesh() + { + MeshGeometry3D mesh = new MeshGeometry3D(); + + for (int stack = 0; stack <= this.Stacks; stack++) + { + double phi = (Math.PI / 2) - (stack * Math.PI / this.Stacks); + double y = this.Radius * Math.Sin(phi); + double scale = -this.Radius * Math.Cos(phi); + + for (int slice = 0; slice <= this.Slices; slice++) + { + double theta = slice * 2 * Math.PI / this.Slices; + double x = scale * Math.Sin(theta); + double z = scale * Math.Cos(theta); + + Vector3D normal = new Vector3D(x, y, z); + mesh.Normals.Add(normal); + mesh.Positions.Add(this.center + normal); + mesh.TextureCoordinates.Add(new Point((double)slice / this.Slices, (double)stack / this.Stacks)); + } + } + + for (int stack = 0; stack <= this.Stacks; stack++) + { + int top = (stack + 0) * (this.Slices + 1); + int bot = (stack + 1) * (this.Slices + 1); + + for (int slice = 0; slice < this.Slices; slice++) + { + if (stack != 0) + { + mesh.TriangleIndices.Add(top + slice); + mesh.TriangleIndices.Add(bot + slice); + mesh.TriangleIndices.Add(top + slice + 1); + } + + if (stack != this.Stacks - 1) + { + mesh.TriangleIndices.Add(top + slice + 1); + mesh.TriangleIndices.Add(bot + slice); + mesh.TriangleIndices.Add(bot + slice + 1); + } + } + } + + return mesh; + } +} diff --git a/WpfRemote/App.xaml b/WpfRemote/App.xaml new file mode 100644 index 00000000..65cd28ed --- /dev/null +++ b/WpfRemote/App.xaml @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WpfRemote/Controls/NumberBox.xaml b/WpfRemote/Controls/NumberBox.xaml new file mode 100644 index 00000000..d170b105 --- /dev/null +++ b/WpfRemote/Controls/NumberBox.xaml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WpfRemote/Controls/NumberBox.xaml.cs b/WpfRemote/Controls/NumberBox.xaml.cs new file mode 100644 index 00000000..644267c0 --- /dev/null +++ b/WpfRemote/Controls/NumberBox.xaml.cs @@ -0,0 +1,556 @@ +namespace WpfRemote.Controls; + +using System; +using System.ComponentModel; +using System.Data; +using System.Drawing; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using PropertyChanged.SourceGenerator; +using WpfRemote.DependencyProperties; +using DrawPoint = System.Drawing.Point; +using WinCur = System.Windows.Forms.Cursor; +using WinPoint = System.Windows.Point; + +/// +/// Interaction logic for NumberBox.xaml. +/// +public partial class NumberBox : UserControl, INotifyPropertyChanged +{ + public static readonly IBind ValueDp = Binder.Register(nameof(Value), OnValueChanged); + public static readonly IBind TickDp = Binder.Register(nameof(TickFrequency), OnTickChanged, BindMode.OneWay); + public static readonly IBind SliderDp = Binder.Register(nameof(Slider), OnSliderChanged, BindMode.OneWay); + public static readonly IBind ButtonsDp = Binder.Register(nameof(Buttons), OnButtonsChanged, BindMode.OneWay); + public static readonly IBind MinDp = Binder.Register(nameof(Minimum), OnMinimumChanged, BindMode.OneWay); + public static readonly IBind MaxDp = Binder.Register(nameof(Maximum), OnMaximumChanged, BindMode.OneWay); + public static readonly IBind WrapDp = Binder.Register(nameof(Wrap), BindMode.OneWay); + public static readonly IBind OffsetDp = Binder.Register(nameof(ValueOffset), BindMode.OneWay); + public static readonly IBind UncapTextInputDp = Binder.Register(nameof(UncapTextInput), BindMode.OneWay); + public static readonly IBind PrefixDp = Binder.Register(nameof(Prefix), BindMode.OneWay); + public static readonly IBind SuffixDp = Binder.Register(nameof(Suffix), BindMode.OneWay); + public static readonly IBind CornerRadiusDp = Binder.Register(nameof(CornerRadius), OnCornerRadiusChanged, BindMode.OneWay); + + private string? inputString; + private Key keyHeld = Key.None; + private double relativeSliderStart; + private double relativeSliderCurrent; + private bool bypassFocusLock = false; + + public NumberBox() + { + this.InitializeComponent(); + this.TickFrequency = 1; + this.Minimum = double.MinValue; + this.Maximum = double.MaxValue; + this.Wrap = false; + this.Text = this.DisplayValue.ToString(); + this.Slider = SliderModes.None; + this.Buttons = false; + this.CornerRadius = new(6, 6, 6, 6); + + this.ContentArea.DataContext = this; + } + + public event PropertyChangedEventHandler? PropertyChanged; + + public enum SliderModes + { + None, + Absolute, + Relative, + } + + public double TickFrequency + { + get => TickDp.Get(this); + set => TickDp.Set(this, value); + } + + public SliderModes Slider + { + get => SliderDp.Get(this); + set => SliderDp.Set(this, value); + } + + public bool Buttons + { + get => ButtonsDp.Get(this); + set => ButtonsDp.Set(this, value); + } + + public double Minimum + { + get => MinDp.Get(this); + set => MinDp.Set(this, value); + } + + public double Maximum + { + get => MaxDp.Get(this); + set => MaxDp.Set(this, value); + } + + public bool Wrap + { + get => WrapDp.Get(this); + set => WrapDp.Set(this, value); + } + + public double ValueOffset + { + get => OffsetDp.Get(this); + set => OffsetDp.Set(this, value); + } + + public double Value + { + get => ValueDp.Get(this); + set => ValueDp.Set(this, value); + } + + public bool UncapTextInput + { + get => UncapTextInputDp.Get(this); + set => UncapTextInputDp.Set(this, value); + } + + public object Prefix + { + get => PrefixDp.Get(this); + set => PrefixDp.Set(this, value); + } + + public object Suffix + { + get => SuffixDp.Get(this); + set => SuffixDp.Set(this, value); + } + + public CornerRadius CornerRadius + { + get => CornerRadiusDp.Get(this); + set => CornerRadiusDp.Set(this, value); + } + + public double DisplayValue + { + get + { + return this.Value + this.ValueOffset; + } + + set + { + this.Value = value - this.ValueOffset; + + if (!this.UncapTextInput) + { + if (this.Wrap) + { + double range = this.Maximum - this.Minimum; + + if (this.Value > this.Maximum) + this.Value = this.Minimum + ((this.Value - this.Maximum) % range); + + if (this.Value < this.Minimum) + this.Value = this.Maximum - ((this.Maximum - this.Value) % range); + } + + this.Value = Math.Max(this.Minimum, this.Value); + this.Value = Math.Min(this.Maximum, this.Value); + } + + this.PropertyChanged?.Invoke(this, new(nameof(NumberBox.DisplayValue))); + } + } + + public string? Text + { + get + { + return this.inputString; + } + + set + { + this.inputString = value; + + double val; + if (double.TryParse(value, out val)) + { + this.DisplayValue = val; + this.ErrorDisplay.Visibility = Visibility.Collapsed; + } + else + { + try + { + val = Convert.ToDouble(new DataTable().Compute(value, null)); + this.DisplayValue = val; + this.ErrorDisplay.Visibility = Visibility.Collapsed; + } + catch (Exception) + { + this.ErrorDisplay.Visibility = Visibility.Visible; + } + } + + this.PropertyChanged?.Invoke(this, new(nameof(NumberBox.Text))); + } + } + + public double SliderValue + { + get + { + if (this.Slider == SliderModes.Absolute) + { + return this.DisplayValue; + } + else + { + return this.relativeSliderCurrent; + } + } + set + { + this.bypassFocusLock = true; + + if (this.Slider == SliderModes.Absolute) + { + this.DisplayValue = value; + } + else + { + this.relativeSliderCurrent = value; + + if (Keyboard.IsKeyDown(Key.LeftShift)) + value *= 10; + + if (Keyboard.IsKeyDown(Key.LeftCtrl)) + value /= 10; + + this.DisplayValue = this.relativeSliderStart + value; + } + + this.bypassFocusLock = false; + + this.PropertyChanged?.Invoke(this, new(nameof(NumberBox.SliderValue))); + } + } + + public double SliderMinimum + { + get + { + if (this.Slider == SliderModes.Absolute) + { + return this.Minimum; + } + else + { + return -(this.TickFrequency * 30); + } + } + } + + public double SliderMaximum + { + get + { + if (this.Slider == SliderModes.Absolute) + { + return this.Maximum; + } + else + { + return this.TickFrequency * 30; + } + } + } + + protected override void OnPreviewKeyDown(KeyEventArgs e) + { + bool focused = this.InputBox.IsKeyboardFocused || this.InputSlider.IsKeyboardFocused; + if (!focused) + return; + + if (e.Key == Key.Return) + { + this.Commit(true); + e.Handled = true; + } + + if (e.Key == Key.Up || e.Key == Key.Down) + { + e.Handled = true; + + if (e.IsRepeat) + { + if (this.keyHeld == e.Key) + return; + + this.keyHeld = e.Key; + Task.Run(this.TickHeldKey); + } + else + { + this.TickKey(e.Key); + } + } + } + + protected override void OnPreviewKeyUp(KeyEventArgs e) + { + if (this.keyHeld == e.Key) + { + e.Handled = true; + this.keyHeld = Key.None; + } + } + + protected override void OnPreviewMouseWheel(MouseWheelEventArgs e) + { + if (!this.InputBox.IsFocused) + return; + + e.Handled = true; + this.TickValue(e.Delta > 0); + } + + private static void OnValueChanged(NumberBox sender, double v) + { + sender.PropertyChanged?.Invoke(sender, new PropertyChangedEventArgs(nameof(NumberBox.SliderValue))); + + if (!sender.bypassFocusLock && sender.InputBox.IsFocused) + return; + + sender.Text = sender.DisplayValue.ToString("0.###"); + } + + private static void OnSliderChanged(NumberBox sender, SliderModes mode) + { + sender.SliderArea.Visibility = mode != SliderModes.None ? Visibility.Visible : Visibility.Collapsed; + sender.BoxBorder.CornerRadius = mode != SliderModes.None ? new(0, sender.CornerRadius.TopRight, sender.CornerRadius.BottomRight, 0) : sender.CornerRadius; + sender.SliderArea.CornerRadius = new(sender.CornerRadius.TopLeft, 0, 0, sender.CornerRadius.BottomLeft); + + int inputColumnWidth = 64; + if (sender.Buttons) + inputColumnWidth += 48; + + sender.SliderColumn.Width = mode != SliderModes.None ? new GridLength(1, GridUnitType.Star) : new GridLength(0); + sender.InputBoxColumn.Width = mode != SliderModes.None ? new GridLength(inputColumnWidth) : new GridLength(1, GridUnitType.Star); + + sender.PropertyChanged?.Invoke(sender, new PropertyChangedEventArgs(nameof(NumberBox.SliderMaximum))); + sender.PropertyChanged?.Invoke(sender, new PropertyChangedEventArgs(nameof(NumberBox.SliderMinimum))); + sender.PropertyChanged?.Invoke(sender, new PropertyChangedEventArgs(nameof(NumberBox.SliderValue))); + } + + private static void OnCornerRadiusChanged(NumberBox sender, CornerRadius value) + { + sender.BoxBorder.CornerRadius = sender.Slider != SliderModes.None ? new(0, sender.CornerRadius.TopRight, sender.CornerRadius.BottomRight, 0) : sender.CornerRadius; + sender.SliderArea.CornerRadius = new(sender.CornerRadius.TopLeft, 0, 0, sender.CornerRadius.BottomLeft); + } + + private static void OnButtonsChanged(NumberBox sender, bool v) + { + sender.DownButton.Visibility = v ? Visibility.Visible : Visibility.Collapsed; + sender.UpButton.Visibility = v ? Visibility.Visible : Visibility.Collapsed; + } + + private static void OnMinimumChanged(NumberBox sender, double value) + { + sender.PropertyChanged?.Invoke(sender, new PropertyChangedEventArgs(nameof(NumberBox.SliderMinimum))); + } + + private static void OnMaximumChanged(NumberBox sender, double value) + { + sender.PropertyChanged?.Invoke(sender, new PropertyChangedEventArgs(nameof(NumberBox.SliderMaximum))); + } + + private static void OnTickChanged(NumberBox sender, double tick) + { + sender.PropertyChanged?.Invoke(sender, new PropertyChangedEventArgs(nameof(NumberBox.SliderMaximum))); + sender.PropertyChanged?.Invoke(sender, new PropertyChangedEventArgs(nameof(NumberBox.SliderMinimum))); + sender.PropertyChanged?.Invoke(sender, new PropertyChangedEventArgs(nameof(NumberBox.SliderValue))); + } + + private void UserControl_Loaded(object sender, RoutedEventArgs e) + { + Window window = Window.GetWindow(this); + if (window != null) + { + window.MouseDown += this.OnWindowMouseDown; + window.Deactivated += this.OnWindowDeactivated; + } + + OnSliderChanged(this, this.Slider); + OnButtonsChanged(this, this.Buttons); + OnTickChanged(this, this.TickFrequency); + + this.Text = this.DisplayValue.ToString("0.###"); + } + + private double Validate(double v) + { + if (this.Wrap) + { + if (v > this.Maximum) + { + v = this.Minimum; + } + + if (v < this.Minimum) + { + v = this.Maximum; + } + } + else + { + v = Math.Min(v, this.Maximum); + v = Math.Max(v, this.Minimum); + } + + ////if (this.TickFrequency != 0) + //// v = Math.Round(v / this.TickFrequency) * this.TickFrequency; + + return v; + } + + private void OnLostFocus(object sender, RoutedEventArgs e) + { + this.Text = this.DisplayValue.ToString("0.###"); + ////this.Commit(false); + } + + private void Commit(bool refocus) + { + try + { + this.DisplayValue = Convert.ToDouble(new DataTable().Compute(this.inputString, null)); + this.ErrorDisplay.Visibility = Visibility.Collapsed; + } + catch (Exception) + { + this.ErrorDisplay.Visibility = Visibility.Visible; + } + + this.Text = this.DisplayValue.ToString("0.###"); + + if (refocus) + { + this.InputBox.Focus(); + this.InputBox.CaretIndex = int.MaxValue; + } + } + + private async Task TickHeldKey() + { + while (this.keyHeld != Key.None) + { + await Application.Current.Dispatcher.InvokeAsync(() => + { + this.TickKey(this.keyHeld); + }); + + await Task.Delay(10); + } + } + + private void TickKey(Key key) + { + if (key == Key.Up) + { + this.TickValue(true); + this.Commit(true); + } + else if (key == Key.Down) + { + this.TickValue(false); + this.Commit(true); + } + } + + private void TickValue(bool increase) + { + double delta = increase ? this.TickFrequency : -this.TickFrequency; + + if (Keyboard.IsKeyDown(Key.LeftShift)) + delta *= 10; + + if (Keyboard.IsKeyDown(Key.LeftCtrl)) + delta /= 10; + + double value = this.DisplayValue; + double newValue = value + delta; + newValue = this.Validate(newValue); + + if (newValue == value) + return; + + this.bypassFocusLock = true; + this.DisplayValue = newValue; + this.bypassFocusLock = false; + } + + private void OnDownClick(object sender, RoutedEventArgs e) + { + this.TickValue(false); + } + + private void OnUpClick(object sender, RoutedEventArgs e) + { + this.TickValue(true); + } + + private void OnSliderMouseMove(object sender, MouseEventArgs e) + { + if (this.Slider != SliderModes.Absolute) + return; + + if (e.LeftButton == MouseButtonState.Pressed && this.Wrap) + { + WinPoint rightEdge = this.InputSlider.PointToScreen(new WinPoint(this.InputSlider.ActualWidth - 5, this.InputSlider.ActualHeight / 2)); + WinPoint leftEdge = this.InputSlider.PointToScreen(new WinPoint(6, this.InputSlider.ActualHeight / 2)); + + if (WinCur.Position.X > rightEdge.X) + { + WinCur.Position = new DrawPoint((int)leftEdge.X, (int)leftEdge.Y); + } + + if (WinCur.Position.X < leftEdge.X) + { + WinCur.Position = new DrawPoint((int)rightEdge.X, (int)rightEdge.Y); + } + } + } + + private void OnWindowMouseDown(object? sender, MouseButtonEventArgs e) + { + FocusManager.SetFocusedElement(FocusManager.GetFocusScope(this), null); + Keyboard.ClearFocus(); + } + + private void OnWindowDeactivated(object? sender, EventArgs e) + { + FocusManager.SetFocusedElement(FocusManager.GetFocusScope(this), null); + Keyboard.ClearFocus(); + } + + private void OnSliderPreviewMouseDown(object? sender, MouseButtonEventArgs e) + { + this.relativeSliderStart = this.DisplayValue; + } + + private void OnSliderPreviewMouseUp(object? sender, MouseButtonEventArgs e) + { + if (this.Slider == SliderModes.Relative) + { + this.relativeSliderStart = this.DisplayValue; + this.SliderValue = 0; + } + } +} diff --git a/WpfRemote/Controls/RelativeSlider.cs b/WpfRemote/Controls/RelativeSlider.cs new file mode 100644 index 00000000..c3c8d8c4 --- /dev/null +++ b/WpfRemote/Controls/RelativeSlider.cs @@ -0,0 +1,68 @@ +namespace WpfRemote.Controls; + +using System.Windows; +using System.Windows.Input; +using WpfRemote.DependencyProperties; + +public class RelativeSlider : Slider +{ + public static readonly IBind RelativeValueDp = Binder.Register(nameof(RelativeValue)); + public static readonly IBind RelativeRangeDp = Binder.Register(nameof(RelativeRange), OnRelativeRangeChanged, BindMode.OneWay); + + private double relativeSliderStart; + + public RelativeSlider() + { + this.PreviewMouseDown += this.OnPreviewMouseDown; + this.PreviewMouseUp += this.OnPreviewMouseUp; + this.ValueChanged += this.OnValueChanged; + + this.Value = 0; + } + + public double RelativeValue + { + get => RelativeValueDp.Get(this); + set => RelativeValueDp.Set(this, value); + } + + public double RelativeRange + { + get => RelativeRangeDp.Get(this); + set => RelativeRangeDp.Set(this, value); + } + + protected override void OnPreviewKeyDown(object sender, KeyEventArgs e) + { + this.relativeSliderStart = this.RelativeValue; + base.OnPreviewKeyDown(sender, e); + } + + protected override void OnPreviewKeyUp(object sender, KeyEventArgs e) + { + this.relativeSliderStart = this.RelativeValue; + base.OnPreviewKeyUp(sender, e); + } + + private static void OnRelativeRangeChanged(RelativeSlider sender, double value) + { + sender.Minimum = -value; + sender.Maximum = value; + } + + private void OnValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + this.RelativeValue = this.relativeSliderStart + this.Value; + } + + private void OnPreviewMouseDown(object? sender, MouseButtonEventArgs e) + { + this.relativeSliderStart = this.RelativeValue; + } + + private void OnPreviewMouseUp(object? sender, MouseButtonEventArgs e) + { + this.relativeSliderStart = this.RelativeValue; + this.Value = 0; + } +} diff --git a/WpfRemote/Controls/Slider.cs b/WpfRemote/Controls/Slider.cs new file mode 100644 index 00000000..c75f3979 --- /dev/null +++ b/WpfRemote/Controls/Slider.cs @@ -0,0 +1,96 @@ +namespace WpfRemote.Controls; + +using System.Reflection; +using System.Windows.Input; + +public class Slider : System.Windows.Controls.Slider +{ + private MethodInfo? moveToNextTickMethod; + + public Slider() + { + this.PreviewKeyDown += this.OnPreviewKeyDown; + this.PreviewKeyUp += this.OnPreviewKeyUp; + } + + protected double GetChangeMultiplier() + { + if (Keyboard.IsKeyDown(Key.LeftShift)) + return 10; + + if (Keyboard.IsKeyDown(Key.RightShift)) + return 10; + + if (Keyboard.IsKeyDown(Key.LeftCtrl)) + return 0.1f; + + if (Keyboard.IsKeyDown(Key.RightCtrl)) + return 0.1f; + + return 1.0; + } + + protected override void OnDecreaseSmall() + { + this.MoveToNextTick(-this.SmallChange * this.GetChangeMultiplier()); + } + + protected override void OnIncreaseSmall() + { + this.MoveToNextTick(this.SmallChange * this.GetChangeMultiplier()); + } + + protected override void OnDecreaseLarge() + { + this.MoveToNextTick(-this.LargeChange * this.GetChangeMultiplier()); + } + + protected override void OnIncreaseLarge() + { + this.MoveToNextTick(this.LargeChange * this.GetChangeMultiplier()); + } + + protected void MoveToNextTick(double direction) + { + if (this.moveToNextTickMethod == null) + this.moveToNextTickMethod = typeof(System.Windows.Controls.Slider).GetMethod("MoveToNextTick", BindingFlags.NonPublic | BindingFlags.Instance); + + if (this.moveToNextTickMethod == null) + return; + + this.moveToNextTickMethod.Invoke(this, new object[] { direction }); + } + + protected virtual void OnPreviewKeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Left) + { + this.OnDecreaseSmall(); + e.Handled = true; + } + else if (e.Key == Key.Right) + { + this.OnIncreaseSmall(); + e.Handled = true; + } + else if (e.Key == Key.Down) + { + this.MoveFocus(new(FocusNavigationDirection.Next)); + e.Handled = true; + } + else if (e.Key == Key.Up) + { + this.MoveFocus(new(FocusNavigationDirection.Previous)); + e.Handled = true; + } + } + + protected virtual void OnPreviewKeyUp(object sender, KeyEventArgs e) + { + if (e.Key == Key.Left || e.Key == Key.Right || e.Key == Key.Down || e.Key == Key.Up) + { + e.Handled = true; + this.Value = 0; + } + } +} diff --git a/WpfRemote/Converters/AbsoluteNumberConverter.cs b/WpfRemote/Converters/AbsoluteNumberConverter.cs new file mode 100644 index 00000000..6f2ce3da --- /dev/null +++ b/WpfRemote/Converters/AbsoluteNumberConverter.cs @@ -0,0 +1,11 @@ +namespace WpfRemote.Converters; + +using System; + +public class AbsoluteNumberConverter : ConverterBase +{ + protected override double Convert(double value) + { + return Math.Abs(value); + } +} diff --git a/WpfRemote/Converters/AnyBoolIsFalseToBoolMultiConverter.cs b/WpfRemote/Converters/AnyBoolIsFalseToBoolMultiConverter.cs new file mode 100644 index 00000000..6a252b21 --- /dev/null +++ b/WpfRemote/Converters/AnyBoolIsFalseToBoolMultiConverter.cs @@ -0,0 +1,33 @@ +namespace WpfRemote.Converters; + +using System; +using System.Globalization; +using System.Windows.Data; + +/// +/// If all of the bools are true, returns false. +/// If any of the bools are false, returns true. +/// +public class AnyBoolIsFalseToBoolMultiConverter : IMultiValueConverter +{ + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + foreach (object value in values) + { + if (value is bool boolValue) + { + if (!boolValue) + { + return true; + } + } + } + + return false; + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + { + throw new NotSupportedException("BooleanAndConverter is a OneWay converter."); + } +} diff --git a/WpfRemote/Converters/BoolInversionConverter.cs b/WpfRemote/Converters/BoolInversionConverter.cs new file mode 100644 index 00000000..5aa41a66 --- /dev/null +++ b/WpfRemote/Converters/BoolInversionConverter.cs @@ -0,0 +1,14 @@ +namespace WpfRemote.Converters; + +public class BoolInversionConverter : ConverterBase +{ + protected override bool Convert(bool value) + { + return !value; + } + + protected override bool ConvertBack(bool value) + { + return !value; + } +} diff --git a/WpfRemote/Converters/BoolToIntConverter.cs b/WpfRemote/Converters/BoolToIntConverter.cs new file mode 100644 index 00000000..ee19a6f1 --- /dev/null +++ b/WpfRemote/Converters/BoolToIntConverter.cs @@ -0,0 +1,9 @@ +namespace WpfRemote.Converters; + +public class BoolToIntConverter : ConverterBase +{ + protected override int Convert(bool value) + { + return value ? 1 : 0; + } +} diff --git a/WpfRemote/Converters/ColorToBrushConverter.cs b/WpfRemote/Converters/ColorToBrushConverter.cs new file mode 100644 index 00000000..62677bed --- /dev/null +++ b/WpfRemote/Converters/ColorToBrushConverter.cs @@ -0,0 +1,13 @@ +namespace WpfRemote.Converters; + +using System.Windows.Data; +using System.Windows.Media; + +[ValueConversion(typeof(Color), typeof(Brush))] +public class ColorToBrushConverter : ConverterBase +{ + protected override Brush Convert(Color value) + { + return new SolidColorBrush(value); + } +} diff --git a/WpfRemote/Converters/ConverterBase.cs b/WpfRemote/Converters/ConverterBase.cs new file mode 100644 index 00000000..2a01fa30 --- /dev/null +++ b/WpfRemote/Converters/ConverterBase.cs @@ -0,0 +1,67 @@ +namespace WpfRemote.Converters; + +using System; +using System.Globalization; +using System.Windows.Data; + +public abstract class ConverterBase : IValueConverter +{ + public object? Parameter { get; private set; } + + public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + this.Parameter = parameter; + + if (value is TFrom tValue) + return this.Convert(tValue); + + throw new InvalidCastException(); + } + + public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + this.Parameter = parameter; + + if (value is TTo tValue) + return this.ConvertBack(tValue); + + throw new InvalidCastException(); + } + + protected abstract TTo Convert(TFrom? value); + + protected virtual TFrom ConvertBack(TTo? value) + { + throw new NotSupportedException(); + } +} + +public abstract class ConverterBase : ConverterBase +{ + public new TParameter Parameter + { + get + { + if (base.Parameter is TParameter tParameter) + return tParameter; + + if (typeof(TParameter) == typeof(double)) + { + double val = System.Convert.ToDouble(base.Parameter, CultureInfo.InvariantCulture); + + if (val is TParameter tParameterVal) + return tParameterVal; + } + + if (typeof(TParameter) == typeof(int)) + { + int val = System.Convert.ToInt32(base.Parameter, CultureInfo.InvariantCulture); + + if (val is TParameter tParameterVal) + return tParameterVal; + } + + throw new InvalidCastException(); + } + } +} diff --git a/WpfRemote/Converters/EnumToBoolConverter.cs b/WpfRemote/Converters/EnumToBoolConverter.cs new file mode 100644 index 00000000..591ea85b --- /dev/null +++ b/WpfRemote/Converters/EnumToBoolConverter.cs @@ -0,0 +1,85 @@ +namespace WpfRemote.Converters; + +using System; +using System.Globalization; +using System.Windows.Data; + +[ValueConversion(typeof(Enum), typeof(bool))] +public class EnumToBoolConverter : IValueConverter +{ + private enum AdditionMode + { + Or, + And, + } + + public static bool Convert(object? value, Type targetType, object parameter) + { + if (value == null) + return false; + + Type enumType = value.GetType(); + + if (!enumType.IsEnum) + throw new Exception("Enum converter can only be used on an enum type"); + + Enum currentValue = (Enum)value; + + string enumValueString = (string)parameter; + + AdditionMode mode = AdditionMode.Or; + + if (enumValueString.Contains("|") && enumValueString.Contains("&")) + { + throw new NotSupportedException("Cannot mix or (|) with and (&) in enum converter parameter"); + } + else if (enumValueString.Contains("|")) + { + mode = AdditionMode.Or; + } + else if (enumValueString.Contains("&")) + { + mode = AdditionMode.And; + } + + string[] values = enumValueString.Split('|', '?', StringSplitOptions.RemoveEmptyEntries); + bool returnvalue = false; + + foreach (string enumValueStringPart in values) + { + Enum parameterValue = (Enum)Enum.Parse(enumType, enumValueStringPart.Trim(' ', '!')); + + bool isEnumValue = Enum.Equals(currentValue, parameterValue); + + if (enumValueString.StartsWith('!')) + isEnumValue = !isEnumValue; + + if (mode == AdditionMode.Or) + { + returnvalue |= isEnumValue; + } + else + { + returnvalue &= isEnumValue; + } + } + + return returnvalue; + } + + public object? Convert(object? value, Type targetType, object parameter, CultureInfo culture) + { + return Convert(value, targetType, parameter); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is not bool bVal || bVal == false) + return Binding.DoNothing; + + if (!targetType.IsEnum) + throw new Exception("Enum converter can only be used on an enum type"); + + return Enum.Parse(targetType, (string)parameter); + } +} diff --git a/WpfRemote/Converters/EnumToVisibilityConverter.cs b/WpfRemote/Converters/EnumToVisibilityConverter.cs new file mode 100644 index 00000000..d4832e2e --- /dev/null +++ b/WpfRemote/Converters/EnumToVisibilityConverter.cs @@ -0,0 +1,20 @@ +namespace WpfRemote.Converters; + +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +[ValueConversion(typeof(Enum), typeof(Visibility))] +public class EnumToVisibilityConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object parameter, CultureInfo culture) + { + return EnumToBoolConverter.Convert(value, targetType, parameter) ? Visibility.Visible : Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/WpfRemote/Converters/FloatToDoubleConverter.cs b/WpfRemote/Converters/FloatToDoubleConverter.cs new file mode 100644 index 00000000..01b517e8 --- /dev/null +++ b/WpfRemote/Converters/FloatToDoubleConverter.cs @@ -0,0 +1,10 @@ +namespace WpfRemote.Converters; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +internal class FloatToDoubleConverter +{ +} diff --git a/WpfRemote/Converters/GreaterThanToVisibilityConverter.cs b/WpfRemote/Converters/GreaterThanToVisibilityConverter.cs new file mode 100644 index 00000000..39a3636e --- /dev/null +++ b/WpfRemote/Converters/GreaterThanToVisibilityConverter.cs @@ -0,0 +1,11 @@ +namespace WpfRemote.Converters; + +using System.Windows; + +public class GreaterThanToVisibilityConverter : ConverterBase +{ + protected override Visibility Convert(double value) + { + return value > this.Parameter ? Visibility.Visible : Visibility.Collapsed; + } +} diff --git a/WpfRemote/Converters/InvertedBoolToVisibilityConverter.cs b/WpfRemote/Converters/InvertedBoolToVisibilityConverter.cs new file mode 100644 index 00000000..8c33bdbd --- /dev/null +++ b/WpfRemote/Converters/InvertedBoolToVisibilityConverter.cs @@ -0,0 +1,19 @@ +namespace WpfRemote.Converters; + +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +public class InvertedBoolToVisibilityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return (bool)value ? Visibility.Collapsed : Visibility.Visible; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/WpfRemote/Converters/IsEmptyToVisibilityConverter.cs b/WpfRemote/Converters/IsEmptyToVisibilityConverter.cs new file mode 100644 index 00000000..ae331660 --- /dev/null +++ b/WpfRemote/Converters/IsEmptyToVisibilityConverter.cs @@ -0,0 +1,32 @@ +namespace WpfRemote.Converters; + +using System; +using System.Collections; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +[ValueConversion(typeof(IEnumerable), typeof(Visibility))] +public class IsEmptyToVisibilityConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object parameter, CultureInfo culture) + { + if (value == null) + return Visibility.Visible; + + if (value is IEnumerable enumerable) + { + foreach (object obj in enumerable) + { + return Visibility.Collapsed; + } + } + + return Visibility.Visible; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/WpfRemote/Converters/IsZeroToBoolConverter.cs b/WpfRemote/Converters/IsZeroToBoolConverter.cs new file mode 100644 index 00000000..546539d0 --- /dev/null +++ b/WpfRemote/Converters/IsZeroToBoolConverter.cs @@ -0,0 +1,51 @@ +namespace WpfRemote.Converters; + +using System; +using System.Windows; +using System.Windows.Data; + +[ValueConversion(typeof(object), typeof(bool))] +public class IsZeroToBoolConverter : IValueConverter +{ + public static bool IsZero(object value) + { + if (value is int intV) + { + return intV == 0; + } + else if (value is float floatV) + { + return floatV == 0; + } + else if (value is double doubleV) + { + return doubleV == 0; + } + else if (value is uint uintV) + { + return uintV == 0; + } + else if (value is ushort ushortV) + { + return ushortV == 0; + } + else if (value is byte byteV) + { + return byteV == 0; + } + else + { + throw new NotImplementedException($"value type {value.GetType()} not supported for not zero converter"); + } + } + + public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + return IsZeroToBoolConverter.IsZero(value); + } + + public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/WpfRemote/Converters/IsZeroToVisibilityConverter.cs b/WpfRemote/Converters/IsZeroToVisibilityConverter.cs new file mode 100644 index 00000000..307de216 --- /dev/null +++ b/WpfRemote/Converters/IsZeroToVisibilityConverter.cs @@ -0,0 +1,19 @@ +namespace WpfRemote.Converters; + +using System; +using System.Windows; +using System.Windows.Data; + +[ValueConversion(typeof(object), typeof(Visibility))] +public class IsZeroToVisibilityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + return IsZeroToBoolConverter.IsZero(value) ? Visibility.Visible : Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/WpfRemote/Converters/LessThanToBoolConverter.cs b/WpfRemote/Converters/LessThanToBoolConverter.cs new file mode 100644 index 00000000..63f4587a --- /dev/null +++ b/WpfRemote/Converters/LessThanToBoolConverter.cs @@ -0,0 +1,11 @@ +namespace WpfRemote.Converters; + +using System.Windows; + +public class LessThanToBoolConverter : ConverterBase +{ + protected override bool Convert(double value) + { + return value < this.Parameter; + } +} diff --git a/WpfRemote/Converters/LessThanToVisibilityConverter.cs b/WpfRemote/Converters/LessThanToVisibilityConverter.cs new file mode 100644 index 00000000..1dc12902 --- /dev/null +++ b/WpfRemote/Converters/LessThanToVisibilityConverter.cs @@ -0,0 +1,11 @@ +namespace WpfRemote.Converters; + +using System.Windows; + +public class LessThanToVisibilityConverter : ConverterBase +{ + protected override Visibility Convert(double value) + { + return value < this.Parameter ? Visibility.Visible : Visibility.Collapsed; + } +} diff --git a/WpfRemote/Converters/ListToStringConverter.cs b/WpfRemote/Converters/ListToStringConverter.cs new file mode 100644 index 00000000..545b1f7a --- /dev/null +++ b/WpfRemote/Converters/ListToStringConverter.cs @@ -0,0 +1,32 @@ +namespace WpfRemote.Converters; + +using System; +using System.Collections; +using System.Windows.Data; + +[ValueConversion(typeof(IEnumerable), typeof(string))] +public class ListToStringConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + if (value is IEnumerable enumerable) + { + string str = string.Empty; + int count = 0; + foreach (object v in enumerable) + { + str += v.ToString() + ", "; + count++; + } + + return count + ": " + str.TrimEnd(' ', ','); + } + + throw new Exception("List to string converter can only be used with enumerable sources"); + } + + public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/WpfRemote/Converters/MultiBoolAndConverter.cs b/WpfRemote/Converters/MultiBoolAndConverter.cs new file mode 100644 index 00000000..ee40df68 --- /dev/null +++ b/WpfRemote/Converters/MultiBoolAndConverter.cs @@ -0,0 +1,27 @@ +namespace WpfRemote.Converters; + +using System; +using System.Globalization; +using System.Windows.Data; + +public class MultiBoolAndConverter : IMultiValueConverter +{ + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + foreach (object value in values) + { + if (value is bool boolValue) + { + if (!boolValue) + return false; + } + } + + return true; + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + { + throw new NotSupportedException("BooleanAndConverter is a OneWay converter."); + } +} diff --git a/WpfRemote/Converters/MultiBoolAndToVisibilityConverter.cs b/WpfRemote/Converters/MultiBoolAndToVisibilityConverter.cs new file mode 100644 index 00000000..9cf51875 --- /dev/null +++ b/WpfRemote/Converters/MultiBoolAndToVisibilityConverter.cs @@ -0,0 +1,30 @@ +namespace WpfRemote.Converters; + +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +public class MultiBoolAndToVisibilityConverter : IMultiValueConverter +{ + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + foreach (object value in values) + { + if (value is bool boolValue) + { + if (!boolValue) + { + return Visibility.Collapsed; + } + } + } + + return Visibility.Visible; + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + { + throw new NotSupportedException("BooleanAndConverter is a OneWay converter."); + } +} diff --git a/WpfRemote/Converters/MultiBoolOrConverter.cs b/WpfRemote/Converters/MultiBoolOrConverter.cs new file mode 100644 index 00000000..16e88df7 --- /dev/null +++ b/WpfRemote/Converters/MultiBoolOrConverter.cs @@ -0,0 +1,27 @@ +namespace WpfRemote.Converters; + +using System; +using System.Globalization; +using System.Windows.Data; + +public class MultiBoolOrConverter : IMultiValueConverter +{ + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + foreach (object value in values) + { + if (value is bool boolValue) + { + if (boolValue) + return true; + } + } + + return false; + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + { + throw new NotSupportedException("BooleanAndConverter is a OneWay converter."); + } +} diff --git a/WpfRemote/Converters/MultiBoolOrToVisibilityConverter.cs b/WpfRemote/Converters/MultiBoolOrToVisibilityConverter.cs new file mode 100644 index 00000000..431bad37 --- /dev/null +++ b/WpfRemote/Converters/MultiBoolOrToVisibilityConverter.cs @@ -0,0 +1,28 @@ +namespace WpfRemote.Converters; + +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +public class MultiBoolOrToVisibilityConverter : IMultiValueConverter +{ + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + foreach (object value in values) + { + if (value is bool boolValue) + { + if (boolValue) + return Visibility.Visible; + } + } + + return Visibility.Collapsed; + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + { + throw new NotSupportedException("BooleanAndConverter is a OneWay converter."); + } +} diff --git a/WpfRemote/Converters/NotEmptyToVisibilityConverter.cs b/WpfRemote/Converters/NotEmptyToVisibilityConverter.cs new file mode 100644 index 00000000..03c92c70 --- /dev/null +++ b/WpfRemote/Converters/NotEmptyToVisibilityConverter.cs @@ -0,0 +1,32 @@ +namespace WpfRemote.Converters; + +using System; +using System.Collections; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +[ValueConversion(typeof(IEnumerable), typeof(Visibility))] +public class NotEmptyToVisibilityConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object parameter, CultureInfo culture) + { + if (value == null) + return Visibility.Collapsed; + + if (value is IEnumerable enumerable) + { + foreach (object obj in enumerable) + { + return Visibility.Visible; + } + } + + return Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/WpfRemote/Converters/NotNullToBoolConverter.cs b/WpfRemote/Converters/NotNullToBoolConverter.cs new file mode 100644 index 00000000..d2ca2c10 --- /dev/null +++ b/WpfRemote/Converters/NotNullToBoolConverter.cs @@ -0,0 +1,26 @@ +namespace WpfRemote.Converters; + +using System; +using System.Windows.Data; + +[ValueConversion(typeof(object), typeof(bool))] +public class NotNullToBoolConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + if (value is string str) + { + if (string.IsNullOrEmpty(str)) + { + value = null; + } + } + + return value != null; + } + + public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/WpfRemote/Converters/NotNullToVisibilityConverter.cs b/WpfRemote/Converters/NotNullToVisibilityConverter.cs new file mode 100644 index 00000000..b0402354 --- /dev/null +++ b/WpfRemote/Converters/NotNullToVisibilityConverter.cs @@ -0,0 +1,27 @@ +namespace WpfRemote.Converters; + +using System; +using System.Windows; +using System.Windows.Data; + +[ValueConversion(typeof(object), typeof(Visibility))] +public class NotNullToVisibilityConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + if (value is string str) + { + if (string.IsNullOrEmpty(str)) + { + value = null; + } + } + + return value == null ? Visibility.Collapsed : Visibility.Visible; + } + + public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/WpfRemote/Converters/NotZeroToBoolConverter.cs b/WpfRemote/Converters/NotZeroToBoolConverter.cs new file mode 100644 index 00000000..1682edb4 --- /dev/null +++ b/WpfRemote/Converters/NotZeroToBoolConverter.cs @@ -0,0 +1,19 @@ +namespace WpfRemote.Converters; + +using System; +using System.Windows; +using System.Windows.Data; + +[ValueConversion(typeof(object), typeof(bool))] +public class NotZeroToBoolConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + return !IsZeroToBoolConverter.IsZero(value); + } + + public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/WpfRemote/Converters/NotZeroToVisibilityConverter.cs b/WpfRemote/Converters/NotZeroToVisibilityConverter.cs new file mode 100644 index 00000000..a7b610be --- /dev/null +++ b/WpfRemote/Converters/NotZeroToVisibilityConverter.cs @@ -0,0 +1,19 @@ +namespace WpfRemote.Converters; + +using System; +using System.Windows; +using System.Windows.Data; + +[ValueConversion(typeof(object), typeof(Visibility))] +public class NotZeroToVisibilityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + return IsZeroToBoolConverter.IsZero(value) ? Visibility.Collapsed : Visibility.Visible; + } + + public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/WpfRemote/Converters/NullToBoolConverter.cs b/WpfRemote/Converters/NullToBoolConverter.cs new file mode 100644 index 00000000..1d02a912 --- /dev/null +++ b/WpfRemote/Converters/NullToBoolConverter.cs @@ -0,0 +1,26 @@ +namespace WpfRemote.Converters; + +using System; +using System.Windows.Data; + +[ValueConversion(typeof(object), typeof(bool))] +public class NullToBoolConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + if (value is string str) + { + if (string.IsNullOrEmpty(str)) + { + value = null; + } + } + + return value == null; + } + + public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/WpfRemote/Converters/NullToVisibilityConverter.cs b/WpfRemote/Converters/NullToVisibilityConverter.cs new file mode 100644 index 00000000..2d217170 --- /dev/null +++ b/WpfRemote/Converters/NullToVisibilityConverter.cs @@ -0,0 +1,27 @@ +namespace WpfRemote.Converters; + +using System; +using System.Windows; +using System.Windows.Data; + +[ValueConversion(typeof(object), typeof(Visibility))] +public class NullToVisibilityConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + if (value is string str) + { + if (string.IsNullOrEmpty(str)) + { + value = null; + } + } + + return value == null ? Visibility.Visible : Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/WpfRemote/Converters/NumberConverter.cs b/WpfRemote/Converters/NumberConverter.cs new file mode 100644 index 00000000..e5d04e4a --- /dev/null +++ b/WpfRemote/Converters/NumberConverter.cs @@ -0,0 +1,37 @@ +namespace WpfRemote.Converters; + +using System; +using System.Globalization; +using System.Windows.Data; + +[ValueConversion(typeof(object), typeof(double))] +public class NumberConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is double doubleVal && parameter is Type target) + { + if (target == typeof(byte)) + return System.Convert.ToByte(value); + + if (target == typeof(short)) + return System.Convert.ToInt16(value); + + if (target == typeof(int)) + return System.Convert.ToInt32(value); + + if (target == typeof(long)) + return System.Convert.ToInt64(value); + + if (target == typeof(float)) + return System.Convert.ToSingle(value); + } + + return value; + } +} diff --git a/WpfRemote/Converters/NumberToThicknessConverter.cs b/WpfRemote/Converters/NumberToThicknessConverter.cs new file mode 100644 index 00000000..1c5f30bf --- /dev/null +++ b/WpfRemote/Converters/NumberToThicknessConverter.cs @@ -0,0 +1,59 @@ +namespace WpfRemote.Converters; + +using System.Windows; + +public class NumberToThicknessLeftConverter : NumberToThicknessConverter +{ + protected override void Add(double value, ref Thickness baseThickness) + { + baseThickness.Left += value; + } +} + +public class NumberToThicknessTopConverter : NumberToThicknessConverter +{ + protected override void Add(double value, ref Thickness baseThickness) + { + baseThickness.Top += value; + } +} + +public class NumberToThicknessRightConverter : NumberToThicknessConverter +{ + protected override void Add(double value, ref Thickness baseThickness) + { + baseThickness.Right += value; + } +} + +public class NumberToThicknessBottomConverter : NumberToThicknessConverter +{ + protected override void Add(double value, ref Thickness baseThickness) + { + baseThickness.Bottom += value; + } +} + +public abstract class NumberToThicknessConverter : ConverterBase +{ + private static readonly ThicknessConverter ThicknessConverter = new(); + + protected sealed override Thickness Convert(double value) + { + Thickness thickness = default; + + if (this.Parameter is string paramStr) + { + object? obj = ThicknessConverter.ConvertFrom(this.Parameter); + if (obj is Thickness thicknessParam) + { + thickness = thicknessParam; + } + } + + this.Add(value, ref thickness); + return thickness; + } + + protected abstract void Add(double value, ref Thickness thickness); +} \ No newline at end of file diff --git a/WpfRemote/Converters/RadiansToDegreesConverter.cs b/WpfRemote/Converters/RadiansToDegreesConverter.cs new file mode 100644 index 00000000..fe460115 --- /dev/null +++ b/WpfRemote/Converters/RadiansToDegreesConverter.cs @@ -0,0 +1,19 @@ +namespace WpfRemote.Converters; + +using System; +using System.Globalization; +using System.Windows.Data; + +[ValueConversion(typeof(float), typeof(float))] +public class RadiansToDegreesConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return System.Convert.ToSingle(value) * (180 / Math.PI); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return System.Convert.ToSingle(value) * (Math.PI / 180); + } +} diff --git a/WpfRemote/Converters/StringHasContentToBoolConverter.cs b/WpfRemote/Converters/StringHasContentToBoolConverter.cs new file mode 100644 index 00000000..a44213c4 --- /dev/null +++ b/WpfRemote/Converters/StringHasContentToBoolConverter.cs @@ -0,0 +1,19 @@ +namespace WpfRemote.Converters; + +using System; +using System.Windows.Data; + +[ValueConversion(typeof(string), typeof(bool))] +public class StringHasContentToBoolConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + string? val = value as string; + return !string.IsNullOrEmpty(val); + } + + public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/WpfRemote/Converters/StringHasContentToVisibilityConverter.cs b/WpfRemote/Converters/StringHasContentToVisibilityConverter.cs new file mode 100644 index 00000000..352e50ce --- /dev/null +++ b/WpfRemote/Converters/StringHasContentToVisibilityConverter.cs @@ -0,0 +1,20 @@ +namespace WpfRemote.Converters; + +using System; +using System.Windows; +using System.Windows.Data; + +[ValueConversion(typeof(string), typeof(Visibility))] +public class StringHasContentToVisibilityConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + string? val = value as string; + return string.IsNullOrEmpty(val) ? Visibility.Collapsed : Visibility.Visible; + } + + public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/WpfRemote/DependencyProperties/Binder.cs b/WpfRemote/DependencyProperties/Binder.cs new file mode 100644 index 00000000..e9e1b1d2 --- /dev/null +++ b/WpfRemote/DependencyProperties/Binder.cs @@ -0,0 +1,65 @@ +namespace WpfRemote.DependencyProperties; + +using System; +using System.Reflection; +using System.Windows; +using System.Windows.Data; + +public class Binder +{ + public static DependencyProperty Register(string propertyName, BindMode mode) + { + Action callback = (d, e) => { }; + return Register(propertyName, new PropertyChangedCallback(callback), mode); + } + + public static DependencyProperty Register(string propertyName, Action? changed = null, BindMode mode = BindMode.TwoWay) + { + Action callback = (d, e) => + { + if (d is TOwner owner && e.NewValue is TValue value) + { + changed?.Invoke(owner, value); + } + }; + + return Register(propertyName, new PropertyChangedCallback(callback), mode); + } + + public static DependencyProperty Register(string propertyName, Action changed, BindMode mode = BindMode.TwoWay) + { + Action callback = (d, e) => + { + if (d is TOwner owner) + { + TValue oldValue = (TValue)e.OldValue; + TValue newValue = (TValue)e.NewValue; + changed?.Invoke(owner, oldValue, newValue); + } + }; + + return Register(propertyName, new PropertyChangedCallback(callback), mode); + } + + private static DependencyProperty Register(string propertyName, PropertyChangedCallback callback, BindMode mode) + { + PropertyInfo? property = typeof(TOwner).GetProperty(propertyName); + if (property == null) + throw new Exception("Failed to locate property: \"" + propertyName + "\" on type: \"" + typeof(TOwner) + "\" for binding."); + + FrameworkPropertyMetadata meta = new FrameworkPropertyMetadata(new PropertyChangedCallback(callback)); + meta.BindsTwoWayByDefault = mode == BindMode.TwoWay; + meta.DefaultUpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged; + meta.Inherits = true; + DependencyProperty dp = DependencyProperty.Register(propertyName, typeof(TValue), typeof(TOwner), meta); + DependencyProperty dpv = new DependencyProperty(dp); + return dpv; + } +} + +#pragma warning disable SA1201 +public enum BindMode +{ + OneWay, + TwoWay, +} diff --git a/WpfRemote/DependencyProperties/DependencyProperty{TValue}.cs b/WpfRemote/DependencyProperties/DependencyProperty{TValue}.cs new file mode 100644 index 00000000..d1b64ae5 --- /dev/null +++ b/WpfRemote/DependencyProperties/DependencyProperty{TValue}.cs @@ -0,0 +1,28 @@ +namespace WpfRemote.DependencyProperties; + +using System.Windows; + +public class DependencyProperty : IBind +{ + private DependencyProperty dp; + + public DependencyProperty(DependencyProperty dp) + { + this.dp = dp; + } + + public TValue Get(DependencyObject control) + { + return (TValue)control.GetValue(this.dp); + } + + public void Set(DependencyObject control, TValue value) + { + TValue old = this.Get(control); + + if (old != null && old.Equals(value)) + return; + + control.SetValue(this.dp, value); + } +} diff --git a/WpfRemote/DependencyProperties/IBind{TValue}.cs b/WpfRemote/DependencyProperties/IBind{TValue}.cs new file mode 100644 index 00000000..441aa041 --- /dev/null +++ b/WpfRemote/DependencyProperties/IBind{TValue}.cs @@ -0,0 +1,9 @@ +namespace WpfRemote.DependencyProperties; + +using System.Windows; + +public interface IBind +{ + TValue Get(DependencyObject control); + void Set(DependencyObject control, TValue value); +} diff --git a/WpfRemote/Extensions/DependencyObjectExtensions.cs b/WpfRemote/Extensions/DependencyObjectExtensions.cs new file mode 100644 index 00000000..c85fbe7a --- /dev/null +++ b/WpfRemote/Extensions/DependencyObjectExtensions.cs @@ -0,0 +1,60 @@ +namespace System.Windows; + +using System.Collections.Generic; +using System.Windows.Media; + +public static class DependencyObjectExtensions +{ + public static T? FindParent(this DependencyObject child) + where T : DependencyObject + { + DependencyObject parentObject = VisualTreeHelper.GetParent(child); + + if (parentObject == null) + return null; + + T? parent = parentObject as T; + + if (parent != null) + { + return parent; + } + else + { + return parentObject.FindParent(); + } + } + + public static T? FindChild(this DependencyObject self) + where T : notnull + { + List results = new List(); + self.FindChildren(ref results); + + if (results.Count == 0) + return default; + + return results[0]; + } + + public static List FindChildren(this DependencyObject self) + { + List results = new List(); + self.FindChildren(ref results); + return results; + } + + public static void FindChildren(this DependencyObject self, ref List results) + { + int children = VisualTreeHelper.GetChildrenCount(self); + for (int i = 0; i < children; i++) + { + DependencyObject? child = VisualTreeHelper.GetChild(self, i); + + if (child is T tChild) + results.Add(tChild); + + child.FindChildren(ref results); + } + } +} diff --git a/WpfRemote/Log.cs b/WpfRemote/Log.cs new file mode 100644 index 00000000..64d28129 --- /dev/null +++ b/WpfRemote/Log.cs @@ -0,0 +1,14 @@ +using System; + +namespace WpfRemote; + +internal static class Log +{ + public static void Information(string message) + { + } + + public static void Error(Exception ex, string message) + { + } +} diff --git a/WpfRemote/MainWindow.xaml b/WpfRemote/MainWindow.xaml new file mode 100644 index 00000000..fcd00a36 --- /dev/null +++ b/WpfRemote/MainWindow.xaml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WpfRemote/MainWindow.xaml.cs b/WpfRemote/MainWindow.xaml.cs new file mode 100644 index 00000000..99c9d06c --- /dev/null +++ b/WpfRemote/MainWindow.xaml.cs @@ -0,0 +1,72 @@ +namespace WpfRemote; + +using Brio.Remote; +using System.ComponentModel; +using System.Windows; +using PropertyChanged.SourceGenerator; +using FFXIVClientStructs.Havok; + +public partial class MainWindow : Window +{ + private RemoteService _remoteService; + + public MainWindow() + { + InitializeComponent(); + + _remoteService = new(); + _remoteService.OnMessageCallback = OnMessage; + } + + protected MainWindowViewModel ViewModel => (MainWindowViewModel)this.DataContext; + + private void OnMessage(object obj) + { + if (obj is BoneMessage bm) + { + if (Dispatcher.HasShutdownStarted) + return; + + Dispatcher?.Invoke(() => + { + ViewModel.BoneName = bm.Name ?? string.Empty; + ViewModel.BoneDisplayName = bm.DisplayName ?? string.Empty; + ViewModel.PositionX = bm.PositionX; + ViewModel.PositionY = bm.PositionY; + ViewModel.PositionZ = bm.PositionZ; + ViewModel.ScaleX = bm.ScaleX; + ViewModel.ScaleY = bm.ScaleY; + ViewModel.ScaleZ = bm.ScaleZ; + + hkQuaternionf rot = new hkQuaternionf(); + rot.X = bm.RotationX; + rot.Y = bm.RotationY; + rot.Z = bm.RotationZ; + rot.W = bm.RotationW; + + ViewModel.Rotation = rot; + ViewModel.EulerRotationX = rot.ToEuler().X; + ViewModel.EulerRotationY = rot.ToEuler().Y; + ViewModel.EulerRotationZ = rot.ToEuler().Z; + }); + } + } +} + +public partial class MainWindowViewModel +{ + [Notify] public string boneName = string.Empty; + [Notify] public string boneDisplayName = string.Empty; + [Notify] public float positionX = 0; + [Notify] public float positionY = 0; + [Notify] public float positionZ = 0; + [Notify] public float scaleX = 0; + [Notify] public float scaleY = 0; + [Notify] public float scaleZ = 0; + [Notify] public float eulerRotationX = 0; + [Notify] public float eulerRotationY = 1; + [Notify] public float eulerRotationZ = 0; + + [Notify] public hkQuaternionf rootRotation; + [Notify] public hkQuaternionf rotation; +} \ No newline at end of file diff --git a/WpfRemote/RemoteService.cs b/WpfRemote/RemoteService.cs new file mode 100644 index 00000000..0730224f --- /dev/null +++ b/WpfRemote/RemoteService.cs @@ -0,0 +1,71 @@ +using Brio.Remote; +using EasyTcp4; +using EasyTcp4.ClientUtils; +using EasyTcp4.ClientUtils.Async; +using MessagePack; +using System; +using System.Net; +using System.Threading.Tasks; +using System.Windows; + +namespace WpfRemote; + +internal class RemoteService +{ + private EasyTcpClient? _client; + private int _heartbeatIndex = 0; + + public Action? OnMessageCallback; + + public RemoteService() + { + Task.Run(StartClient); + } + + public async Task StartClient() + { + if (_client != null) + throw new Exception("Attempt to start remote client while it is already running"); + + _client = new(); + _client.OnError += (s, e) => Log.Error(e, "IPC error"); + _client.OnDataReceive += this.OnDataReceived; + + bool success = await _client.ConnectAsync(IPAddress.Loopback, Configuration.Port); + + if (success) + _ = Task.Run(HeartbeatTask); + + return success; + } + + public void Send(object obj) + { + byte[] data = MessagePackSerializer.Typeless.Serialize(obj); + _client.Send(data); + } + + private void OnDataReceived(object? sender, Message e) + { + object? obj = MessagePackSerializer.Typeless.Deserialize(e.Data); + Log.Information($"Received {obj?.GetType()}"); + + if (obj == null) + return; + + OnMessageCallback?.Invoke(obj); + } + + private async Task HeartbeatTask() + { + while(Application.Current != null && _client.IsConnected()) + { + Heartbeat hb = new(); + hb.Count = _heartbeatIndex; + Send(hb); + + await Task.Delay(1000); + } + } +} + diff --git a/WpfRemote/Styles/BorderStyles.xaml b/WpfRemote/Styles/BorderStyles.xaml new file mode 100644 index 00000000..b1178e0d --- /dev/null +++ b/WpfRemote/Styles/BorderStyles.xaml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WpfRemote/Styles/ButtonStyles.xaml b/WpfRemote/Styles/ButtonStyles.xaml new file mode 100644 index 00000000..70c3ff11 --- /dev/null +++ b/WpfRemote/Styles/ButtonStyles.xaml @@ -0,0 +1,231 @@ + + + + + + + + + \ No newline at end of file diff --git a/WpfRemote/Styles/CheckBoxStyles.xaml b/WpfRemote/Styles/CheckBoxStyles.xaml new file mode 100644 index 00000000..dc6215a8 --- /dev/null +++ b/WpfRemote/Styles/CheckBoxStyles.xaml @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WpfRemote/Styles/ComboBoxStyles.xaml b/WpfRemote/Styles/ComboBoxStyles.xaml new file mode 100644 index 00000000..9e28ce03 --- /dev/null +++ b/WpfRemote/Styles/ComboBoxStyles.xaml @@ -0,0 +1,221 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WpfRemote/Styles/ExpanderStyles.xaml b/WpfRemote/Styles/ExpanderStyles.xaml new file mode 100644 index 00000000..8b8c000e --- /dev/null +++ b/WpfRemote/Styles/ExpanderStyles.xaml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WpfRemote/Styles/GroupBoxStyles.xaml b/WpfRemote/Styles/GroupBoxStyles.xaml new file mode 100644 index 00000000..99478715 --- /dev/null +++ b/WpfRemote/Styles/GroupBoxStyles.xaml @@ -0,0 +1,75 @@ + + + + \ No newline at end of file diff --git a/WpfRemote/Styles/ListBoxStyles.xaml b/WpfRemote/Styles/ListBoxStyles.xaml new file mode 100644 index 00000000..3572785c --- /dev/null +++ b/WpfRemote/Styles/ListBoxStyles.xaml @@ -0,0 +1,245 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WpfRemote/Styles/ProgressBarStyles.xaml b/WpfRemote/Styles/ProgressBarStyles.xaml new file mode 100644 index 00000000..6917be6c --- /dev/null +++ b/WpfRemote/Styles/ProgressBarStyles.xaml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/WpfRemote/Styles/ScrollBarStyles.xaml b/WpfRemote/Styles/ScrollBarStyles.xaml new file mode 100644 index 00000000..45dd0c07 --- /dev/null +++ b/WpfRemote/Styles/ScrollBarStyles.xaml @@ -0,0 +1,119 @@ + + + 12 + 12 + 48 + 48 + + + + + + + + + \ No newline at end of file diff --git a/WpfRemote/Styles/SliderStyles.xaml b/WpfRemote/Styles/SliderStyles.xaml new file mode 100644 index 00000000..23aad36d --- /dev/null +++ b/WpfRemote/Styles/SliderStyles.xaml @@ -0,0 +1,217 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WpfRemote/Styles/TabControlStyles.xaml b/WpfRemote/Styles/TabControlStyles.xaml new file mode 100644 index 00000000..1c30646e --- /dev/null +++ b/WpfRemote/Styles/TabControlStyles.xaml @@ -0,0 +1,314 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WpfRemote/Styles/TextBlockStyles.xaml b/WpfRemote/Styles/TextBlockStyles.xaml new file mode 100644 index 00000000..17ff3215 --- /dev/null +++ b/WpfRemote/Styles/TextBlockStyles.xaml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WpfRemote/Styles/TextBoxStyles.xaml b/WpfRemote/Styles/TextBoxStyles.xaml new file mode 100644 index 00000000..d87779e8 --- /dev/null +++ b/WpfRemote/Styles/TextBoxStyles.xaml @@ -0,0 +1,142 @@ + + + + \ No newline at end of file diff --git a/WpfRemote/Styles/ToggleButtonStyles.xaml b/WpfRemote/Styles/ToggleButtonStyles.xaml new file mode 100644 index 00000000..e5a9480d --- /dev/null +++ b/WpfRemote/Styles/ToggleButtonStyles.xaml @@ -0,0 +1,210 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WpfRemote/Styles/WindowStyles.xaml b/WpfRemote/Styles/WindowStyles.xaml new file mode 100644 index 00000000..cf2a6888 --- /dev/null +++ b/WpfRemote/Styles/WindowStyles.xaml @@ -0,0 +1,35 @@ + + + \ No newline at end of file diff --git a/WpfRemote/Themes/Dark.xaml b/WpfRemote/Themes/Dark.xaml new file mode 100644 index 00000000..f3d186f3 --- /dev/null +++ b/WpfRemote/Themes/Dark.xaml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WpfRemote/Utility/Dispatch.cs b/WpfRemote/Utility/Dispatch.cs new file mode 100644 index 00000000..0fb75cf2 --- /dev/null +++ b/WpfRemote/Utility/Dispatch.cs @@ -0,0 +1,70 @@ +namespace WpfRemote; + +using System; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Threading; + +public static class Dispatch +{ + ////public static SwitchToUiAwaitable MainThread() => new(); + public static SwitchFromUiAwaitable NonUiThread() => new(); + + public static SwitchToMainThreadAwaitable MainThread(this DispatcherObject self) => new(self.Dispatcher); + public static SwitchToMainThreadAwaitable MainThread(this Dispatcher self) => new(self); + + public struct SwitchToMainThreadAwaitable : INotifyCompletion + { + private readonly Dispatcher dispatch; + + public SwitchToMainThreadAwaitable(Dispatcher dispatcher) + { + this.dispatch = dispatcher; + } + + public bool IsCompleted => this.dispatch?.CheckAccess() == true; + + public SwitchToMainThreadAwaitable GetAwaiter() => this; + public void GetResult() + { + } + + public void OnCompleted(Action continuation) + { + this.dispatch.BeginInvoke(continuation); + } + } + + public struct SwitchFromUiAwaitable : INotifyCompletion + { + private readonly Dispatcher? dispatch; + + public SwitchFromUiAwaitable() + { + this.dispatch = Dispatcher.FromThread(Thread.CurrentThread); + + if (this.dispatch == null) + { + this.dispatch = Application.Current?.Dispatcher; + } + } + + public bool IsCompleted => this.dispatch?.CheckAccess() == false; + + public SwitchFromUiAwaitable GetAwaiter() + { + return this; + } + + public void GetResult() + { + } + + public void OnCompleted(Action continuation) + { + Task.Run(continuation); + } + } +} diff --git a/WpfRemote/WpfRemote.csproj b/WpfRemote/WpfRemote.csproj new file mode 100644 index 00000000..c003c336 --- /dev/null +++ b/WpfRemote/WpfRemote.csproj @@ -0,0 +1,47 @@ + + + + WinExe + net7.0-windows + enable + true + True + CopyUsed + false + true + true + 1701;1702;SA1503;CS1591;SA1401;SA1516;CS0067;IDE0027;IDE0025;SA1011;SA1134; + .\bin\ + .\obj\ScreenshotStudio.xml + False + false + x64 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + $(appdata)\XIVLauncher\addon\Hooks\dev\ + + + $(DALAMUD_HOME)/ + true + + + + + + + + + $(DalamudLibPath)FFXIVClientStructs.dll + True + + + +