From e1a47a4c5688d6bded24ff3f2b560f6618d69527 Mon Sep 17 00:00:00 2001 From: Dominic Beger Date: Sun, 11 Oct 2026 01:45:47 +0200 Subject: [PATCH 01/14] Convert to SDK-style projects on .NET 10 and port tests to xUnit --- .editorconfig | 52 +++ .gitignore | 328 +++++++++++++++++- Directory.Build.props | 23 ++ Directory.Packages.props | 21 ++ SharpMath.Tests/Line3DTest.cs | 39 --- SharpMath.Tests/ParserTest.cs | 22 -- SharpMath.Tests/Properties/AssemblyInfo.cs | 42 --- SharpMath.Tests/SharpMath.Tests.csproj | 98 ------ SharpMath.sln.GhostDoc.xml | 6 - SharpMath.slnx | 9 + SharpMath/Analysis/Domain.cs | 10 - SharpMath/Analysis/Function.cs | 53 --- SharpMath/Analysis/Interval.cs | 19 - SharpMath/Analysis/IntervalValue.cs | 14 - SharpMath/Analysis/IntervalValueOption.cs | 12 - .../Analysis/IntervalValueOptionAttribute.cs | 14 - SharpMath/Analysis/Monotony.cs | 44 --- SharpMath/Analysis/MonotonyType.cs | 10 - SharpMath/Analysis/Rule.cs | 9 - SharpMath/Analysis/RuleSet/IRuleSet.cs | 9 - SharpMath/Analysis/Signs.cs | 11 - SharpMath/Properties/AssemblyInfo.cs | 44 --- SharpMath/SharpMath.cd | 316 ----------------- SharpMath/SharpMath.csproj | 97 ------ SharpMath/SharpMath.csproj.DotSettings | 2 - global.json | 9 + src/Directory.Build.props | 18 + .../Legacy}/Canvas2D.cs | 0 .../Legacy}/FunctionWrapper.cs | 0 .../SharpMath.Canvas.WinForms.csproj | 20 ++ {SharpMath => src/SharpMath}/Algorithms.cs | 0 .../EquationNotSolvableException.cs | 0 .../SharpMath}/Equations/LinearEquation.cs | 0 .../Equations/LinearEquationSystem.cs | 0 .../SharpMath}/Expressions/CharEx.cs | 0 .../Expressions/Exceptions/ParserException.cs | 0 .../SharpMath}/Expressions/Parser.cs | 0 .../SharpMath}/Expressions/Token.cs | 0 .../SharpMath}/Expressions/TokenEx.cs | 0 .../SharpMath}/Expressions/TokenType.cs | 0 {SharpMath => src/SharpMath}/Extensions.cs | 0 .../SharpMath}/FloatingNumber.cs | 0 .../SharpMath}/FloatingNumberExtensions.cs | 0 .../Geometry/Exceptions/DimensionException.cs | 0 .../SharpMath}/Geometry/IMatrix.cs | 0 .../SharpMath}/Geometry/ISquareMatrix.cs | 0 .../SharpMath}/Geometry/IVector.cs | 0 .../SharpMath}/Geometry/Line2D.cs | 0 .../SharpMath}/Geometry/Line3D.cs | 0 .../SharpMath}/Geometry/Matrix1x1.cs | 0 .../SharpMath}/Geometry/Matrix1x3.cs | 0 .../SharpMath}/Geometry/Matrix1x4.cs | 0 .../SharpMath}/Geometry/Matrix2x2.cs | 0 .../SharpMath}/Geometry/Matrix3x1.cs | 0 .../SharpMath}/Geometry/Matrix3x3.cs | 0 .../SharpMath}/Geometry/Matrix4x1.cs | 0 .../SharpMath}/Geometry/Matrix4x4.cs | 0 .../SharpMath}/Geometry/MatrixEnumerator.cs | 0 .../SharpMath}/Geometry/MatrixUtils.cs | 0 .../SharpMath}/Geometry/Point2D.cs | 0 .../SharpMath}/Geometry/Point3D.cs | 0 .../SharpMath}/Geometry/Polygon.cs | 0 .../SharpMath}/Geometry/ProjectionData.cs | 0 .../SharpMath}/Geometry/VariableMatrix.cs | 0 .../SharpMath}/Geometry/Vector2.cs | 0 .../SharpMath}/Geometry/Vector3.cs | 0 .../SharpMath}/Geometry/Vector4.cs | 0 .../SharpMath}/Geometry/VectorUtils.cs | 0 {SharpMath => src/SharpMath}/MathHelper.cs | 0 src/SharpMath/SharpMath.csproj | 13 + tests/Directory.Build.props | 16 + .../SharpMath.Tests}/Line2DTest.cs | 53 ++- tests/SharpMath.Tests/Line3DTest.cs | 34 ++ .../SharpMath.Tests}/LinearEquationTest.cs | 31 +- .../SharpMath.Tests}/MatrixTest.cs | 53 ++- tests/SharpMath.Tests/ParserTest.cs | 17 + .../SharpMath.Tests}/PolygonTest.cs | 27 +- tests/SharpMath.Tests/SharpMath.Tests.csproj | 14 + .../SharpMath.Tests}/VectorTest.cs | 163 +++++---- 79 files changed, 710 insertions(+), 1062 deletions(-) create mode 100644 .editorconfig create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props delete mode 100644 SharpMath.Tests/Line3DTest.cs delete mode 100644 SharpMath.Tests/ParserTest.cs delete mode 100644 SharpMath.Tests/Properties/AssemblyInfo.cs delete mode 100644 SharpMath.Tests/SharpMath.Tests.csproj delete mode 100644 SharpMath.sln.GhostDoc.xml create mode 100644 SharpMath.slnx delete mode 100644 SharpMath/Analysis/Domain.cs delete mode 100644 SharpMath/Analysis/Function.cs delete mode 100644 SharpMath/Analysis/Interval.cs delete mode 100644 SharpMath/Analysis/IntervalValue.cs delete mode 100644 SharpMath/Analysis/IntervalValueOption.cs delete mode 100644 SharpMath/Analysis/IntervalValueOptionAttribute.cs delete mode 100644 SharpMath/Analysis/Monotony.cs delete mode 100644 SharpMath/Analysis/MonotonyType.cs delete mode 100644 SharpMath/Analysis/Rule.cs delete mode 100644 SharpMath/Analysis/RuleSet/IRuleSet.cs delete mode 100644 SharpMath/Analysis/Signs.cs delete mode 100644 SharpMath/Properties/AssemblyInfo.cs delete mode 100644 SharpMath/SharpMath.cd delete mode 100644 SharpMath/SharpMath.csproj delete mode 100644 SharpMath/SharpMath.csproj.DotSettings create mode 100644 global.json create mode 100644 src/Directory.Build.props rename {SharpMath/Presentation => src/SharpMath.Canvas.WinForms/Legacy}/Canvas2D.cs (100%) rename {SharpMath/Presentation => src/SharpMath.Canvas.WinForms/Legacy}/FunctionWrapper.cs (100%) create mode 100644 src/SharpMath.Canvas.WinForms/SharpMath.Canvas.WinForms.csproj rename {SharpMath => src/SharpMath}/Algorithms.cs (100%) rename {SharpMath => src/SharpMath}/Equations/Exceptions/EquationNotSolvableException.cs (100%) rename {SharpMath => src/SharpMath}/Equations/LinearEquation.cs (100%) rename {SharpMath => src/SharpMath}/Equations/LinearEquationSystem.cs (100%) rename {SharpMath => src/SharpMath}/Expressions/CharEx.cs (100%) rename {SharpMath => src/SharpMath}/Expressions/Exceptions/ParserException.cs (100%) rename {SharpMath => src/SharpMath}/Expressions/Parser.cs (100%) rename {SharpMath => src/SharpMath}/Expressions/Token.cs (100%) rename {SharpMath => src/SharpMath}/Expressions/TokenEx.cs (100%) rename {SharpMath => src/SharpMath}/Expressions/TokenType.cs (100%) rename {SharpMath => src/SharpMath}/Extensions.cs (100%) rename {SharpMath => src/SharpMath}/FloatingNumber.cs (100%) rename {SharpMath => src/SharpMath}/FloatingNumberExtensions.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/Exceptions/DimensionException.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/IMatrix.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/ISquareMatrix.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/IVector.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/Line2D.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/Line3D.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/Matrix1x1.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/Matrix1x3.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/Matrix1x4.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/Matrix2x2.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/Matrix3x1.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/Matrix3x3.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/Matrix4x1.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/Matrix4x4.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/MatrixEnumerator.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/MatrixUtils.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/Point2D.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/Point3D.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/Polygon.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/ProjectionData.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/VariableMatrix.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/Vector2.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/Vector3.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/Vector4.cs (100%) rename {SharpMath => src/SharpMath}/Geometry/VectorUtils.cs (100%) rename {SharpMath => src/SharpMath}/MathHelper.cs (100%) create mode 100644 src/SharpMath/SharpMath.csproj create mode 100644 tests/Directory.Build.props rename {SharpMath.Tests => tests/SharpMath.Tests}/Line2DTest.cs (51%) create mode 100644 tests/SharpMath.Tests/Line3DTest.cs rename {SharpMath.Tests => tests/SharpMath.Tests}/LinearEquationTest.cs (60%) rename {SharpMath.Tests => tests/SharpMath.Tests}/MatrixTest.cs (81%) create mode 100644 tests/SharpMath.Tests/ParserTest.cs rename {SharpMath.Tests => tests/SharpMath.Tests}/PolygonTest.cs (60%) create mode 100644 tests/SharpMath.Tests/SharpMath.Tests.csproj rename {SharpMath.Tests => tests/SharpMath.Tests}/VectorTest.cs (63%) diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..27a4c24 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,52 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{csproj,props,targets,slnx,xml,axaml,resx,json,yml,yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.cs] +# Namespaces and usings +csharp_style_namespace_declarations = file_scoped:warning +dotnet_diagnostic.IDE0161.severity = warning +dotnet_sort_system_directives_first = true +dotnet_diagnostic.IDE0005.severity = warning + +# Braces and layout (Allman; braces optional for single-line statements) +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_prefer_braces = when_multiline:suggestion + +# var and expression bodies +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_properties = true:suggestion +csharp_style_expression_bodied_operators = when_on_single_line:suggestion + +# Modern language features +csharp_style_prefer_primary_constructors = true:suggestion +dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion +csharp_style_prefer_pattern_matching = true:suggestion +csharp_style_prefer_switch_expression = true:suggestion + +# Naming: private fields are _camelCase +dotnet_naming_rule.private_fields_underscore.symbols = private_fields +dotnet_naming_rule.private_fields_underscore.style = underscore_camel +dotnet_naming_rule.private_fields_underscore.severity = suggestion +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private +dotnet_naming_style.underscore_camel.required_prefix = _ +dotnet_naming_style.underscore_camel.capitalization = camel_case diff --git a/.gitignore b/.gitignore index 186a0dd..0808c4a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,24 @@ ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env # User-specific files +*.rsuser *.suo *.user *.userosscache *.sln.docstates +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + # Build results [Dd]ebug/ [Dd]ebugPublic/ @@ -14,44 +26,79 @@ [Rr]eleases/ x64/ x86/ -build/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ bld/ [Bb]in/ [Oo]bj/ +[Ll]og/ +[Ll]ogs/ -# Roslyn cache directories -*.ide/ +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ # MSTest test Results [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* -#NUNIT +# NUnit *.VisualState.xml TestResult.xml +nunit-*.xml # Build Results of an ATL Project [Dd]ebugPS/ [Rr]eleasePS/ dlldata.c +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio *_i.c *_p.c -*_i.h +*_h.h *.ilk *.meta *.obj +*.iobj *.pch +*.pdb +*.ipdb *.pgc *.pgd *.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp *.sbr *.tlb *.tli *.tlh *.tmp *.tmp_proj +*_wpftmp.csproj *.log +*.tlog *.vspscc *.vssscc .builds @@ -66,14 +113,21 @@ _Chutzpah* ipch/ *.aps *.ncb +*.opendb *.opensdf *.sdf *.cachefile +*.VC.db +*.VC.VC.opendb # Visual Studio profiler *.psess *.vsp *.vspx +*.sap + +# Visual Studio Trace Files +*.e2e # TFS 2012 Local Workspace $tf/ @@ -86,18 +140,29 @@ _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user -# JustCode is a .NET coding addin-in -.JustCode - # TeamCity is a build add-in _TeamCity* # DotCover is a Code Coverage Tool *.dotCover +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + # NCrunch _NCrunch_* .*crunch*.local.xml +nCrunchTemp_* # MightyMoose *.mm.* @@ -125,39 +190,71 @@ publish/ # Publish Web Output *.[Pp]ublish.xml *.azurePubxml -# TODO: Comment the next line if you want to checkin your web deploy settings +# Note: Comment the next line if you want to checkin your web deploy settings, # but database connection strings (with potential passwords) will be unencrypted *.pubxml *.publishproj +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + # NuGet Packages *.nupkg +# NuGet Symbol Packages +*.snupkg # The packages folder can be ignored because of Package Restore -**/packages/* +**/[Pp]ackages/* # except build/, which is used as an MSBuild target. -!**/packages/build/ +!**/[Pp]ackages/build/ # Uncomment if necessary however generally it will be regenerated when needed -#!**/packages/repositories.config +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets -# Windows Azure Build Output +# Microsoft Azure Build Output csx/ *.build.csdef -# Windows Store app package directory +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ # Others -*.[Cc]ache ClientBin/ -[Ss]tyle[Cc]op.* ~$* *~ *.dbmdl *.dbproj.schemaview +*.jfm *.pfx *.publishsettings -node_modules/ -bower_components/ +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ # RIA/Silverlight projects Generated_Code/ @@ -169,24 +266,217 @@ _UpgradeReport_Files/ Backup*/ UpgradeLog*.XML UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak # SQL Server files *.mdf *.ldf +*.ndf # Business Intelligence projects *.rdl.data *.bim.layout *.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl # Microsoft Fakes FakesAssemblies/ +# GhostDoc plugin setting file +*.GhostDoc.xml + # Node.js Tools for Visual Studio .ntvs_analysis.dat +node_modules/ # Visual Studio 6 build log *.plg # Visual Studio 6 workspace options file -*.opt \ No newline at end of file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..c240c55 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,23 @@ + + + net10.0 + 14 + enable + enable + true + latest-recommended + true + true + true + false + + 2.0.0 + Dominic Beger + Copyright © Dominic Beger 2015-2026 + MIT + https://github.com/dbforge/SharpMath + https://github.com/dbforge/SharpMath + git + math;vector;matrix;geometry;linear-equations;expression-parser;plotting;canvas + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..f34a449 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,21 @@ + + + true + + + + + + + + + + + + + + + + + + diff --git a/SharpMath.Tests/Line3DTest.cs b/SharpMath.Tests/Line3DTest.cs deleted file mode 100644 index b705b76..0000000 --- a/SharpMath.Tests/Line3DTest.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Line3DTest.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SharpMath.Geometry; - -namespace SharpMath.Tests -{ - [TestClass] - public class Line3DTest - { - [TestMethod] - public void CanCreateLineFromPoints() - { - var line = Line3D.FromPoints(new Point3D(Vector3.Zero), new Point3D(Vector3.One)); - Assert.AreEqual(new Point3D(Vector3.Zero), line.Point); - Assert.AreEqual(Vector3.One, line.Direction); - - var secondLine = Line3D.FromPoints(new Point3D(2, 3, 1), new Point3D(3, 2, 4)); - Assert.AreEqual(new Point3D(2, 3, 1), secondLine.Point); - Assert.AreEqual(new Vector3(1, -1, 3), secondLine.Direction); - } - - [TestMethod] - public void CanDetermineIfPointIsOnLine() - { - var line = new Line3D(new Point3D(2, 3, 1), new Vector3(2, 1, 2)); - Assert.IsTrue(line.IsPointOnLine(new Point3D(6, 5, 5))); - Assert.IsFalse(line.IsPointOnLine(new Point3D(2, 3.5, 2))); - } - - [TestMethod] - public void CanGetPoint() - { - var line = new Line3D(new Point3D(2, 3, 1), new Vector3(2, 1, 2)); - Assert.AreEqual(new Point3D(6, 5, 5), line.GetPoint(2)); - } - } -} \ No newline at end of file diff --git a/SharpMath.Tests/ParserTest.cs b/SharpMath.Tests/ParserTest.cs deleted file mode 100644 index 7c05efa..0000000 --- a/SharpMath.Tests/ParserTest.cs +++ /dev/null @@ -1,22 +0,0 @@ -// ParserTest.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SharpMath.Expressions; - -namespace SharpMath.Tests -{ - [TestClass] - public class ParserTest - { - [TestMethod] - public void CanParseTerm() - { - var term = "(-2+3)*(cos(0)*(2/3))"; - Assert.AreEqual(2d / 3d, new Parser(term).Evaluate()); - - var secondTerm = "3*4^2"; - Assert.AreEqual(48, new Parser(secondTerm).Evaluate()); - } - } -} \ No newline at end of file diff --git a/SharpMath.Tests/Properties/AssemblyInfo.cs b/SharpMath.Tests/Properties/AssemblyInfo.cs deleted file mode 100644 index 97746f6..0000000 --- a/SharpMath.Tests/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,42 +0,0 @@ -// AssemblyInfo.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. - -[assembly: AssemblyTitle("SharpMath.Tests")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("SharpMath.Tests")] -[assembly: AssemblyCopyright("Copyright © 2015")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. - -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM - -[assembly: Guid("42b815a5-ddaa-4ded-bd69-fa212c1d0d46")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] - -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] \ No newline at end of file diff --git a/SharpMath.Tests/SharpMath.Tests.csproj b/SharpMath.Tests/SharpMath.Tests.csproj deleted file mode 100644 index 17acca0..0000000 --- a/SharpMath.Tests/SharpMath.Tests.csproj +++ /dev/null @@ -1,98 +0,0 @@ - - - - Debug - AnyCPU - {42B815A5-DDAA-4DED-BD69-FA212C1D0D46} - Library - Properties - SharpMath.Tests - SharpMath.Tests - v4.0 - 512 - {3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - 10.0 - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - $(ProgramFiles)\Common Files\microsoft shared\VSTT\$(VisualStudioVersion)\UITestExtensionPackages - False - UnitTest - - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - - - - - - - - - - - False - - - - - - - - - - - - - - - - - {4706ae0f-30c8-460b-bbbf-5f189be4eb76} - SharpMath - - - - - - - False - - - False - - - False - - - False - - - - - - - - \ No newline at end of file diff --git a/SharpMath.sln.GhostDoc.xml b/SharpMath.sln.GhostDoc.xml deleted file mode 100644 index 0c987e9..0000000 --- a/SharpMath.sln.GhostDoc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - *.min.js - jquery*.js - - diff --git a/SharpMath.slnx b/SharpMath.slnx new file mode 100644 index 0000000..0814907 --- /dev/null +++ b/SharpMath.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/SharpMath/Analysis/Domain.cs b/SharpMath/Analysis/Domain.cs deleted file mode 100644 index 840df40..0000000 --- a/SharpMath/Analysis/Domain.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Collections.Generic; - -namespace SharpMath.Analysis -{ - public struct Domain - { - public IEnumerable Exceptions { get; set; } - public Signs SignAreas { get; set; } - } -} diff --git a/SharpMath/Analysis/Function.cs b/SharpMath/Analysis/Function.cs deleted file mode 100644 index 4681958..0000000 --- a/SharpMath/Analysis/Function.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.Globalization; -using SharpMath.Expressions; - -namespace SharpMath.Analysis -{ - /// - /// Represents a mathematical function. - /// - public class Function - { - public Function(string term) - { - - } - - public string Term { get; set; } - - /// - /// Gets the poles of this . - /// - public double[] Poles { get; } - - /// - /// Gets the roots of this . - /// - public double[] Roots { get; } - - /// - /// Gets the of this . - /// - public Domain Domain { get; } - - /// - /// Calculates the derivation of this . - /// - /// The derivation of this . - public Function Derive() - { - return null; - } - - /// - /// Gets the slope of the tangent that touches the with the specified x-coordinate. - /// - /// The x-coordinate value of the the tangent touches. - /// The slope of the . - public double Derive(double xValue) - { - var parser = new Parser(Derive().Term.Replace("x", xValue.ToString(CultureInfo.InvariantCulture))); - return parser.Evaluate(); - } - } -} \ No newline at end of file diff --git a/SharpMath/Analysis/Interval.cs b/SharpMath/Analysis/Interval.cs deleted file mode 100644 index 45cdb2f..0000000 --- a/SharpMath/Analysis/Interval.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace SharpMath.Analysis -{ - public struct Interval - { - public Interval(IntervalValue leftSide, IntervalValue rightSide) - { - LeftSide = leftSide; - RightSide = rightSide; - } - - public IntervalValue LeftSide { get; set; } - public IntervalValue RightSide { get; set; } - - public override string ToString() - { - return $"{LeftSide.ValueOption.GetEnumDescription()}{LeftSide.Value};{RightSide.Value}{RightSide.ValueOption.GetEnumDescription()}"; - } - } -} \ No newline at end of file diff --git a/SharpMath/Analysis/IntervalValue.cs b/SharpMath/Analysis/IntervalValue.cs deleted file mode 100644 index 9e43f9b..0000000 --- a/SharpMath/Analysis/IntervalValue.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace SharpMath.Analysis -{ - public struct IntervalValue - { - public IntervalValue(double value, IntervalValueOption valueOption) - { - Value = value; - ValueOption = valueOption; - } - - public double Value { get; set; } - public IntervalValueOption ValueOption { get; set; } - } -} diff --git a/SharpMath/Analysis/IntervalValueOption.cs b/SharpMath/Analysis/IntervalValueOption.cs deleted file mode 100644 index 0e417ff..0000000 --- a/SharpMath/Analysis/IntervalValueOption.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.ComponentModel; - -namespace SharpMath.Analysis -{ - public enum IntervalValueOption - { - [Description("[")] - Include, - [Description("]")] - Exclude, - } -} \ No newline at end of file diff --git a/SharpMath/Analysis/IntervalValueOptionAttribute.cs b/SharpMath/Analysis/IntervalValueOptionAttribute.cs deleted file mode 100644 index 7950f90..0000000 --- a/SharpMath/Analysis/IntervalValueOptionAttribute.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; - -namespace SharpMath.Analysis -{ - public class IntervalValueOptionAttribute : Attribute - { - public IntervalValueOption ValueOption { get; set; } - - public IntervalValueOptionAttribute(IntervalValueOption valueOption) - { - ValueOption = valueOption; - } - } -} diff --git a/SharpMath/Analysis/Monotony.cs b/SharpMath/Analysis/Monotony.cs deleted file mode 100644 index 6678da3..0000000 --- a/SharpMath/Analysis/Monotony.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace SharpMath.Analysis -{ - public class Monotony - { - public Dictionary MonotonyIntervals { get; set; } = new Dictionary(); - - //public Monotony Determine(string term) - //{ - // Determine(new Function(term)); - //} - - //public Monotony Determine(Function function) - //{ - // var monotony = new Monotony(); - // var derivation = function.Derive(); - // double[] roots = derivation.Roots; - // double[] poles = derivation.Poles; - - // var specialValues = roots.ToList().Concat(poles).ToList(); - // double beginElement = double.NegativeInfinity; - // if ((derivation.Domain.SignAreas & Signs.Negative) != Signs.Negative) - // { - // beginElement = specialValues.Min(); - // specialValues.Remove(beginElement); - // } - - // // If the value is a root it can neither be a pole, nor infinity and must be included. - // var determineIntervalValueOption = new Func(d => roots.Contains(d) ? IntervalValueOption.Include : IntervalValueOption.Exclude); - // foreach (var value in specialValues) - // { - // var interval = - // new Interval(new IntervalValue(beginElement, determineIntervalValueOption(beginElement)), - // new IntervalValue(value, determineIntervalValueOption(value))); - // double difference = 0; - - // beginElement = value; - // } - //} - } -} \ No newline at end of file diff --git a/SharpMath/Analysis/MonotonyType.cs b/SharpMath/Analysis/MonotonyType.cs deleted file mode 100644 index 9e1fdb9..0000000 --- a/SharpMath/Analysis/MonotonyType.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace SharpMath.Analysis -{ - public enum MonotonyType - { - MonotonicallyIncreasing, - StrictlyMonotonicallyIncreasing, - MonotonicallyDecreasing, - StrictlyMonotonicallyDecreasing, - } -} \ No newline at end of file diff --git a/SharpMath/Analysis/Rule.cs b/SharpMath/Analysis/Rule.cs deleted file mode 100644 index 33e73a6..0000000 --- a/SharpMath/Analysis/Rule.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Collections.Generic; - -namespace SharpMath.Analysis -{ - public class Rule - { - - } -} \ No newline at end of file diff --git a/SharpMath/Analysis/RuleSet/IRuleSet.cs b/SharpMath/Analysis/RuleSet/IRuleSet.cs deleted file mode 100644 index df985b1..0000000 --- a/SharpMath/Analysis/RuleSet/IRuleSet.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Collections.Generic; - -namespace SharpMath.Analysis.RuleSet -{ - public interface IRuleSet - { - IEnumerable Rules { get; set; } - } -} \ No newline at end of file diff --git a/SharpMath/Analysis/Signs.cs b/SharpMath/Analysis/Signs.cs deleted file mode 100644 index c0a6fa5..0000000 --- a/SharpMath/Analysis/Signs.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; - -namespace SharpMath.Analysis -{ - [Flags] - public enum Signs - { - Positive, - Negative, - } -} diff --git a/SharpMath/Properties/AssemblyInfo.cs b/SharpMath/Properties/AssemblyInfo.cs deleted file mode 100644 index 3a24fd8..0000000 --- a/SharpMath/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,44 +0,0 @@ -// AssemblyInfo.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. - -[assembly: AssemblyTitle("SharpMath")] -[assembly: - AssemblyDescription( - "A C# math library supporting vectors, matrices, linear equation systems and expression parsing.")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("SharpMath")] -[assembly: AssemblyCopyright("Copyright © Dominic Beger (Trade/ProgTrade) 2015-2016")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. - -[assembly: ComVisible(true)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM - -[assembly: Guid("4706ae0f-30c8-460b-bbbf-5f189be4eb76")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] - -[assembly: AssemblyVersion("1.14.7.0")] -[assembly: AssemblyFileVersion("1.14.7.0")] \ No newline at end of file diff --git a/SharpMath/SharpMath.cd b/SharpMath/SharpMath.cd deleted file mode 100644 index 072b6de..0000000 --- a/SharpMath/SharpMath.cd +++ /dev/null @@ -1,316 +0,0 @@ - - - - - - AAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= - Algorithms.cs - - - - - - AAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAICAAAA= - Extensions.cs - - - - - - AAAABAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAA= - FloatingNumber.cs - - - - - - AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAA= - FloatingNumberExtensions.cs - - - - - - CAAAJAAAAAAAAAAQBAACAAAAAAQAAAAAAAEAAgAAAAA= - MathHelper.cs - - - - - - AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= - Equations\Exceptions\EquationNotSolvableException.cs - - - - - - AAAAAAAAAAAAAAAAAAAAAAQAAEAAAAAAAAAAAAAAAAA= - Equations\LinearEquation.cs - - - - - - AAAAAAAAAAAAAAAAAAAAAAAIEAAAAAAAAAAAAAAAAAA= - Equations\LinearEquationSystem.cs - - - - - - AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAICAAAA= - Expressions\CharEx.cs - - - - - - AAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAQAAAAAAAAAAA= - Expressions\Parser.cs - - - - - - AAAEAAAAAAAAAIAAAAEAAgAAIAAAAAAAEQAAAAAAAAA= - Expressions\Token.cs - - - - - - AAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAQgAAAAAAA= - Expressions\Token.cs - - - - - - AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= - Expressions\Exceptions\ParserException.cs - - - - - - AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= - Geometry\Exceptions\DimensionException.cs - - - - - - AAAAAAAAACAABAAAAACgAAAAAAAAAAAAoAAAAQAAAAA= - Geometry\MatrixEnumerator.cs - - - - - - - ACZCQAAAAAAAAACAIAADCAAAAAAEACAAAAAAICAADBA= - Geometry\MatrixUtils.cs - - - - - - AAAAAAAAAQAAgBAEgAAAAAAAACAAAIAEAACAAQAYAjA= - Geometry\Polygon.cs - - - - - - - AAAAAAAAAAAAAAAACACAAAAAQAAAAAAAAAAAAAAAAgA= - Geometry\VariableMatrix.cs - - - - - - - AAIAQAAAAQAEACAAEYADAAAAQABAAABAgEAEAAEAABA= - Geometry\VectorUtils.cs - - - - - - AAgAADACAJABAACQAAASAAIAAAQAIAIAEABAAAJAAgA= - Presentation\Canvas2D.cs - - - - - - AAAAAAAAIAAAAAAAAAAgAAAAAAAAAAAAAAAgAAAAAAA= - Presentation\FunctionWrapper.cs - - - - - - AAAAIIAAAMAgAAAEgAAAAAAAAAAQIIAAAAAAAAAAgmA= - Geometry\Line2D.cs - - - - - - - AAAAIAAAAIAggAAEgCAAAAAAAAAAAIAAAAAAAAAAAiA= - Geometry\Line3D.cs - - - - - - - AACQAAAgAAAAABAA6CMAAAAAQQIASLAEAEAEgAgAAiQ= - Geometry\Matrix1x1.cs - - - - - - - AAAAAAAAAAAAABAACAAAAAAAQAAAABAEAAAAAAAAAgA= - Geometry\Matrix1x3.cs - - - - - - - AAAAAAAAAAAAABAACAAAAAAAQAAAABAEAAAAAAAAAgA= - Geometry\Matrix1x4.cs - - - - - - - AACQAAAgAAAAABAA6CMAACAAQQIASLAEAEAEgAgAAiQ= - Geometry\Matrix2x2.cs - - - - - - - AABAAAAAAAAAABAACAAAACAAQAAAABAEAAAAAAAAAgA= - Geometry\Matrix3x1.cs - - - - - - - AATQAAAgAAAAAJAC6CMAACAAQQIASLAMAEAEgAgAAiQ= - Geometry\Matrix3x3.cs - - - - - - - AABAAAAAAAAAABAACAAAACAAQAAAABAEAAAAAAgAAgA= - Geometry\Matrix4x1.cs - - - - - - - AATQAAAgABAAAJAC6CMAACAAQQIBSLAMAEAEgAgAAiQ= - Geometry\Matrix4x4.cs - - - - - - - AAAAAAAAAAAAABAEyAEAQAAAAAAAAIAEAAAAAAAAYiA= - Geometry\Point2D.cs - - - - - - - AAAAAAAAAAAAABAEyAEAQAAAAAAAAIAEAAAAAAAAYiE= - Geometry\Point3D.cs - - - - - - - AAAAAAAAAAAAAAAABkAAIAQAAAAAAAAiAAAAAAAAAAA= - Geometry\ProjectionData.cs - - - - - - AAAAYAIAAQAAgBAE6AEAQQQAAQBAQIhEiECAAQGAcuI= - Geometry\Vector2.cs - - - - - - - AQAAIgIAAAAAgBAE6AEAQQQAAQBAQIgEiBCAAQGAYqM= - Geometry\Vector3.cs - - - - - - - AAAAIgAAAAAAABAEiAABQQQAAQAAQIgEgAAAAAGIYqE= - Geometry\Vector4.cs - - - - - - - AAAAAAAAAAAAAAAACAAAAAAAQAAAAAAAAAAAAAAAAgA= - Geometry\IMatrix.cs - - - - - - AACAAAAgAAAAAAAACCAAAAAAAAIASDAAAAAAgAAAAAQ= - Geometry\ISquareMatrix.cs - - - - - - AAAQAAAAAAAAAAAAACIAAAAAAAAAAAAAAAAEAAgAAAA= - Geometry\ISquareMatrix.cs - - - - - - AAAAAAAAAAAAAAAACAAAAAAAAAAAQAgAgAAAAAEAACA= - Geometry\IVector.cs - - - - - - AAAAEAAAAAAAAAAAAEAAAAIAAAAAAAAAAAAAAAAAIAA= - Expressions\TokenType.cs - - - - \ No newline at end of file diff --git a/SharpMath/SharpMath.csproj b/SharpMath/SharpMath.csproj deleted file mode 100644 index bdafff6..0000000 --- a/SharpMath/SharpMath.csproj +++ /dev/null @@ -1,97 +0,0 @@ - - - - - Debug - AnyCPU - {4706AE0F-30C8-460B-BBBF-5F189BE4EB76} - Library - Properties - SharpMath - SharpMath - v4.0 - 512 - - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Component - - - - - - - - - - - - - - \ No newline at end of file diff --git a/SharpMath/SharpMath.csproj.DotSettings b/SharpMath/SharpMath.csproj.DotSettings deleted file mode 100644 index 827beb4..0000000 --- a/SharpMath/SharpMath.csproj.DotSettings +++ /dev/null @@ -1,2 +0,0 @@ - - Experimental \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..1d364c6 --- /dev/null +++ b/global.json @@ -0,0 +1,9 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..0f62a7a --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,18 @@ + + + + + true + true + README.md + true + snupkg + true + true + + + + + + + diff --git a/SharpMath/Presentation/Canvas2D.cs b/src/SharpMath.Canvas.WinForms/Legacy/Canvas2D.cs similarity index 100% rename from SharpMath/Presentation/Canvas2D.cs rename to src/SharpMath.Canvas.WinForms/Legacy/Canvas2D.cs diff --git a/SharpMath/Presentation/FunctionWrapper.cs b/src/SharpMath.Canvas.WinForms/Legacy/FunctionWrapper.cs similarity index 100% rename from SharpMath/Presentation/FunctionWrapper.cs rename to src/SharpMath.Canvas.WinForms/Legacy/FunctionWrapper.cs diff --git a/src/SharpMath.Canvas.WinForms/SharpMath.Canvas.WinForms.csproj b/src/SharpMath.Canvas.WinForms/SharpMath.Canvas.WinForms.csproj new file mode 100644 index 0000000..5b44dbe --- /dev/null +++ b/src/SharpMath.Canvas.WinForms/SharpMath.Canvas.WinForms.csproj @@ -0,0 +1,20 @@ + + + + net10.0-windows + true + true + WinForms host and GDI+ renderer for the SharpMath 2D canvas. + + disable + false + none + false + $(NoWarn);CS1591;WFO1000 + + + + + + + diff --git a/SharpMath/Algorithms.cs b/src/SharpMath/Algorithms.cs similarity index 100% rename from SharpMath/Algorithms.cs rename to src/SharpMath/Algorithms.cs diff --git a/SharpMath/Equations/Exceptions/EquationNotSolvableException.cs b/src/SharpMath/Equations/Exceptions/EquationNotSolvableException.cs similarity index 100% rename from SharpMath/Equations/Exceptions/EquationNotSolvableException.cs rename to src/SharpMath/Equations/Exceptions/EquationNotSolvableException.cs diff --git a/SharpMath/Equations/LinearEquation.cs b/src/SharpMath/Equations/LinearEquation.cs similarity index 100% rename from SharpMath/Equations/LinearEquation.cs rename to src/SharpMath/Equations/LinearEquation.cs diff --git a/SharpMath/Equations/LinearEquationSystem.cs b/src/SharpMath/Equations/LinearEquationSystem.cs similarity index 100% rename from SharpMath/Equations/LinearEquationSystem.cs rename to src/SharpMath/Equations/LinearEquationSystem.cs diff --git a/SharpMath/Expressions/CharEx.cs b/src/SharpMath/Expressions/CharEx.cs similarity index 100% rename from SharpMath/Expressions/CharEx.cs rename to src/SharpMath/Expressions/CharEx.cs diff --git a/SharpMath/Expressions/Exceptions/ParserException.cs b/src/SharpMath/Expressions/Exceptions/ParserException.cs similarity index 100% rename from SharpMath/Expressions/Exceptions/ParserException.cs rename to src/SharpMath/Expressions/Exceptions/ParserException.cs diff --git a/SharpMath/Expressions/Parser.cs b/src/SharpMath/Expressions/Parser.cs similarity index 100% rename from SharpMath/Expressions/Parser.cs rename to src/SharpMath/Expressions/Parser.cs diff --git a/SharpMath/Expressions/Token.cs b/src/SharpMath/Expressions/Token.cs similarity index 100% rename from SharpMath/Expressions/Token.cs rename to src/SharpMath/Expressions/Token.cs diff --git a/SharpMath/Expressions/TokenEx.cs b/src/SharpMath/Expressions/TokenEx.cs similarity index 100% rename from SharpMath/Expressions/TokenEx.cs rename to src/SharpMath/Expressions/TokenEx.cs diff --git a/SharpMath/Expressions/TokenType.cs b/src/SharpMath/Expressions/TokenType.cs similarity index 100% rename from SharpMath/Expressions/TokenType.cs rename to src/SharpMath/Expressions/TokenType.cs diff --git a/SharpMath/Extensions.cs b/src/SharpMath/Extensions.cs similarity index 100% rename from SharpMath/Extensions.cs rename to src/SharpMath/Extensions.cs diff --git a/SharpMath/FloatingNumber.cs b/src/SharpMath/FloatingNumber.cs similarity index 100% rename from SharpMath/FloatingNumber.cs rename to src/SharpMath/FloatingNumber.cs diff --git a/SharpMath/FloatingNumberExtensions.cs b/src/SharpMath/FloatingNumberExtensions.cs similarity index 100% rename from SharpMath/FloatingNumberExtensions.cs rename to src/SharpMath/FloatingNumberExtensions.cs diff --git a/SharpMath/Geometry/Exceptions/DimensionException.cs b/src/SharpMath/Geometry/Exceptions/DimensionException.cs similarity index 100% rename from SharpMath/Geometry/Exceptions/DimensionException.cs rename to src/SharpMath/Geometry/Exceptions/DimensionException.cs diff --git a/SharpMath/Geometry/IMatrix.cs b/src/SharpMath/Geometry/IMatrix.cs similarity index 100% rename from SharpMath/Geometry/IMatrix.cs rename to src/SharpMath/Geometry/IMatrix.cs diff --git a/SharpMath/Geometry/ISquareMatrix.cs b/src/SharpMath/Geometry/ISquareMatrix.cs similarity index 100% rename from SharpMath/Geometry/ISquareMatrix.cs rename to src/SharpMath/Geometry/ISquareMatrix.cs diff --git a/SharpMath/Geometry/IVector.cs b/src/SharpMath/Geometry/IVector.cs similarity index 100% rename from SharpMath/Geometry/IVector.cs rename to src/SharpMath/Geometry/IVector.cs diff --git a/SharpMath/Geometry/Line2D.cs b/src/SharpMath/Geometry/Line2D.cs similarity index 100% rename from SharpMath/Geometry/Line2D.cs rename to src/SharpMath/Geometry/Line2D.cs diff --git a/SharpMath/Geometry/Line3D.cs b/src/SharpMath/Geometry/Line3D.cs similarity index 100% rename from SharpMath/Geometry/Line3D.cs rename to src/SharpMath/Geometry/Line3D.cs diff --git a/SharpMath/Geometry/Matrix1x1.cs b/src/SharpMath/Geometry/Matrix1x1.cs similarity index 100% rename from SharpMath/Geometry/Matrix1x1.cs rename to src/SharpMath/Geometry/Matrix1x1.cs diff --git a/SharpMath/Geometry/Matrix1x3.cs b/src/SharpMath/Geometry/Matrix1x3.cs similarity index 100% rename from SharpMath/Geometry/Matrix1x3.cs rename to src/SharpMath/Geometry/Matrix1x3.cs diff --git a/SharpMath/Geometry/Matrix1x4.cs b/src/SharpMath/Geometry/Matrix1x4.cs similarity index 100% rename from SharpMath/Geometry/Matrix1x4.cs rename to src/SharpMath/Geometry/Matrix1x4.cs diff --git a/SharpMath/Geometry/Matrix2x2.cs b/src/SharpMath/Geometry/Matrix2x2.cs similarity index 100% rename from SharpMath/Geometry/Matrix2x2.cs rename to src/SharpMath/Geometry/Matrix2x2.cs diff --git a/SharpMath/Geometry/Matrix3x1.cs b/src/SharpMath/Geometry/Matrix3x1.cs similarity index 100% rename from SharpMath/Geometry/Matrix3x1.cs rename to src/SharpMath/Geometry/Matrix3x1.cs diff --git a/SharpMath/Geometry/Matrix3x3.cs b/src/SharpMath/Geometry/Matrix3x3.cs similarity index 100% rename from SharpMath/Geometry/Matrix3x3.cs rename to src/SharpMath/Geometry/Matrix3x3.cs diff --git a/SharpMath/Geometry/Matrix4x1.cs b/src/SharpMath/Geometry/Matrix4x1.cs similarity index 100% rename from SharpMath/Geometry/Matrix4x1.cs rename to src/SharpMath/Geometry/Matrix4x1.cs diff --git a/SharpMath/Geometry/Matrix4x4.cs b/src/SharpMath/Geometry/Matrix4x4.cs similarity index 100% rename from SharpMath/Geometry/Matrix4x4.cs rename to src/SharpMath/Geometry/Matrix4x4.cs diff --git a/SharpMath/Geometry/MatrixEnumerator.cs b/src/SharpMath/Geometry/MatrixEnumerator.cs similarity index 100% rename from SharpMath/Geometry/MatrixEnumerator.cs rename to src/SharpMath/Geometry/MatrixEnumerator.cs diff --git a/SharpMath/Geometry/MatrixUtils.cs b/src/SharpMath/Geometry/MatrixUtils.cs similarity index 100% rename from SharpMath/Geometry/MatrixUtils.cs rename to src/SharpMath/Geometry/MatrixUtils.cs diff --git a/SharpMath/Geometry/Point2D.cs b/src/SharpMath/Geometry/Point2D.cs similarity index 100% rename from SharpMath/Geometry/Point2D.cs rename to src/SharpMath/Geometry/Point2D.cs diff --git a/SharpMath/Geometry/Point3D.cs b/src/SharpMath/Geometry/Point3D.cs similarity index 100% rename from SharpMath/Geometry/Point3D.cs rename to src/SharpMath/Geometry/Point3D.cs diff --git a/SharpMath/Geometry/Polygon.cs b/src/SharpMath/Geometry/Polygon.cs similarity index 100% rename from SharpMath/Geometry/Polygon.cs rename to src/SharpMath/Geometry/Polygon.cs diff --git a/SharpMath/Geometry/ProjectionData.cs b/src/SharpMath/Geometry/ProjectionData.cs similarity index 100% rename from SharpMath/Geometry/ProjectionData.cs rename to src/SharpMath/Geometry/ProjectionData.cs diff --git a/SharpMath/Geometry/VariableMatrix.cs b/src/SharpMath/Geometry/VariableMatrix.cs similarity index 100% rename from SharpMath/Geometry/VariableMatrix.cs rename to src/SharpMath/Geometry/VariableMatrix.cs diff --git a/SharpMath/Geometry/Vector2.cs b/src/SharpMath/Geometry/Vector2.cs similarity index 100% rename from SharpMath/Geometry/Vector2.cs rename to src/SharpMath/Geometry/Vector2.cs diff --git a/SharpMath/Geometry/Vector3.cs b/src/SharpMath/Geometry/Vector3.cs similarity index 100% rename from SharpMath/Geometry/Vector3.cs rename to src/SharpMath/Geometry/Vector3.cs diff --git a/SharpMath/Geometry/Vector4.cs b/src/SharpMath/Geometry/Vector4.cs similarity index 100% rename from SharpMath/Geometry/Vector4.cs rename to src/SharpMath/Geometry/Vector4.cs diff --git a/SharpMath/Geometry/VectorUtils.cs b/src/SharpMath/Geometry/VectorUtils.cs similarity index 100% rename from SharpMath/Geometry/VectorUtils.cs rename to src/SharpMath/Geometry/VectorUtils.cs diff --git a/SharpMath/MathHelper.cs b/src/SharpMath/MathHelper.cs similarity index 100% rename from SharpMath/MathHelper.cs rename to src/SharpMath/MathHelper.cs diff --git a/src/SharpMath/SharpMath.csproj b/src/SharpMath/SharpMath.csproj new file mode 100644 index 0000000..4faf450 --- /dev/null +++ b/src/SharpMath/SharpMath.csproj @@ -0,0 +1,13 @@ + + + + A .NET math library: vectors, matrices, lines, polygons, linear equation systems and an expression parser. + + disable + false + none + false + $(NoWarn);CS1591 + + + diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 0000000..bc58e1b --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,16 @@ + + + + + Exe + true + + $(NoWarn);CA1707;CA1305;CA1307;CA1310;CA1852;CA1861 + + + + + + + + diff --git a/SharpMath.Tests/Line2DTest.cs b/tests/SharpMath.Tests/Line2DTest.cs similarity index 51% rename from SharpMath.Tests/Line2DTest.cs rename to tests/SharpMath.Tests/Line2DTest.cs index 35ed602..cb421cc 100644 --- a/SharpMath.Tests/Line2DTest.cs +++ b/tests/SharpMath.Tests/Line2DTest.cs @@ -1,59 +1,54 @@ -// Line2DTest.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using Microsoft.VisualStudio.TestTools.UnitTesting; using SharpMath.Geometry; namespace SharpMath.Tests { - [TestClass] public class Line2DTest { - [TestMethod] + [Fact] public void CanCreateLineFromPoints() { var line = Line2D.FromPoints(new Point2D(0, 2), new Point2D(3, 1)); - Assert.AreEqual(-1d / 3d, line.Slope); - Assert.AreEqual(2, line.Offset); + Assert.Equal(-1d / 3d, line.Slope); + Assert.Equal(2, line.Offset); var secondLine = Line2D.FromPoints(new Point2D(4, 5), new Point2D(1, 3)); - Assert.AreEqual(2d / 3d, secondLine.Slope); - Assert.AreEqual(7d / 3d, secondLine.Offset); + Assert.Equal(2d / 3d, secondLine.Slope); + Assert.Equal(7d / 3d, secondLine.Offset); } - [TestMethod] + [Fact] public void CanDetermineIfLinesAreParallel() { var line = new Line2D(2, 0); var secondLine = new Line2D(2, 3); var thirdLine = new Line2D(3, 1); - Assert.IsTrue(line.IsParallelTo(secondLine)); - Assert.IsFalse(secondLine.IsParallelTo(thirdLine)); + Assert.True(line.IsParallelTo(secondLine)); + Assert.False(secondLine.IsParallelTo(thirdLine)); } - [TestMethod] + [Fact] public void CanDetermineIfLinesIntersect() { var line = new Line2D(2, 0); var secondLine = new Line2D(3, 1); var thirdLine = new Line2D(2, 3); - Assert.IsTrue(line.IntersectsWith(secondLine)); - Assert.IsFalse(line.IntersectsWith(thirdLine)); + Assert.True(line.IntersectsWith(secondLine)); + Assert.False(line.IntersectsWith(thirdLine)); } - [TestMethod] + [Fact] public void CanDetermineIfPointIsOnLine() { var line = new Line2D(1, 0); - Assert.IsTrue(line.IsPointOnLine(new Point2D(1, 1))); - Assert.IsFalse(line.IsPointOnLine(new Point2D(2, 1))); + Assert.True(line.IsPointOnLine(new Point2D(1, 1))); + Assert.False(line.IsPointOnLine(new Point2D(2, 1))); var secondLine = new Line2D(2, 3); - Assert.IsTrue(secondLine.IsPointOnLine(new Point2D(4, 11))); - Assert.IsFalse(secondLine.IsPointOnLine(new Point2D(3, 2))); + Assert.True(secondLine.IsPointOnLine(new Point2D(4, 11))); + Assert.False(secondLine.IsPointOnLine(new Point2D(3, 2))); } - [TestMethod] + [Fact] public void CanFindIntersectionPoint() { // 0.5x - 1 @@ -62,24 +57,24 @@ public void CanFindIntersectionPoint() var secondLine = new Line2D(-2, -6); var intersectionPoint = firstLine.GetIntersectionPoint(secondLine); - Assert.AreEqual(-2, intersectionPoint.X); - Assert.AreEqual(-2, intersectionPoint.Y); + Assert.Equal(-2, intersectionPoint.X); + Assert.Equal(-2, intersectionPoint.Y); } - [TestMethod] + [Fact] public void CanGetIntersectionPoint() { var line = new Line2D(2, 0); var secondLine = new Line2D(3, 1); - Assert.AreEqual(new Point2D(-1, -2), line.GetIntersectionPoint(secondLine)); + Assert.Equal(new Point2D(-1, -2), line.GetIntersectionPoint(secondLine)); } - [TestMethod] + [Fact] public void CanGetPoint() { var line = new Line2D(3, 1); - Assert.AreEqual(new Point2D(1, 4), line.GetPoint(1)); - Assert.AreEqual(new Point2D(10, 31), line.GetPoint(10)); + Assert.Equal(new Point2D(1, 4), line.GetPoint(1)); + Assert.Equal(new Point2D(10, 31), line.GetPoint(10)); } } } \ No newline at end of file diff --git a/tests/SharpMath.Tests/Line3DTest.cs b/tests/SharpMath.Tests/Line3DTest.cs new file mode 100644 index 0000000..832ebd5 --- /dev/null +++ b/tests/SharpMath.Tests/Line3DTest.cs @@ -0,0 +1,34 @@ +using SharpMath.Geometry; + +namespace SharpMath.Tests +{ + public class Line3DTest + { + [Fact] + public void CanCreateLineFromPoints() + { + var line = Line3D.FromPoints(new Point3D(Vector3.Zero), new Point3D(Vector3.One)); + Assert.Equal(new Point3D(Vector3.Zero), line.Point); + Assert.Equal(Vector3.One, line.Direction); + + var secondLine = Line3D.FromPoints(new Point3D(2, 3, 1), new Point3D(3, 2, 4)); + Assert.Equal(new Point3D(2, 3, 1), secondLine.Point); + Assert.Equal(new Vector3(1, -1, 3), secondLine.Direction); + } + + [Fact] + public void CanDetermineIfPointIsOnLine() + { + var line = new Line3D(new Point3D(2, 3, 1), new Vector3(2, 1, 2)); + Assert.True(line.IsPointOnLine(new Point3D(6, 5, 5))); + Assert.False(line.IsPointOnLine(new Point3D(2, 3.5, 2))); + } + + [Fact] + public void CanGetPoint() + { + var line = new Line3D(new Point3D(2, 3, 1), new Vector3(2, 1, 2)); + Assert.Equal(new Point3D(6, 5, 5), line.GetPoint(2)); + } + } +} \ No newline at end of file diff --git a/SharpMath.Tests/LinearEquationTest.cs b/tests/SharpMath.Tests/LinearEquationTest.cs similarity index 60% rename from SharpMath.Tests/LinearEquationTest.cs rename to tests/SharpMath.Tests/LinearEquationTest.cs index 43042a6..218f4a1 100644 --- a/SharpMath.Tests/LinearEquationTest.cs +++ b/tests/SharpMath.Tests/LinearEquationTest.cs @@ -1,26 +1,23 @@ -// LinearEquationTest.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using Microsoft.VisualStudio.TestTools.UnitTesting; using SharpMath.Equations; using SharpMath.Equations.Exceptions; namespace SharpMath.Tests { - [TestClass] public class LinearEquationTest { - [TestMethod] - [ExpectedException(typeof(EquationNotSolvableException))] + [Fact] public void CanNotSolveLinearEquationSystemClearly() { - // 0x = 0 - infinite solutions - var equation = new LinearEquation(new[] {0.0}, 0); - var results = new LinearEquationSystem(equation).Solve(); // This will throw an exception - Assert.AreEqual(0, results[0]); + Assert.Throws(() => + { + // 0x = 0 - infinite solutions + var equation = new LinearEquation(new[] {0.0}, 0); + var results = new LinearEquationSystem(equation).Solve(); // This will throw an exception + Assert.Equal(0, results[0]); + }); } - [TestMethod] + [Fact] public void CanSolveLinearEquationSystem() { // x - y + 2z = 6 @@ -32,12 +29,12 @@ public void CanSolveLinearEquationSystem() var linearEquationSystem = new LinearEquationSystem(firstEquation, secondEquation, thirdEquation); var results = linearEquationSystem.Solve(); - Assert.AreEqual(1, results[0]); - Assert.AreEqual(1, results[1]); - Assert.AreEqual(3, results[2]); + Assert.Equal(1, results[0]); + Assert.Equal(1, results[1]); + Assert.Equal(3, results[2]); } - [TestMethod] + [Fact] public void CanSolveSimpleLinearEquationSystem() { // 2x = 1 @@ -47,7 +44,7 @@ public void CanSolveSimpleLinearEquationSystem() Result = 1 }; var linearEquationSystem = new LinearEquationSystem(equation); - Assert.AreEqual(0.5, linearEquationSystem.Solve()[0]); + Assert.Equal(0.5, linearEquationSystem.Solve()[0]); } } } \ No newline at end of file diff --git a/SharpMath.Tests/MatrixTest.cs b/tests/SharpMath.Tests/MatrixTest.cs similarity index 81% rename from SharpMath.Tests/MatrixTest.cs rename to tests/SharpMath.Tests/MatrixTest.cs index 8a2a2c6..4208472 100644 --- a/SharpMath.Tests/MatrixTest.cs +++ b/tests/SharpMath.Tests/MatrixTest.cs @@ -1,20 +1,15 @@ -// MatrixTest.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - using System.Diagnostics; -using Microsoft.VisualStudio.TestTools.UnitTesting; using SharpMath.Geometry; namespace SharpMath.Tests { - [TestClass] public class MatrixTest { - [TestMethod] + [Fact] public void CanCalculateDeterminant() { var firstMatrix = new Matrix1x1 {[0, 0] = 2}; - Assert.AreEqual(2, firstMatrix.Determinant); + Assert.Equal(2, firstMatrix.Determinant); var secondMatrix = new Matrix2x2 { @@ -23,7 +18,7 @@ public void CanCalculateDeterminant() [1, 0] = 4, [1, 1] = 2 }; - Assert.AreEqual(4, secondMatrix.Determinant); + Assert.Equal(4, secondMatrix.Determinant); var thirdMatrix = new Matrix3x3 { @@ -38,7 +33,7 @@ public void CanCalculateDeterminant() [2, 2] = -4 }; - Assert.AreEqual(111, thirdMatrix.Determinant); + Assert.Equal(111, thirdMatrix.Determinant); var fourthMatrix = new Matrix3x3 { @@ -53,7 +48,7 @@ public void CanCalculateDeterminant() [2, 2] = -9 }; - Assert.AreEqual(566.5, fourthMatrix.Determinant); + Assert.Equal(566.5, fourthMatrix.Determinant); var fifthMatrix = new Matrix4x4 { @@ -75,10 +70,10 @@ public void CanCalculateDeterminant() [3, 3] = -2 }; - Assert.AreEqual(-24, fifthMatrix.Determinant); + Assert.Equal(-24, fifthMatrix.Determinant); } - [TestMethod] + [Fact] public void CanDetermineIfMatrixIsDiagonal() { var matrix = new Matrix3x3 @@ -87,7 +82,7 @@ public void CanDetermineIfMatrixIsDiagonal() M22 = 1, M33 = 1 }; - Assert.IsTrue(matrix.IsDiagonal); + Assert.True(matrix.IsDiagonal); var secondMatrix = new Matrix4x4 { @@ -96,7 +91,7 @@ public void CanDetermineIfMatrixIsDiagonal() M33 = 1, M44 = 1 }; - Assert.IsTrue(secondMatrix.IsDiagonal); + Assert.True(secondMatrix.IsDiagonal); var thirdMatrix = new Matrix4x4 { @@ -106,7 +101,7 @@ public void CanDetermineIfMatrixIsDiagonal() M44 = 1, M14 = 1 }; - Assert.IsFalse(thirdMatrix.IsDiagonal); + Assert.False(thirdMatrix.IsDiagonal); var fourthMatrix = new Matrix4x4 { @@ -114,10 +109,10 @@ public void CanDetermineIfMatrixIsDiagonal() M22 = 1, M33 = 1 }; - Assert.IsFalse(fourthMatrix.IsDiagonal); + Assert.False(fourthMatrix.IsDiagonal); } - [TestMethod] + [Fact] public void CanDetermineIfMatrixIsTriangle() { var matrix = new Matrix3x3 @@ -129,7 +124,7 @@ public void CanDetermineIfMatrixIsTriangle() M13 = 4, M23 = 7 }; - Assert.IsTrue(matrix.IsTriangle); + Assert.True(matrix.IsTriangle); var secondMatrix = new Matrix4x4 { @@ -144,7 +139,7 @@ public void CanDetermineIfMatrixIsTriangle() M24 = 3, M34 = 9 }; - Assert.IsTrue(secondMatrix.IsTriangle); + Assert.True(secondMatrix.IsTriangle); var thirdMatrix = new Matrix4x4 { @@ -155,7 +150,7 @@ public void CanDetermineIfMatrixIsTriangle() M14 = 2, M31 = 9 }; - Assert.IsFalse(thirdMatrix.IsTriangle); + Assert.False(thirdMatrix.IsTriangle); var fourthMatrix = new Matrix4x4 { @@ -164,17 +159,17 @@ public void CanDetermineIfMatrixIsTriangle() M33 = 1, M44 = 1 }; - Assert.IsTrue(fourthMatrix.IsTriangle); + Assert.True(fourthMatrix.IsTriangle); } - [TestMethod] + [Fact] public void CanGetStringRepresentation() { var matrix = Matrix4x4.View(Vector3.Zero, Vector3.Forward, Vector3.Up); Debug.Print(matrix.ToString()); } - [TestMethod] + [Fact] public void CanMultiplyMatrices() { var firstMatrix = new Matrix1x3(); @@ -189,7 +184,7 @@ public void CanMultiplyMatrices() secondMatrix[2, 0] = 2; var matrixProduct = MatrixUtils.Multiply(firstMatrix, secondMatrix); - Assert.AreEqual(matrixProduct[0, 0], 10); + Assert.Equal(matrixProduct[0, 0], 10); // -------------------------------- @@ -237,20 +232,20 @@ 35.000 55.000 8.000 [2, 2] = 55 }; - Assert.AreEqual(resultMatrix, MatrixUtils.Multiply(thirdMatrix, fourthMatrix)); + Assert.Equal(resultMatrix, MatrixUtils.Multiply(thirdMatrix, fourthMatrix)); } - [TestMethod] + [Fact] public void CanMultiplyMatrix3X3WithVector2() { var translation = Matrix3x3.Translation(10, 10); var transformed = translation * Vector2.One; var result = new Vector2(11, 11); - Assert.AreEqual(result, transformed); + Assert.Equal(result, transformed); } // TODO: Implement - //[TestMethod] + //[Fact] //public void CanGetCore() //{ // var matrix = new Matrix3x3 @@ -263,7 +258,7 @@ public void CanMultiplyMatrix3X3WithVector2() // M23 = 5, // }; - // Assert.AreEqual(new Vector3(-3, -5, 1), matrix.GetCore()); + // Assert.Equal(new Vector3(-3, -5, 1), matrix.GetCore()); //} } } \ No newline at end of file diff --git a/tests/SharpMath.Tests/ParserTest.cs b/tests/SharpMath.Tests/ParserTest.cs new file mode 100644 index 0000000..5ddb21e --- /dev/null +++ b/tests/SharpMath.Tests/ParserTest.cs @@ -0,0 +1,17 @@ +using SharpMath.Expressions; + +namespace SharpMath.Tests +{ + public class ParserTest + { + [Fact] + public void CanParseTerm() + { + var term = "(-2+3)*(cos(0)*(2/3))"; + Assert.Equal(2d / 3d, new Parser(term).Evaluate()); + + var secondTerm = "3*4^2"; + Assert.Equal(48, new Parser(secondTerm).Evaluate()); + } + } +} \ No newline at end of file diff --git a/SharpMath.Tests/PolygonTest.cs b/tests/SharpMath.Tests/PolygonTest.cs similarity index 60% rename from SharpMath.Tests/PolygonTest.cs rename to tests/SharpMath.Tests/PolygonTest.cs index 85a417b..6814faa 100644 --- a/SharpMath.Tests/PolygonTest.cs +++ b/tests/SharpMath.Tests/PolygonTest.cs @@ -1,48 +1,45 @@ -// PolygonTest.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - using System; -using Microsoft.VisualStudio.TestTools.UnitTesting; using SharpMath.Geometry; namespace SharpMath.Tests { - [TestClass] public class PolygonTest { - [TestMethod] + [Fact] public void CanCalculateArea() { var square = new Polygon(new Point2D(0, 0), new Point2D(2, 0), new Point2D(2, 2), new Point2D(0, 2)); - Assert.IsTrue(FloatingNumber.AreApproximatelyEqual(4, square.Area)); + Assert.True(FloatingNumber.AreApproximatelyEqual(4, square.Area)); var triangle = new Polygon(new Point2D(0, 0), new Point2D(2, 0), new Point2D(1, 2)); - Assert.AreEqual(2, triangle.Area); + Assert.Equal(2, triangle.Area); var polygon = new Polygon(new Point2D(-2, -2), new Point2D(2, -2), new Point2D(4, 1), new Point2D(0, 2), new Point2D(-4, 1)); - Assert.AreEqual(22, polygon.Area); + Assert.Equal(22, polygon.Area); } - [TestMethod] + [Fact] public void CanCalculatePerimeter() { var square = new Polygon(new Point2D(0, 0), new Point2D(2, 0), new Point2D(2, 2), new Point2D(0, 2)); - Assert.AreEqual(8, square.Perimeter); + Assert.Equal(8, square.Perimeter); var polygon = new Polygon(new Point2D(-2, -2), new Point2D(2, -2), new Point2D(4, 1), new Point2D(0, 2), new Point2D(-4, 1)); - Assert.AreEqual(4 + 2 * Math.Sqrt(13) + 2 * Math.Sqrt(17), polygon.Perimeter); + Assert.Equal(4 + 2 * Math.Sqrt(13) + 2 * Math.Sqrt(17), polygon.Perimeter); } - [TestMethod] - [ExpectedException(typeof(ArgumentException))] + [Fact] public void CanValidatePointAmount() { - new Polygon(new Point2D(0, 1), new Point2D(3, 4)); + Assert.Throws(() => + { + new Polygon(new Point2D(0, 1), new Point2D(3, 4)); + }); } } } \ No newline at end of file diff --git a/tests/SharpMath.Tests/SharpMath.Tests.csproj b/tests/SharpMath.Tests/SharpMath.Tests.csproj new file mode 100644 index 0000000..1ba8e71 --- /dev/null +++ b/tests/SharpMath.Tests/SharpMath.Tests.csproj @@ -0,0 +1,14 @@ + + + + + disable + false + none + + + + + + + diff --git a/SharpMath.Tests/VectorTest.cs b/tests/SharpMath.Tests/VectorTest.cs similarity index 63% rename from SharpMath.Tests/VectorTest.cs rename to tests/SharpMath.Tests/VectorTest.cs index abfa977..db1115e 100644 --- a/SharpMath.Tests/VectorTest.cs +++ b/tests/SharpMath.Tests/VectorTest.cs @@ -1,95 +1,90 @@ -// VectorTest.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - using System; using System.Diagnostics; -using Microsoft.VisualStudio.TestTools.UnitTesting; using SharpMath.Geometry; // ReSharper disable UnusedVariable namespace SharpMath.Tests { - [TestClass] public class VectorTest { - [TestMethod] + [Fact] public void CanCalculateAngle() { var vector1 = Vector2.UnitX; var vector2 = Vector2.UnitY; - Assert.AreEqual(Math.PI / 2, vector1.Angle(vector2)); - Assert.IsTrue(vector1.CheckForOrthogonality(vector2)); + Assert.Equal(Math.PI / 2, vector1.Angle(vector2)); + Assert.True(vector1.CheckForOrthogonality(vector2)); } - [TestMethod] + [Fact] public void CanCalculateArea() { var firstVector = new Vector3(3, 4, 4); var secondVector = new Vector3(1, -2, 3); var area = Vector3.Area(firstVector, secondVector); - Assert.AreEqual(Math.Sqrt(525), area); + Assert.Equal(Math.Sqrt(525), area); var thirdVector = new Vector2(2, 4); var fourthVector = new Vector2(3, 1); var secondArea = Vector2.Area(thirdVector, fourthVector); - Assert.IsTrue(FloatingNumber.AreApproximatelyEqual(10, secondArea)); + Assert.True(FloatingNumber.AreApproximatelyEqual(10, secondArea)); } - [TestMethod] + [Fact] public void CanCalculateCrossProduct() { var vector = new Vector3(1, -5, 2); var secondVector = new Vector3(2, 0, 3); var resultVector = vector.VectorProduct(secondVector); - Assert.AreEqual(-15, resultVector.X); - Assert.AreEqual(1, resultVector.Y); - Assert.AreEqual(10, resultVector.Z); + Assert.Equal(-15, resultVector.X); + Assert.Equal(1, resultVector.Y); + Assert.Equal(10, resultVector.Z); } - [TestMethod] + [Fact] public void CanCalculateDistance() { var vector = new Vector3(10, 5, 3); var secondVector = new Vector3(5, 6, 1); // 5, -1, 2 // Magnitude: sqrt(30) - Assert.AreEqual(Math.Sqrt(30), vector.Distance(secondVector)); + Assert.Equal(Math.Sqrt(30), vector.Distance(secondVector)); } - [TestMethod] + [Fact] public void CanCalculateLength() { var vector = new Vector3(13, 0, 0); - Assert.AreEqual(13, vector.Magnitude); + Assert.Equal(13, vector.Magnitude); var secondVector = new Vector3(1, 2, 2); - Assert.AreEqual(3, secondVector.Magnitude); + Assert.Equal(3, secondVector.Magnitude); } - [TestMethod] + [Fact] public void CanCalculateScalarProduct() { - Assert.AreEqual(0, Vector2.UnitX.DotProduct(Vector2.UnitY)); - Assert.AreEqual(20, new Vector2(2, 4).DotProduct(new Vector2(4, 3))); - Assert.AreEqual(0, Vector3.Forward.DotProduct(Vector3.Up)); - Assert.AreEqual(8, new Vector3(2, 3, 1).DotProduct(new Vector3(-1, 2, 4))); + Assert.Equal(0, Vector2.UnitX.DotProduct(Vector2.UnitY)); + Assert.Equal(20, new Vector2(2, 4).DotProduct(new Vector2(4, 3))); + Assert.Equal(0, Vector3.Forward.DotProduct(Vector3.Up)); + Assert.Equal(8, new Vector3(2, 3, 1).DotProduct(new Vector3(-1, 2, 4))); } - [TestMethod] + [Fact] public void CanCompareVectors() { // Let's see, if the dimension check is working var vector = new Vector3(); var secondVector = new Vector2(); - Assert.IsFalse(vector.Equals(secondVector)); + Assert.False(vector.Equals(secondVector)); // Let's see, if the coordinate comparison is working (in this case simply two zero vectors) var thirdVector = new Vector4(); var fourthVector = new Vector4(); - Assert.IsTrue(thirdVector.Equals(fourthVector)); - Assert.IsTrue(thirdVector == fourthVector); - Assert.IsFalse(thirdVector != fourthVector); + Assert.True(thirdVector.Equals(fourthVector)); + Assert.True(thirdVector == fourthVector); + Assert.False(thirdVector != fourthVector); // Let's see, if the coordinate comparison is working when we have the same dimension but different coordinate values var fifthVector = new Vector3 @@ -99,7 +94,7 @@ public void CanCompareVectors() [2] = 1 }; - Assert.AreEqual(new Vector3(1, 2, 1), fifthVector); + Assert.Equal(new Vector3(1, 2, 1), fifthVector); var sixthVector = new Vector3 { @@ -108,12 +103,12 @@ public void CanCompareVectors() [2] = 1 }; - Assert.IsFalse(fifthVector.Equals(sixthVector)); - Assert.IsFalse(fifthVector == sixthVector); - Assert.IsTrue(fifthVector != sixthVector); + Assert.False(fifthVector.Equals(sixthVector)); + Assert.False(fifthVector == sixthVector); + Assert.True(fifthVector != sixthVector); } - [TestMethod] + [Fact] public void CanConvertVectorIntoMatrices() { var firstMatrix = new Matrix3x1 @@ -123,7 +118,7 @@ public void CanConvertVectorIntoMatrices() [2, 0] = 0 }; var firstVectorMatrix = Vector3.Right.AsVerticalMatrix(); - Assert.AreEqual(firstMatrix, firstVectorMatrix); + Assert.True(firstMatrix.Equals(firstVectorMatrix)); // Equals semantics as in MSTest var secondMatrix = new Matrix1x3 { @@ -132,20 +127,20 @@ public void CanConvertVectorIntoMatrices() [0, 2] = 0 }; var secondVectorMatrix = Vector3.Right.AsHorizontalMatrix(); - Assert.AreEqual(secondMatrix, secondVectorMatrix); + Assert.True(secondMatrix.Equals(secondVectorMatrix)); // Equals semantics as in MSTest } - [TestMethod] + [Fact] public void CanConvertVectors() { var firstVector = new Vector3(2, 6, 8); var newVector = firstVector.Convert(); - Assert.AreEqual(2, (int) newVector.Dimension); - Assert.AreEqual(2, newVector.X); - Assert.AreEqual(6, newVector.Y); + Assert.Equal(2, (int) newVector.Dimension); + Assert.Equal(2, newVector.X); + Assert.Equal(6, newVector.Y); } - [TestMethod] + [Fact] public void CanCreateScalarTripleProduct() { var firstVector = new Vector3(2, 0, 5); @@ -154,58 +149,58 @@ public void CanCreateScalarTripleProduct() var scalarTripleProduct = Vector3.ScalarTripleProduct(firstVector, secondVector, thirdVector); Debug.Print(scalarTripleProduct.ToString()); - Assert.AreEqual(31, scalarTripleProduct); + Assert.Equal(31, scalarTripleProduct); } - [TestMethod] + [Fact] public void CanDetermineIfVectorsAreOrthogonal() { - Assert.IsTrue(Vector3.Forward.CheckForOrthogonality(Vector3.Up)); - Assert.IsFalse(Vector3.Forward.CheckForOrthogonality(Vector3.Back)); - Assert.IsFalse(Vector3.Zero.CheckForOrthogonality(Vector3.UnitX)); + Assert.True(Vector3.Forward.CheckForOrthogonality(Vector3.Up)); + Assert.False(Vector3.Forward.CheckForOrthogonality(Vector3.Back)); + Assert.False(Vector3.Zero.CheckForOrthogonality(Vector3.UnitX)); } - [TestMethod] + [Fact] public void CanDetermineIfVectorsAreOrthonormal() { - Assert.IsTrue(Vector3.Forward.CheckForOrthonormality(Vector3.Up)); - Assert.IsTrue(Vector3.Back.CheckForOrthonormality(Vector3.Down)); - Assert.IsFalse(Vector3.Forward.CheckForOrthonormality(Vector3.Back)); - Assert.IsFalse(Vector3.Forward.CheckForOrthonormality(new Vector3(2, 3, 2))); + Assert.True(Vector3.Forward.CheckForOrthonormality(Vector3.Up)); + Assert.True(Vector3.Back.CheckForOrthonormality(Vector3.Down)); + Assert.False(Vector3.Forward.CheckForOrthonormality(Vector3.Back)); + Assert.False(Vector3.Forward.CheckForOrthonormality(new Vector3(2, 3, 2))); } - [TestMethod] + [Fact] public void CanDetermineIfVectorsAreParallel() { - Assert.IsTrue(new Vector3(2, 3, 3).CheckForParallelism(new Vector3(4, 6, 6))); - Assert.IsTrue(new Vector3(1, 2, 3).CheckForParallelism(new Vector3(3, 6, 9))); - Assert.IsFalse(new Vector3(0, 1, 3).CheckForParallelism(new Vector3(0, 3, 2))); + Assert.True(new Vector3(2, 3, 3).CheckForParallelism(new Vector3(4, 6, 6))); + Assert.True(new Vector3(1, 2, 3).CheckForParallelism(new Vector3(3, 6, 9))); + Assert.False(new Vector3(0, 1, 3).CheckForParallelism(new Vector3(0, 3, 2))); } - [TestMethod] + [Fact] public void CanGetCorrectLaTeXString() { var vector = new Vector3(1, 6, 8); - Assert.AreEqual(@"\left( \begin{array}{c} 1 \\ 6 \\ 8 \end{array} \right)", vector.ToLaTeXString()); + Assert.Equal(@"\left( \begin{array}{c} 1 \\ 6 \\ 8 \end{array} \right)", vector.ToLaTeXString()); } - [TestMethod] + [Fact] public void CanGetNegatedVector() { var vector = new Vector3(2, 3, 2); - Assert.AreEqual(new Vector3(-2, -3, -2), vector.Negate()); - Assert.AreEqual(new Vector3(-2, -3, -2), vector.Negate()); + Assert.Equal(new Vector3(-2, -3, -2), vector.Negate()); + Assert.Equal(new Vector3(-2, -3, -2), vector.Negate()); } - [TestMethod] + [Fact] public void CanGetNormalizedVector() { var vector = new Vector2(3, 4); - Assert.AreEqual(new Vector2(3d / 5d, 4d / 5d), vector.Normalize()); - Assert.AreEqual(new Vector2(3d / 5d, 4d / 5d), vector.Normalize()); + Assert.Equal(new Vector2(3d / 5d, 4d / 5d), vector.Normalize()); + Assert.Equal(new Vector2(3d / 5d, 4d / 5d), vector.Normalize()); } - [TestMethod] + [Fact] public void CanLerp() { var firstVector = new Vector3(2, 6, 8); @@ -213,41 +208,45 @@ public void CanLerp() var lerpResult = Vector3.Lerp(firstVector, secondVector, 0.5); // ((6,10,10)-(2,6,8))*0.5+(2,6,8) = (4,4,2)*0.5+(2,6,8) = (2,2,1)+(2,6,8) = (4,8,9) - Assert.AreEqual(new Vector3(4, 8, 9), lerpResult); + Assert.Equal(new Vector3(4, 8, 9), lerpResult); var thirdVector = new Vector3(13, 2, 9); var fourthVector = new Vector3(3, 10, 5); var secondLerpResult = Vector3.Lerp(thirdVector, fourthVector, 0.25); // ((3,10,5)-(13,2,9))*0.25+(13,2,9) = (-10,8,-4)*0.25+(13,2,9) = (-2.5,2,-1)+(13,2,9) = (10.5,4,8) - Assert.AreEqual(new Vector3(10.5, 4, 8), secondLerpResult); + Assert.Equal(new Vector3(10.5, 4, 8), secondLerpResult); } - [TestMethod] - [ExpectedException(typeof(InvalidOperationException))] + [Fact] public void CanNotCalculateAngleBetweenZeroVectors() { - var angle = Vector2.Zero.Angle(Vector2.UnitX); + Assert.Throws(() => + { + var angle = Vector2.Zero.Angle(Vector2.UnitX); + }); } - [TestMethod] - [ExpectedException(typeof(IndexOutOfRangeException))] + [Fact] public void CanNotUseIndexerWithInvalidIndex() { - var vector = new Vector3(10, 5, 3); - var w = vector[3]; // Throws an exception + Assert.Throws(() => + { + var vector = new Vector3(10, 5, 3); + var w = vector[3]; // Throws an exception + }); } - [TestMethod] + [Fact] public void CanRotateTwoDimensionalVector() { var firstVector = new Vector2(0, 1); var rotatedVector = Matrix3x3.Rotation(MathHelper.DegreesToRadians(180)) * firstVector; - Assert.IsTrue(FloatingNumber.AreApproximatelyEqual(firstVector.X, 0)); - Assert.AreEqual(-1, rotatedVector.Y); + Assert.True(FloatingNumber.AreApproximatelyEqual(firstVector.X, 0)); + Assert.Equal(-1, rotatedVector.Y); } - [TestMethod] + [Fact] public void CanUseIndexer() { var vector = new Vector3(10, 5, 3); @@ -255,12 +254,12 @@ public void CanUseIndexer() var y = vector[1]; var z = vector[2]; - Assert.AreEqual(x, 10); - Assert.AreEqual(y, 5); - Assert.AreEqual(z, 3); + Assert.Equal(x, 10); + Assert.Equal(y, 5); + Assert.Equal(z, 3); } - [TestMethod] + [Fact] public void CompareAreaCalculations() { var stopWatch = new Stopwatch(); From bc93707801738cefbdcc10b6faab843ad0654156 Mon Sep 17 00:00:00 2001 From: Dominic Beger Date: Sun, 11 Oct 2026 02:09:23 +0200 Subject: [PATCH 02/14] Rewrite geometry types as immutable, allocation-free value types Vectors, points and matrices become readonly structs with System.Numerics vocabulary, closed-form determinants and inverses, column-vector convention and tolerance-based predicates. Adds MatrixMxN, LineSegment2D, general-form Line2D, canonical Line3D and intersection results. Removes Analysis, MathHelper, FloatingNumber and the generic matrix/vector utilities. --- SharpMath.sln | 46 - SharpMath.slnx | 1 - .../Legacy/Canvas2D.cs | 298 ---- .../Legacy/FunctionWrapper.cs | 29 - .../SharpMath.Canvas.WinForms.csproj | 20 - src/SharpMath/Algorithms.cs | 90 -- .../EquationNotSolvableException.cs | 22 - src/SharpMath/Equations/LinearEquation.cs | 30 - .../Equations/LinearEquationSystem.cs | 87 -- src/SharpMath/Expressions/CharEx.cs | 18 - .../Expressions/Exceptions/ParserException.cs | 23 - src/SharpMath/Expressions/Parser.cs | 49 - src/SharpMath/Expressions/Token.cs | 317 ----- src/SharpMath/Expressions/TokenEx.cs | 18 - src/SharpMath/Expressions/TokenType.cs | 17 - src/SharpMath/Extensions.cs | 41 - src/SharpMath/FloatingNumber.cs | 66 - src/SharpMath/FloatingNumberExtensions.cs | 60 - src/SharpMath/Geometry/BoundingBox2D.cs | 155 +++ .../Geometry/Exceptions/DimensionException.cs | 23 - src/SharpMath/Geometry/IMatrix.cs | 34 +- src/SharpMath/Geometry/ISquareMatrix.cs | 93 +- src/SharpMath/Geometry/IVector.cs | 65 +- src/SharpMath/Geometry/IntersectionKind.cs | 32 + src/SharpMath/Geometry/Line2D.cs | 441 +++--- src/SharpMath/Geometry/Line3D.cs | 352 +++-- src/SharpMath/Geometry/LineIntersection2D.cs | 11 + src/SharpMath/Geometry/LineIntersection3D.cs | 12 + src/SharpMath/Geometry/LineSegment2D.cs | 216 +++ src/SharpMath/Geometry/Matrix1x1.cs | 310 ----- src/SharpMath/Geometry/Matrix1x3.cs | 106 -- src/SharpMath/Geometry/Matrix1x4.cs | 116 -- src/SharpMath/Geometry/Matrix2x2.cs | 657 ++++----- src/SharpMath/Geometry/Matrix3x1.cs | 106 -- .../Geometry/Matrix3x3.Operations.cs | 67 + src/SharpMath/Geometry/Matrix3x3.cs | 879 +++++------- src/SharpMath/Geometry/Matrix4x1.cs | 116 -- .../Geometry/Matrix4x4.Operations.cs | 245 ++++ src/SharpMath/Geometry/Matrix4x4.cs | 1202 ++++++----------- src/SharpMath/Geometry/MatrixEnumerator.cs | 39 - src/SharpMath/Geometry/MatrixMath.cs | 183 +++ src/SharpMath/Geometry/MatrixMxN.cs | 608 +++++++++ src/SharpMath/Geometry/MatrixUtils.cs | 400 ------ src/SharpMath/Geometry/Point2D.cs | 360 +++-- src/SharpMath/Geometry/Point3D.cs | 387 +++--- src/SharpMath/Geometry/Polygon.cs | 473 ++++--- src/SharpMath/Geometry/ProjectionData.cs | 196 +-- .../Geometry/SegmentIntersection2D.cs | 12 + src/SharpMath/Geometry/SegmentMath.cs | 37 + src/SharpMath/Geometry/VariableMatrix.cs | 60 - src/SharpMath/Geometry/Vector2.cs | 939 ++++++------- src/SharpMath/Geometry/Vector3.cs | 945 +++++++------ src/SharpMath/Geometry/Vector4.cs | 674 +++++---- src/SharpMath/Geometry/VectorMath.cs | 38 + src/SharpMath/Geometry/VectorUtils.cs | 289 ---- src/SharpMath/MathHelper.cs | 70 - src/SharpMath/SharpMath.csproj | 6 - src/SharpMath/Tolerance.cs | 95 ++ tests/Directory.Build.props | 5 +- .../Geometry/BoundingBox2DTests.cs | 33 + tests/SharpMath.Tests/Geometry/Line2DTests.cs | 124 ++ tests/SharpMath.Tests/Geometry/Line3DTests.cs | 84 ++ .../Geometry/LineSegment2DTests.cs | 100 ++ .../Geometry/Matrix2x2Tests.cs | 118 ++ .../Geometry/Matrix3x3Tests.cs | 117 ++ .../Geometry/Matrix4x4Tests.cs | 165 +++ .../Geometry/MatrixMxNTests.cs | 126 ++ tests/SharpMath.Tests/Geometry/PointTests.cs | 69 + .../SharpMath.Tests/Geometry/PolygonTests.cs | 113 ++ .../Geometry/ProjectionDataTests.cs | 41 + .../Geometry/ReviewRegressionTests.cs | 172 +++ .../SharpMath.Tests/Geometry/Vector2Tests.cs | 197 +++ .../SharpMath.Tests/Geometry/Vector3Tests.cs | 136 ++ .../SharpMath.Tests/Geometry/Vector4Tests.cs | 88 ++ tests/SharpMath.Tests/Line2DTest.cs | 80 -- tests/SharpMath.Tests/Line3DTest.cs | 34 - tests/SharpMath.Tests/LinearEquationTest.cs | 50 - tests/SharpMath.Tests/MatrixTest.cs | 264 ---- tests/SharpMath.Tests/ParserTest.cs | 17 - tests/SharpMath.Tests/PolygonTest.cs | 45 - tests/SharpMath.Tests/SharpMath.Tests.csproj | 7 - tests/SharpMath.Tests/ToleranceTests.cs | 51 + tests/SharpMath.Tests/VectorTest.cs | 307 ----- 83 files changed, 7076 insertions(+), 7748 deletions(-) delete mode 100644 SharpMath.sln delete mode 100644 src/SharpMath.Canvas.WinForms/Legacy/Canvas2D.cs delete mode 100644 src/SharpMath.Canvas.WinForms/Legacy/FunctionWrapper.cs delete mode 100644 src/SharpMath.Canvas.WinForms/SharpMath.Canvas.WinForms.csproj delete mode 100644 src/SharpMath/Algorithms.cs delete mode 100644 src/SharpMath/Equations/Exceptions/EquationNotSolvableException.cs delete mode 100644 src/SharpMath/Equations/LinearEquation.cs delete mode 100644 src/SharpMath/Equations/LinearEquationSystem.cs delete mode 100644 src/SharpMath/Expressions/CharEx.cs delete mode 100644 src/SharpMath/Expressions/Exceptions/ParserException.cs delete mode 100644 src/SharpMath/Expressions/Parser.cs delete mode 100644 src/SharpMath/Expressions/Token.cs delete mode 100644 src/SharpMath/Expressions/TokenEx.cs delete mode 100644 src/SharpMath/Expressions/TokenType.cs delete mode 100644 src/SharpMath/Extensions.cs delete mode 100644 src/SharpMath/FloatingNumber.cs delete mode 100644 src/SharpMath/FloatingNumberExtensions.cs create mode 100644 src/SharpMath/Geometry/BoundingBox2D.cs delete mode 100644 src/SharpMath/Geometry/Exceptions/DimensionException.cs create mode 100644 src/SharpMath/Geometry/IntersectionKind.cs create mode 100644 src/SharpMath/Geometry/LineIntersection2D.cs create mode 100644 src/SharpMath/Geometry/LineIntersection3D.cs create mode 100644 src/SharpMath/Geometry/LineSegment2D.cs delete mode 100644 src/SharpMath/Geometry/Matrix1x1.cs delete mode 100644 src/SharpMath/Geometry/Matrix1x3.cs delete mode 100644 src/SharpMath/Geometry/Matrix1x4.cs delete mode 100644 src/SharpMath/Geometry/Matrix3x1.cs create mode 100644 src/SharpMath/Geometry/Matrix3x3.Operations.cs delete mode 100644 src/SharpMath/Geometry/Matrix4x1.cs create mode 100644 src/SharpMath/Geometry/Matrix4x4.Operations.cs delete mode 100644 src/SharpMath/Geometry/MatrixEnumerator.cs create mode 100644 src/SharpMath/Geometry/MatrixMath.cs create mode 100644 src/SharpMath/Geometry/MatrixMxN.cs delete mode 100644 src/SharpMath/Geometry/MatrixUtils.cs create mode 100644 src/SharpMath/Geometry/SegmentIntersection2D.cs create mode 100644 src/SharpMath/Geometry/SegmentMath.cs delete mode 100644 src/SharpMath/Geometry/VariableMatrix.cs create mode 100644 src/SharpMath/Geometry/VectorMath.cs delete mode 100644 src/SharpMath/Geometry/VectorUtils.cs delete mode 100644 src/SharpMath/MathHelper.cs create mode 100644 src/SharpMath/Tolerance.cs create mode 100644 tests/SharpMath.Tests/Geometry/BoundingBox2DTests.cs create mode 100644 tests/SharpMath.Tests/Geometry/Line2DTests.cs create mode 100644 tests/SharpMath.Tests/Geometry/Line3DTests.cs create mode 100644 tests/SharpMath.Tests/Geometry/LineSegment2DTests.cs create mode 100644 tests/SharpMath.Tests/Geometry/Matrix2x2Tests.cs create mode 100644 tests/SharpMath.Tests/Geometry/Matrix3x3Tests.cs create mode 100644 tests/SharpMath.Tests/Geometry/Matrix4x4Tests.cs create mode 100644 tests/SharpMath.Tests/Geometry/MatrixMxNTests.cs create mode 100644 tests/SharpMath.Tests/Geometry/PointTests.cs create mode 100644 tests/SharpMath.Tests/Geometry/PolygonTests.cs create mode 100644 tests/SharpMath.Tests/Geometry/ProjectionDataTests.cs create mode 100644 tests/SharpMath.Tests/Geometry/ReviewRegressionTests.cs create mode 100644 tests/SharpMath.Tests/Geometry/Vector2Tests.cs create mode 100644 tests/SharpMath.Tests/Geometry/Vector3Tests.cs create mode 100644 tests/SharpMath.Tests/Geometry/Vector4Tests.cs delete mode 100644 tests/SharpMath.Tests/Line2DTest.cs delete mode 100644 tests/SharpMath.Tests/Line3DTest.cs delete mode 100644 tests/SharpMath.Tests/LinearEquationTest.cs delete mode 100644 tests/SharpMath.Tests/MatrixTest.cs delete mode 100644 tests/SharpMath.Tests/ParserTest.cs delete mode 100644 tests/SharpMath.Tests/PolygonTest.cs create mode 100644 tests/SharpMath.Tests/ToleranceTests.cs delete mode 100644 tests/SharpMath.Tests/VectorTest.cs diff --git a/SharpMath.sln b/SharpMath.sln deleted file mode 100644 index 092fe06..0000000 --- a/SharpMath.sln +++ /dev/null @@ -1,46 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.23107.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpMath", "SharpMath\SharpMath.csproj", "{4706AE0F-30C8-460B-BBBF-5F189BE4EB76}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpMath.Tests", "SharpMath.Tests\SharpMath.Tests.csproj", "{42B815A5-DDAA-4DED-BD69-FA212C1D0D46}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpMath.3DTest", "SharpMath.3DTest\SharpMath.3DTest.csproj", "{15428131-2AC9-4AF4-8966-664FF1974CE8}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpMath.PolygonTest", "SharpMath.PolygonTest\SharpMath.PolygonTest.csproj", "{1D3DD70B-E845-4993-A73E-7EBD14B9E005}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpMath.Canvas2DTest", "SharpMath.CanvasTest\SharpMath.Canvas2DTest.csproj", "{E8982E21-DEED-40EA-8DF9-0BD161AD1490}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {4706AE0F-30C8-460B-BBBF-5F189BE4EB76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4706AE0F-30C8-460B-BBBF-5F189BE4EB76}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4706AE0F-30C8-460B-BBBF-5F189BE4EB76}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4706AE0F-30C8-460B-BBBF-5F189BE4EB76}.Release|Any CPU.Build.0 = Release|Any CPU - {42B815A5-DDAA-4DED-BD69-FA212C1D0D46}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {42B815A5-DDAA-4DED-BD69-FA212C1D0D46}.Debug|Any CPU.Build.0 = Debug|Any CPU - {42B815A5-DDAA-4DED-BD69-FA212C1D0D46}.Release|Any CPU.ActiveCfg = Release|Any CPU - {42B815A5-DDAA-4DED-BD69-FA212C1D0D46}.Release|Any CPU.Build.0 = Release|Any CPU - {15428131-2AC9-4AF4-8966-664FF1974CE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {15428131-2AC9-4AF4-8966-664FF1974CE8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {15428131-2AC9-4AF4-8966-664FF1974CE8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {15428131-2AC9-4AF4-8966-664FF1974CE8}.Release|Any CPU.Build.0 = Release|Any CPU - {1D3DD70B-E845-4993-A73E-7EBD14B9E005}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1D3DD70B-E845-4993-A73E-7EBD14B9E005}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1D3DD70B-E845-4993-A73E-7EBD14B9E005}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1D3DD70B-E845-4993-A73E-7EBD14B9E005}.Release|Any CPU.Build.0 = Release|Any CPU - {E8982E21-DEED-40EA-8DF9-0BD161AD1490}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E8982E21-DEED-40EA-8DF9-0BD161AD1490}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E8982E21-DEED-40EA-8DF9-0BD161AD1490}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E8982E21-DEED-40EA-8DF9-0BD161AD1490}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal diff --git a/SharpMath.slnx b/SharpMath.slnx index 0814907..44b90c3 100644 --- a/SharpMath.slnx +++ b/SharpMath.slnx @@ -1,6 +1,5 @@ - diff --git a/src/SharpMath.Canvas.WinForms/Legacy/Canvas2D.cs b/src/SharpMath.Canvas.WinForms/Legacy/Canvas2D.cs deleted file mode 100644 index 1154ea1..0000000 --- a/src/SharpMath.Canvas.WinForms/Legacy/Canvas2D.cs +++ /dev/null @@ -1,298 +0,0 @@ -// Canvas2D.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Drawing; -using System.Drawing.Drawing2D; -using System.Globalization; -using System.Linq; -using System.Windows.Forms; -using SharpMath.Geometry; - -namespace SharpMath.Presentation -{ - public sealed class Canvas2D : Control - { - private bool _isMouseDown; - private PointF _lastOrigin; - private Point _previousLocation; - public BindingList Functions = new BindingList(); - - public Canvas2D() - { - DoubleBuffered = true; - - GridSize = new SizeF(20, 20); - GridOrigin = PointF.Empty; - - Font = new Font("Segoe UI", 8f, FontStyle.Regular); - ForeColor = Color.Gray; - - Paint += DrawCanvas; - Resize += CanvasResized; - MouseWheel += HandleMouseWheel; - - MouseDown += HandleMouseDown; - MouseMove += HandleMouseMove; - MouseUp += HandleMouseUp; - } - - public PointF GridOrigin { get; set; } - public SizeF GridScale { get; set; } - - public SizeF GridSize { get; set; } - public bool TrackingLines { get; set; } - public bool ValueIndicator { get; set; } - public BindingList Vectors { get; } = new BindingList(); - - private void CanvasResized(object sender, EventArgs e) - { - Invalidate(); - } - - private void DrawCanvas(object sender, PaintEventArgs e) - { - if (GridOrigin == PointF.Empty || DesignMode) - GridOrigin = new PointF(Width / 2, Height / 2); - - _lastOrigin = GridOrigin; - - var g = e.Graphics; - g.FillRectangle(Brushes.White, ClientRectangle); - - DrawCanvasLines(g); - DrawVertices(g); - DrawFunctions(g); - DrawFeatures(g); - } - - private void DrawCanvasLines(Graphics g) - { - #region BackgroundLines - - // Draw horizontal lines - for (var i = 0f; i <= Height; i += GridSize.Height) - { - var startPoint = Height / 2 + (GridOrigin.Y - Height / 2) % GridSize.Height; - g.DrawLine(Pens.LightGray, new PointF(0, startPoint + i), new PointF(Width, startPoint + i)); - g.DrawLine(Pens.LightGray, new PointF(0, startPoint - i), new PointF(Width, startPoint - i)); - } - - // Draw vertical lines - for (var i = 0f; i <= Width; i += GridSize.Width) - { - var startPoint = Width / 2 + (GridOrigin.X - Width / 2) % GridSize.Width; - g.DrawLine(Pens.LightGray, new PointF(startPoint + i, 0), new PointF(startPoint + i, Height)); - g.DrawLine(Pens.LightGray, new PointF(startPoint - i, 0), new PointF(startPoint - i, Height)); - } - - #endregion - - #region AxisScale - - // Draw vertical axis scale - var scaleVariantY = GetNearestScale(Height / GridSize.Height / 8.0); - - for (var i = 0.0; i <= Height; i += GridSize.Height * scaleVariantY) - { - var startPoint = Height / 2 + (GridOrigin.Y - Height / 2) % (GridSize.Height * scaleVariantY); - var value = Math.Round((GridOrigin.Y - startPoint) / GridSize.Height, 1); - var valueOffset = i / GridSize.Height; - - g.DrawString(Math.Round(value - valueOffset, 2).ToString(), Font, Brushes.Black, - new PointF(GridOrigin.X + GridSize.Width / 10, (float) (startPoint + i))); - g.DrawString(Math.Round(value + valueOffset, 2).ToString(), Font, Brushes.Black, - new PointF(GridOrigin.X + GridSize.Width / 10, (float) (startPoint - i))); - } - - // Draw horizontal axis scale - var scaleVariantX = GetNearestScale(Width / GridSize.Width / 8.0); - - for (var i = 0.0; i <= Width; i += GridSize.Width * scaleVariantX) - { - var startPoint = Width / 2 + (GridOrigin.X - Width / 2) % (GridSize.Width * scaleVariantX); - var value = GridOrigin.X > startPoint - ? -Math.Round((GridOrigin.X - startPoint) / GridSize.Width, 1) - : Math.Round(Math.Abs(GridOrigin.X - startPoint) / GridSize.Width, 1); - var valueOffset = i / GridSize.Width; - - if (!Math.Round(value + valueOffset, 2).IsApproximatelyEqualTo(0)) - g.DrawString(Math.Round(value + valueOffset, 2).ToString(CultureInfo.InvariantCulture), Font, - Brushes.Black, - new PointF((float) (startPoint + i), GridOrigin.Y + GridSize.Height / 10)); - - if (!Math.Round(value - valueOffset, 2).IsApproximatelyEqualTo(0)) - g.DrawString(Math.Round(value - valueOffset, 2).ToString(CultureInfo.InvariantCulture), Font, - Brushes.Black, - new PointF((float) (startPoint - i), GridOrigin.Y + GridSize.Height / 10)); - } - - #endregion - - #region AxisLines - - // TODO: Fix overflows - var axisPen = new Pen(new SolidBrush(Color.FromArgb(255, 245, 125, 0))); - g.DrawLine(axisPen, new PointF(0, GridOrigin.Y), new PointF(Width, GridOrigin.Y)); - g.DrawLine(axisPen, new PointF(GridOrigin.X, 0), new PointF(GridOrigin.X, Height)); - - #endregion - } - - private void DrawFeatures(Graphics g) - { - #region TrackingLines - - if (TrackingLines) - { - g.DrawLine(Pens.Gray, new PointF(_previousLocation.X, 0), new PointF(_previousLocation.X, Height)); - g.DrawLine(Pens.Gray, new PointF(0, _previousLocation.Y), new PointF(Width, _previousLocation.Y)); - } - - #endregion - - #region ValueIndicator - - if (!ValueIndicator) - return; - - var currentX = (_previousLocation.X - GridOrigin.X) / GridSize.Width; - var currentY = (GridOrigin.Y - _previousLocation.Y) / GridSize.Height; - - var resultString = $"{Math.Round(currentX, 2)} | {Math.Round(currentY, 2)}"; - var stringDimensions = g.MeasureString(resultString, Font); - - var toolTipRect = new Rectangle(_previousLocation.X + 15, _previousLocation.Y + 15, 80, 25); - g.FillRectangle(Brushes.White, toolTipRect); - g.DrawRectangle(Pens.LightGray, toolTipRect); - - g.DrawString(resultString, Font, Brushes.LightGray, - new PointF(_previousLocation.X + 15 + (80 - stringDimensions.Width) / 2, - _previousLocation.Y + 17 + (25 - stringDimensions.Height) / 2)); - - #endregion - } - - private void DrawFunctions(Graphics g) - { - foreach (var func in Functions) - { - var lastPos = PointF.Empty; - for (var i = 0; i < Width; i++) - try - { - var currentX = (-GridOrigin.X + i) / GridSize.Width; - var currentY = GridOrigin.Y - func.GetValue(currentX) * GridSize.Height; - var currentPos = new PointF(currentX * GridSize.Width + GridOrigin.X, (float) currentY); - - if (lastPos != PointF.Empty) - g.DrawLine(Pens.Black, lastPos, currentPos); - - lastPos = currentPos; - } - catch (OverflowException) - { - // Content will follow - } - } - } - - private void DrawVertices(Graphics g) - { - var vectorPen = new Pen(new SolidBrush(Color.FromArgb(255, 122, 125, 201))) {EndCap = LineCap.ArrowAnchor}; - foreach (var v in Vectors) - g.DrawLine(vectorPen, - GridOrigin, - new PointF(GridOrigin.X + (float) v.X * GridSize.Width, - GridOrigin.Y - (float) v.Y * GridSize.Height)); - } - - private double GetNearestScale(double value) - { - var scaleVariants = new List {10, 5, 2}; - var dimension = (int) Math.Floor(Math.Log10(Math.Abs(value)) + 1); - - if (dimension > 1) value = value / ((dimension - 1) * 10); - if (dimension == 0) value = value * ((dimension - 1) * 10); - if (dimension < 0) value = value * (Math.Abs(dimension) * Math.Pow(10, Math.Abs(dimension) + 1)); - value = Math.Abs(value); - - var normalizedValue = scaleVariants.Aggregate((x, y) => Math.Abs(x - value) < Math.Abs(y - value) ? x : y); - if (dimension == 1) return normalizedValue; - if (dimension > 1) return normalizedValue * ((dimension - 1) * 10); - if (dimension < 0) return normalizedValue / Math.Pow(10, Math.Abs(dimension) + 1); - if (dimension < 1) return normalizedValue / ((dimension + 1) * 10); - return 0; - } - - private void HandleMouseDown(object sender, MouseEventArgs e) - { - _isMouseDown = true; - _previousLocation = e.Location; - } - - private void HandleMouseMove(object sender, MouseEventArgs e) - { - if (TrackingLines || ValueIndicator) - Invalidate(); - - if (_isMouseDown) - { - var deltaX = e.X - _previousLocation.X; - var deltaY = e.Y - _previousLocation.Y; - - var newX = GridOrigin.X + deltaX; - var newY = GridOrigin.Y + deltaY; - - GridOrigin = new PointF(newX, newY); - Invalidate(); - } - - _previousLocation = e.Location; - } - - private void HandleMouseUp(object sender, MouseEventArgs e) - { - _isMouseDown = false; - } - - private void HandleMouseWheel(object sender, MouseEventArgs e) - { - // Needs work - if (e.Delta > 0) - { - GridSize = new SizeF((float) (GridSize.Width * 1.25), (float) (GridSize.Height * 1.25)); - GridOrigin = new PointF( - GridOrigin.X + - (e.X > _lastOrigin.X ? -1.25f * (e.X - _lastOrigin.X) : 1.25f * (_lastOrigin.X - e.X)), - GridOrigin.Y + - (e.Y > _lastOrigin.Y ? -1.25f * (e.Y - _lastOrigin.Y) : 1.25f * (_lastOrigin.Y - e.Y)) - ); - _lastOrigin = GridOrigin; - } - else - { - var newSize = new SizeF((float) (GridSize.Width / 1.25), (float) (GridSize.Height / 1.25)); - if (newSize.Width > 3 && newSize.Height > 3) - { - GridSize = newSize; - GridOrigin = new PointF( - GridOrigin.X + - (_lastOrigin.X + e.X > _lastOrigin.X - ? (e.X - _lastOrigin.X) / 1.25f - : (_lastOrigin.X - e.X) / -1.25f), - GridOrigin.Y + - (_lastOrigin.Y + e.Y > _lastOrigin.Y - ? (e.Y - _lastOrigin.Y) / 1.25f - : (_lastOrigin.Y - e.Y) / -1.25f) - ); - _lastOrigin = GridOrigin; - } - } - - Invalidate(); - } - } -} \ No newline at end of file diff --git a/src/SharpMath.Canvas.WinForms/Legacy/FunctionWrapper.cs b/src/SharpMath.Canvas.WinForms/Legacy/FunctionWrapper.cs deleted file mode 100644 index 649fc57..0000000 --- a/src/SharpMath.Canvas.WinForms/Legacy/FunctionWrapper.cs +++ /dev/null @@ -1,29 +0,0 @@ -// FunctionWrapper.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; -using System.Collections.Generic; - -namespace SharpMath.Presentation -{ - public class FunctionWrapper - { - public FunctionWrapper(Func function) - { - Values = new Dictionary(); - BaseFunction = function; - } - - public Func BaseFunction { get; } - public Dictionary Values { get; } - - public double GetValue(double x) - { - if (Values.ContainsKey(x)) - return Values[x]; - var resultValue = BaseFunction(x); - Values.Add(x, resultValue); - return resultValue; - } - } -} \ No newline at end of file diff --git a/src/SharpMath.Canvas.WinForms/SharpMath.Canvas.WinForms.csproj b/src/SharpMath.Canvas.WinForms/SharpMath.Canvas.WinForms.csproj deleted file mode 100644 index 5b44dbe..0000000 --- a/src/SharpMath.Canvas.WinForms/SharpMath.Canvas.WinForms.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - net10.0-windows - true - true - WinForms host and GDI+ renderer for the SharpMath 2D canvas. - - disable - false - none - false - $(NoWarn);CS1591;WFO1000 - - - - - - - diff --git a/src/SharpMath/Algorithms.cs b/src/SharpMath/Algorithms.cs deleted file mode 100644 index b414997..0000000 --- a/src/SharpMath/Algorithms.cs +++ /dev/null @@ -1,90 +0,0 @@ -// Algorithms.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System.Collections.Generic; -using SharpMath.Expressions; -using SharpMath.Expressions.Exceptions; - -namespace SharpMath -{ - /// - /// Provides algorithms for common mathematical operations. - /// - public static class Algorithms - { - /// - /// Converts infix tokens to postfix tokens. - /// - /// The infix tokens to convert. - /// The postfix tokens.> - public static IEnumerable ShuntingYard(List infixTokens) - { - var opStack = new Stack>(); - var output = new List(infixTokens.Count); - - Token lastToken = null; - foreach (var token in infixTokens) - { - if (lastToken != null) - { - // A constant value or number must be followed by an operator or closing bracket... - if ((lastToken.Type == TokenType.Constant || lastToken.Type == TokenType.Number) && - token.Type != TokenType.Operator && !token.IsClosingBracket()) - throw new ParserException( - "Cannot calculate the postfix tokens of the given input as the term is invalid. A constant or number must be followed by an operator or a closing bracket."); - - // A closing bracket must be followed by an operator or another closing bracket... - if (lastToken.IsClosingBracket() && token.Type != TokenType.Operator && !token.IsClosingBracket()) - throw new ParserException( - "Cannot calculate the postfix tokens of the given input as the term is invalid. A closing bracket must be followed by an operator or another closing bracket."); - } - - lastToken = token; - switch (token.Type) - { - case TokenType.Number: - output.Add((Token) token); - break; - case TokenType.Function: - opStack.Push((Token) token); - break; - case TokenType.Operator: - var currentOperatorToken = (Token) token; - while (opStack.Count > 0 && opStack.Peek().Type == TokenType.Operator && - (!currentOperatorToken.IsRightAssociative && - currentOperatorToken.Priority == opStack.Peek().Priority || - currentOperatorToken.Priority < opStack.Peek().Priority)) - output.Add(opStack.Pop()); - opStack.Push(currentOperatorToken); - break; - case TokenType.Bracket: - var bracketToken = (Token) token; - switch (bracketToken.Value) - { - case "(": - opStack.Push(bracketToken); - break; - case ")": - while (opStack.Peek().Value != "(") - output.Add(opStack.Pop()); - opStack.Pop(); - - if (opStack.Count > 0 && opStack.Peek().Type == TokenType.Function) - output.Add(opStack.Pop()); - break; - } - - break; - case TokenType.Constant: - output.Add((Token) token); - break; - } - } - - while (opStack.Count > 0) // Flush the remaining stuff - output.Add(opStack.Pop()); - - return output; - } - } -} \ No newline at end of file diff --git a/src/SharpMath/Equations/Exceptions/EquationNotSolvableException.cs b/src/SharpMath/Equations/Exceptions/EquationNotSolvableException.cs deleted file mode 100644 index 4d3414e..0000000 --- a/src/SharpMath/Equations/Exceptions/EquationNotSolvableException.cs +++ /dev/null @@ -1,22 +0,0 @@ -// EquationNotSolvableException.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; - -namespace SharpMath.Equations.Exceptions -{ - /// - /// The exception that is thrown when an equation is not solvable. - /// - /// - public class EquationNotSolvableException : Exception - { - /// - /// Initializes a new instance of the class. - /// - /// The message that describes the error. - public EquationNotSolvableException(string message) : base(message) - { - } - } -} \ No newline at end of file diff --git a/src/SharpMath/Equations/LinearEquation.cs b/src/SharpMath/Equations/LinearEquation.cs deleted file mode 100644 index c4624db..0000000 --- a/src/SharpMath/Equations/LinearEquation.cs +++ /dev/null @@ -1,30 +0,0 @@ -// LinearEquation.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -namespace SharpMath.Equations -{ - /// - /// Represents a linear equation. - /// - public class LinearEquation - { - public LinearEquation() : this(new double[] { }, 0) - { - } - - public LinearEquation(double[] coefficients, double result) - { - Coefficients = coefficients; - Result = result; - } - - public LinearEquation(double result, params double[] coefficients) - { - Coefficients = coefficients; - Result = result; - } - - public double[] Coefficients { get; set; } - public double Result { get; set; } - } -} \ No newline at end of file diff --git a/src/SharpMath/Equations/LinearEquationSystem.cs b/src/SharpMath/Equations/LinearEquationSystem.cs deleted file mode 100644 index 247b5dd..0000000 --- a/src/SharpMath/Equations/LinearEquationSystem.cs +++ /dev/null @@ -1,87 +0,0 @@ -// LinearEquationSystem.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; -using System.Collections.Generic; -using System.Linq; -using SharpMath.Equations.Exceptions; -using SharpMath.Geometry; - -namespace SharpMath.Equations -{ - /// - /// Represents a linear equation system. - /// - public class LinearEquationSystem - { - /// - /// Initializes a new instance of the class. - /// - public LinearEquationSystem() - { - } - - /// - /// Initializes a new instance of the class. - /// - /// - /// The s that this should - /// contain. - /// - public LinearEquationSystem(IEnumerable equations) - { - Equations.AddRange(equations); - } - - /// - /// Initializes a new instance of the class. - /// - /// - /// The s that this should - /// contain. - /// - public LinearEquationSystem(params LinearEquation[] equations) - { - Equations.AddRange(equations); - } - - /// - /// Gets or sets the s of this . - /// - public List Equations { get; set; } = new List(); - - /// - /// Solves this using the Gauss-Jordan algorithm. - /// - /// The result of each variable in chronological order. - public double[] Solve() - { - var count = (uint) Equations.Count; - if (count == 0) - throw new ArgumentException("There must be at least one equation to solve."); - - var coefficientsCount = Equations.Select(item => item.Coefficients).Count(); - if (coefficientsCount == 0 || coefficientsCount != count) - throw new EquationNotSolvableException("This linear equation system cannot be solved."); - - var leftSide = new VariableMatrix(count, count); - var rightSide = new VariableMatrix(count, 1); - - for (uint i = 0; i < count; ++i) - { - var currentEquation = Equations[(int) i]; - var coefficients = currentEquation.Coefficients; - for (uint c = 0; c < coefficients.Length; ++c) - leftSide[i, c] = coefficients[c]; - rightSide[i, 0] = currentEquation.Result; - } - - var resultMatrix = MatrixUtils.GaussJordan(leftSide, rightSide); - var result = new double[rightSide.RowCount]; - for (uint i = 0; i < resultMatrix.RowCount; ++i) - result[i] = resultMatrix[i, 0]; - - return result; - } - } -} \ No newline at end of file diff --git a/src/SharpMath/Expressions/CharEx.cs b/src/SharpMath/Expressions/CharEx.cs deleted file mode 100644 index 1c75e14..0000000 --- a/src/SharpMath/Expressions/CharEx.cs +++ /dev/null @@ -1,18 +0,0 @@ -// CharEx.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -namespace SharpMath.Expressions -{ - public class CharEx - { - public static bool IsBracket(char c) - { - return c == '(' || c == ')'; - } - - public static bool IsMathematicOperator(char c) - { - return "+-*/%^!".IndexOf(c) >= 0; - } - } -} \ No newline at end of file diff --git a/src/SharpMath/Expressions/Exceptions/ParserException.cs b/src/SharpMath/Expressions/Exceptions/ParserException.cs deleted file mode 100644 index 9da5673..0000000 --- a/src/SharpMath/Expressions/Exceptions/ParserException.cs +++ /dev/null @@ -1,23 +0,0 @@ -// ParserException.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; - -namespace SharpMath.Expressions.Exceptions -{ - /// - /// The exception that is thrown when the parser encountered an invalid token. - /// - /// - public class ParserException : Exception - { - /// - /// Initializes a new instance of the class. - /// - /// The message that describes the error. - public ParserException(string message) - : base(message) - { - } - } -} \ No newline at end of file diff --git a/src/SharpMath/Expressions/Parser.cs b/src/SharpMath/Expressions/Parser.cs deleted file mode 100644 index e4bc6fd..0000000 --- a/src/SharpMath/Expressions/Parser.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Parser.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; -using System.Collections.Generic; -using System.Linq; - -namespace SharpMath.Expressions -{ - /// - /// Provides functions to evaluate mathematic terms. - /// - public class Parser - { - /// - /// Initializes a new instance of the class. - /// - /// The expression/term that should evaluated. - public Parser(string expression) - { - if (expression == null) - throw new ArgumentNullException(nameof(expression)); - Expression = expression; - } - - /// - /// Gets or sets the expression/term that should be evaluated. - /// - public string Expression { get; set; } - - /// - /// Evaluates the term. - /// - /// The result of the evaluation. - public double Evaluate() - { - if (string.IsNullOrWhiteSpace(Expression)) - throw new Exception("The term that should be evaluated is empty."); - - var resultStack = new Stack(); - var infixTokens = Token.CalculateInfixTokens(Expression); - var postfixTokens = Algorithms.ShuntingYard(infixTokens.ToList()); - foreach (var pToken in postfixTokens) - pToken.Evaluate(resultStack); - - return resultStack.Pop(); - } - } -} \ No newline at end of file diff --git a/src/SharpMath/Expressions/Token.cs b/src/SharpMath/Expressions/Token.cs deleted file mode 100644 index c43a8cf..0000000 --- a/src/SharpMath/Expressions/Token.cs +++ /dev/null @@ -1,317 +0,0 @@ -// Token.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; -using SharpMath.Expressions.Exceptions; - -// ReSharper disable InconsistentNaming - -namespace SharpMath.Expressions -{ - /// - /// Represents a part of a mathematic term. - /// - public class Token - { - private static readonly Regex SWhitespace = new Regex(@"\s+"); - - protected static Dictionary>> _functionActions = new Dictionary - >> - { - {"sin", stack => stack.Push(Math.Sin(stack.Pop()))}, - {"cos", stack => stack.Push(Math.Cos(stack.Pop()))}, - {"tan", stack => stack.Push(Math.Tan(stack.Pop()))}, - {"asin", stack => stack.Push(Math.Asin(stack.Pop()))}, - {"acos", stack => stack.Push(Math.Acos(stack.Pop()))}, - {"atan", stack => stack.Push(Math.Atan(stack.Pop()))}, - {"sqrt", stack => stack.Push(Math.Sqrt(stack.Pop()))}, - {"abs", stack => stack.Push(Math.Abs(stack.Pop()))}, - {"ln", stack => stack.Push(Math.Log(stack.Pop()))}, - {"lg", stack => stack.Push(Math.Log(stack.Pop(), 10))}, - { - "log", stack => - { - var exponent = stack.Pop(); - stack.Push(Math.Log(exponent, stack.Pop())); - } - } - }; - - protected static Dictionary>> _constantActions = new Dictionary - >> - { - {"pi", stack => stack.Push(Math.PI)}, - {"e", stack => stack.Push(Math.E)} - }; - - protected static Dictionary>> _operatorActions = new Dictionary - >> - { - {"+", stack => stack.Push(stack.Pop() + stack.Pop())}, - { - "-", stack => - { - var subtrahend = stack.Pop(); - stack.Push(stack.Pop() - subtrahend); - } - }, - {"*", stack => stack.Push(stack.Pop() * stack.Pop())}, - { - "/", stack => - { - var divisor = stack.Pop(); - stack.Push(stack.Pop() / divisor); - } - }, - {"!", stack => stack.Push(-stack.Pop())}, - { - "^", stack => - { - var exponent = stack.Pop(); - stack.Push(Math.Pow(stack.Pop(), exponent)); - } - } - }; - - /// - /// Initializes a new instance of the class. - /// - /// The type of the . - public Token(TokenType type) - { - Type = type; - } - - /// - /// Gets or sets the type of this . - /// - public TokenType Type { get; set; } - - /// - /// Creates an containing infix tokens from partitioning the specified term. - /// - /// The term to partition into infix tokens. - /// containing the created infix tokens. - public static IEnumerable CalculateInfixTokens(string term) - { - var infixTokens = new List(); - term = SWhitespace.Replace(term, string.Empty).ToLowerInvariant(); - var i = 0; - while (i < term.Length) - { - var current = term[i]; - if (char.IsLetter(current) || CharEx.IsMathematicOperator(current) || CharEx.IsBracket(current)) - // Functions/Operators/Constants - { - if ((current == '+' || current == '-') && - (i == 0 || - infixTokens.Last().Type == TokenType.Bracket && - ((Token) infixTokens.Last()).Value == "(")) - // Must be a sign and handled differently. - { - if (current == '-') - { - var sb = new StringBuilder(term) {[term.IndexOf(current)] = '!'}; - term = sb.ToString(); - } - else - { - term.Remove(term.IndexOf(current), 1); - } - - // Remove the '+' as it is redundant and disturbs our calculations - } - - infixTokens.Add(ReadStringToken(term, ref i)); - } - else if (char.IsDigit(current)) // Numbers - { - infixTokens.Add(ReadNumberToken(term, ref i)); - } - else - { - throw new ParserException($"Char {current} cannot be interpreted as a valid token."); - } - } - - return infixTokens; - } - - /// - /// Evaluates this . - /// - /// The to take relating from and push the result to. - public void Evaluate(Stack stack) - { - switch (Type) - { - case TokenType.Number: - var token = this as Token; - if (token != null) - stack.Push(token.Value); - break; - case TokenType.Function: - var token1 = this as Token; - if (token1 != null) - _functionActions[token1.Value](stack); - break; - case TokenType.Operator: - var token2 = this as Token; - if (token2 != null) - _operatorActions[token2.Value](stack); - break; - case TokenType.Constant: - var token3 = this as Token; - if (token3 != null) - _constantActions[token3.Value](stack); - break; - } - } - - /// - /// Reads a number token in the input string starting at the specified index. - /// - /// The input string. - /// The index to start at. - /// The that has been read. - public static Token ReadNumberToken(string input, ref int index) - { - var endIndex = index; - while (endIndex < input.Length && (char.IsDigit(input, endIndex) || input[endIndex] == '.')) - endIndex++; - var numberToken = input.Substring(index, endIndex - index); - double numberValue; - if ( - !double.TryParse(numberToken, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, - out numberValue)) - throw new ParserException($"Invalid token: {numberToken} is not a valid number."); - index = endIndex; - return new Token(numberValue, TokenType.Number); - } - - /// - /// Reads a bracket, constant, operator or function token in the input string starting at the specified index. - /// - /// The input string. - /// The index to start at. - /// The that has been read. - public static Token ReadStringToken(string input, ref int index) - { - TokenType newType; - var endIndex = index; - while (endIndex < input.Length && - (char.IsLetter(input, endIndex) || CharEx.IsMathematicOperator(input[endIndex]) || - CharEx.IsBracket(input[endIndex]))) - { - endIndex++; - var currentString = input.Substring(index, endIndex - index); - if (_functionActions.ContainsKey(currentString) || _operatorActions.ContainsKey(currentString) || - _constantActions.ContainsKey(currentString) || - currentString.IsBracket() - ) // Last two conditions to parse e.g. "* sin(5)" correctly: As soon as the current token has finished, we should stop reading. - break; // This indicates that it has been found in the dictionary, so that is already it. - } - - var stringToken = input.Substring(index, endIndex - index); - - if (_functionActions.ContainsKey(stringToken)) - newType = TokenType.Function; - else if (_operatorActions.ContainsKey(stringToken)) - newType = TokenType.Operator; - else if (stringToken.IsBracket()) - newType = TokenType.Bracket; - else if (_constantActions.ContainsKey(stringToken)) - newType = TokenType.Constant; - else - throw new ParserException( - $"Invalid token: {stringToken} is not a valid function/operator, bracket or number equivalent."); - - index = endIndex; - return new Token(stringToken, newType); - } - } - - /// - /// Represents a part of a mathematic term with a specific data type that should represent it. - /// - /// The data type of the that represents it. - public class Token : Token - { - /// - /// Initializes a new instance of the class. - /// - /// The value of the . - /// The type of the . - public Token(T value, TokenType type) - : base(type) - { - Value = value; - switch (type) - { - case TokenType.Number: - case TokenType.Constant: - Priority = 100; - break; - case TokenType.Operator: - switch (value as string) - { - case "+": - Priority = 1; - IsRightAssociative = false; - break; - case "-": - Priority = 1; - IsRightAssociative = false; - break; - case "*": - Priority = 2; - IsRightAssociative = false; - break; - case "/": - Priority = 2; - IsRightAssociative = false; - break; - case "%": - Priority = 3; - IsRightAssociative = false; - break; - case "!": - Priority = 4; - IsRightAssociative = false; - break; - case "^": - Priority = 5; - IsRightAssociative = true; - break; - } - - break; - } - } - - /// - /// Gets or sets a value indicating whether this is right associative, or not. - /// - public bool IsRightAssociative { get; set; } - - /// - /// Gets or sets the priority of this . - /// - public int Priority { get; set; } - - /// - /// Gets or sets the value of this . - /// - public T Value { get; set; } - - public override string ToString() - { - return Value.ToString(); - } - } -} \ No newline at end of file diff --git a/src/SharpMath/Expressions/TokenEx.cs b/src/SharpMath/Expressions/TokenEx.cs deleted file mode 100644 index 50c95f1..0000000 --- a/src/SharpMath/Expressions/TokenEx.cs +++ /dev/null @@ -1,18 +0,0 @@ -// TokenEx.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -namespace SharpMath.Expressions -{ - public static class TokenEx - { - public static bool IsClosingBracket(this Token token) - { - return token.Type == TokenType.Bracket && ((Token) token).Value == ")"; - } - - public static bool IsOpeningBracket(this Token token) - { - return token.Type == TokenType.Bracket && ((Token) token).Value == "("; - } - } -} \ No newline at end of file diff --git a/src/SharpMath/Expressions/TokenType.cs b/src/SharpMath/Expressions/TokenType.cs deleted file mode 100644 index b3806e4..0000000 --- a/src/SharpMath/Expressions/TokenType.cs +++ /dev/null @@ -1,17 +0,0 @@ -// TokenType.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -namespace SharpMath.Expressions -{ - /// - /// Represents the different token types of a mathematic term. - /// - public enum TokenType - { - Function, - Operator, - Number, - Constant, - Bracket - } -} \ No newline at end of file diff --git a/src/SharpMath/Extensions.cs b/src/SharpMath/Extensions.cs deleted file mode 100644 index 6fb4ccd..0000000 --- a/src/SharpMath/Extensions.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Extensions.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; -using System.Collections.Generic; -using System.ComponentModel; - -namespace SharpMath -{ - internal static class Extensions - { - public static IEnumerable AllIndicesOf(this string str, string searchString) - { - var minIndex = str.IndexOf(searchString, StringComparison.Ordinal); - while (minIndex != -1) - { - yield return minIndex; - minIndex = str.IndexOf(searchString, minIndex + searchString.Length, StringComparison.Ordinal); - } - } - - public static string GetEnumDescription(this Enum value) - { - var fi = value.GetType().GetField(value.ToString()); - - var attributes = - (DescriptionAttribute[]) fi.GetCustomAttributes( - typeof(DescriptionAttribute), - false); - - if (attributes.Length > 0) - return attributes[0].Description; - return value.ToString(); - } - - public static bool IsBracket(this string s) - { - return s == "(" || s == ")"; - } - } -} \ No newline at end of file diff --git a/src/SharpMath/FloatingNumber.cs b/src/SharpMath/FloatingNumber.cs deleted file mode 100644 index e314cc0..0000000 --- a/src/SharpMath/FloatingNumber.cs +++ /dev/null @@ -1,66 +0,0 @@ -// FloatingNumber.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; - -namespace SharpMath -{ - /// - /// Provides functions for comparing floating numbers using approximations. - /// - public static class FloatingNumber - { - /// - /// Gets the default tolerance value for comparing floating numbers. - /// - public static double Epsilon => 1.6234133E-09; - - /// - /// Determines whether two floating numbers are approximately equal to each other using the - /// value. - /// - /// The first . - /// The second . - /// Returns true, if they are approximately equal, otherwise false. - public static bool AreApproximatelyEqual(float firstNumber, float secondNumber) - { - return AreApproximatelyEqual(firstNumber, secondNumber, Epsilon); - } - - /// - /// Determines whether two floating numbers are approximately equal to each other using the specified epsilon value. - /// - /// The first . - /// The second . - /// The epsilon value that represents the tolerance. - /// Returns true, if they are approximately equal, otherwise false. - public static bool AreApproximatelyEqual(float firstNumber, float secondNumber, double epsilon) - { - return Math.Abs(firstNumber - secondNumber) <= epsilon; - } - - /// - /// Determines whether two floating numbers are approximately equal to each other using the - /// value. - /// - /// The first . - /// The second . - /// Returns true, if they are approximately equal, otherwise false. - public static bool AreApproximatelyEqual(double firstNumber, double secondNumber) - { - return AreApproximatelyEqual(firstNumber, secondNumber, Epsilon); - } - - /// - /// Determines whether two floating numbers are approximately equal to each other using the specified epsilon value. - /// - /// The first . - /// The second . - /// The epsilon value that represents the tolerance. - /// Returns true, if they are approximately equal, otherwise false. - public static bool AreApproximatelyEqual(double firstNumber, double secondNumber, double epsilon) - { - return Math.Abs(firstNumber - secondNumber) <= epsilon; - } - } -} \ No newline at end of file diff --git a/src/SharpMath/FloatingNumberExtensions.cs b/src/SharpMath/FloatingNumberExtensions.cs deleted file mode 100644 index 2b0825a..0000000 --- a/src/SharpMath/FloatingNumberExtensions.cs +++ /dev/null @@ -1,60 +0,0 @@ -// FloatingNumberExtensions.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -namespace SharpMath -{ - /// - /// Provides extensions for comparing floating numbers using approximations. - /// - public static class FloatingNumberExtensions - { - /// - /// Determines whether two floating numbers are approximately equal to each other using the - /// value. - /// - /// The current . - /// The other . - /// Returns true, if they are approximately equal, otherwise false. - public static bool IsApproximatelyEqualTo(this float number, float other) - { - return FloatingNumber.AreApproximatelyEqual(number, other); - } - - /// - /// Determines whether two floating numbers are approximately equal to each other using the specified epsilon value. - /// - /// The current . - /// The other . - /// The epsilon value that represents the tolerance. - /// Returns true, if they are approximately equal, otherwise false. - public static bool IsApproximatelyEqualTo(this float number, float other, double epsilon) - { - return FloatingNumber.AreApproximatelyEqual(number, other, epsilon); - } - - /// - /// Determines whether two floating numbers are approximately equal to each other using the - /// - /// value. - /// - /// The current . - /// The other . - /// Returns true, if they are approximately equal, otherwise false. - public static bool IsApproximatelyEqualTo(this double number, double other) - { - return FloatingNumber.AreApproximatelyEqual(number, other); - } - - /// - /// Determines whether two floating numbers are approximately equal to each other using the specified epsilon value. - /// - /// The current . - /// The other . - /// The epsilon value that represents the tolerance. - /// Returns true, if they are approximately equal, otherwise false. - public static bool IsApproximatelyEqualTo(this double number, double other, double epsilon) - { - return FloatingNumber.AreApproximatelyEqual(number, other, epsilon); - } - } -} \ No newline at end of file diff --git a/src/SharpMath/Geometry/BoundingBox2D.cs b/src/SharpMath/Geometry/BoundingBox2D.cs new file mode 100644 index 0000000..d2ce0d0 --- /dev/null +++ b/src/SharpMath/Geometry/BoundingBox2D.cs @@ -0,0 +1,155 @@ +using System.Globalization; + +namespace SharpMath.Geometry; + +/// +/// Represents an immutable axis-aligned bounding box in the plane. +/// +public readonly struct BoundingBox2D : IEquatable +{ + /// + /// Initializes a new instance of the struct. + /// + /// The smallest X coordinate. + /// The smallest Y coordinate. + /// The largest X coordinate. + /// The largest Y coordinate. + /// A minimum is greater than the corresponding maximum, or a value is NaN. + public BoundingBox2D(double minX, double minY, double maxX, double maxY) + { + if (!(minX <= maxX)) + throw new ArgumentException("minX must not be greater than maxX.", nameof(minX)); + if (!(minY <= maxY)) + throw new ArgumentException("minY must not be greater than maxY.", nameof(minY)); + + MinX = minX; + MinY = minY; + MaxX = maxX; + MaxY = maxY; + } + + /// + /// Gets the smallest X coordinate. + /// + public double MinX { get; } + + /// + /// Gets the smallest Y coordinate. + /// + public double MinY { get; } + + /// + /// Gets the largest X coordinate. + /// + public double MaxX { get; } + + /// + /// Gets the largest Y coordinate. + /// + public double MaxY { get; } + + /// + /// Gets the width. + /// + public double Width => MaxX - MinX; + + /// + /// Gets the height. + /// + public double Height => MaxY - MinY; + + /// + /// Gets the center. + /// + public Point2D Center => new((MinX + MaxX) / 2, (MinY + MaxY) / 2); + + /// + /// Creates the smallest bounding box that contains all specified points. + /// + /// The points; at least one. + /// The bounding box. + /// is empty. + public static BoundingBox2D FromPoints(ReadOnlySpan points) + { + if (points.IsEmpty) + throw new ArgumentException("At least one point is required.", nameof(points)); + + double minX = points[0].X, minY = points[0].Y, maxX = minX, maxY = minY; + foreach (var point in points[1..]) + { + minX = Math.Min(minX, point.X); + minY = Math.Min(minY, point.Y); + maxX = Math.Max(maxX, point.X); + maxY = Math.Max(maxY, point.Y); + } + + return new BoundingBox2D(minX, minY, maxX, maxY); + } + + /// + /// Determines whether a point lies inside the box or on its border. + /// + /// The point. + /// if the box contains the point; otherwise, . + public bool Contains(Point2D point) => + point.X >= MinX && point.X <= MaxX && point.Y >= MinY && point.Y <= MaxY; + + /// + /// Returns the smallest bounding box that contains this box and another box. + /// + /// The other box. + /// The union. + public BoundingBox2D Union(BoundingBox2D other) => + new(Math.Min(MinX, other.MinX), Math.Min(MinY, other.MinY), Math.Max(MaxX, other.MaxX), + Math.Max(MaxY, other.MaxY)); + + /// + /// Determines whether this box equals another box within the default tolerances. + /// + /// The other box. + /// if the boxes are approximately equal; otherwise, . + public bool ApproximatelyEquals(BoundingBox2D other) => + ApproximatelyEquals(other, Tolerance.DefaultAbsolute, Tolerance.DefaultRelative); + + /// + /// Determines whether this box equals another box within the specified tolerances. + /// + /// The other box. + /// The absolute tolerance. + /// The relative tolerance. + /// if the boxes are approximately equal; otherwise, . + public bool ApproximatelyEquals(BoundingBox2D other, double absolute, double relative) => + Tolerance.AreEqual(MinX, other.MinX, absolute, relative) && Tolerance.AreEqual(MinY, other.MinY, absolute, relative) && + Tolerance.AreEqual(MaxX, other.MaxX, absolute, relative) && Tolerance.AreEqual(MaxY, other.MaxY, absolute, relative); + + /// + public override string ToString() => + string.Create(CultureInfo.InvariantCulture, $"BoundingBox2D [Min: ({MinX}, {MinY}), Max: ({MaxX}, {MaxY})]"); + + /// + public bool Equals(BoundingBox2D other) => + MinX.Equals(other.MinX) && MinY.Equals(other.MinY) && MaxX.Equals(other.MaxX) && MaxY.Equals(other.MaxY); + + /// + public override bool Equals(object? obj) => obj is BoundingBox2D other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(MinX, MinY, MaxX, MaxY); + + /// + /// Determines whether two boxes are equal. + /// + /// The first box. + /// The second box. + /// if the boxes are equal; otherwise, . + public static bool operator ==(BoundingBox2D left, BoundingBox2D right) => + left.MinX == right.MinX && left.MinY == right.MinY && left.MaxX == right.MaxX && left.MaxY == right.MaxY; + + /// + /// Determines whether two boxes differ. + /// + /// The first box. + /// The second box. + /// if the boxes differ; otherwise, . + public static bool operator !=(BoundingBox2D left, BoundingBox2D right) => !(left == right); +} diff --git a/src/SharpMath/Geometry/Exceptions/DimensionException.cs b/src/SharpMath/Geometry/Exceptions/DimensionException.cs deleted file mode 100644 index 5e0e5cd..0000000 --- a/src/SharpMath/Geometry/Exceptions/DimensionException.cs +++ /dev/null @@ -1,23 +0,0 @@ -// DimensionException.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; - -namespace SharpMath.Geometry.Exceptions -{ - /// - /// The exception that is thrown when the dimension count of two vectors is different. - /// - /// - public class DimensionException : Exception - { - /// - /// Initializes a new instance of the class. - /// - /// The message that describes the error. - public DimensionException(string message) - : base(message) - { - } - } -} \ No newline at end of file diff --git a/src/SharpMath/Geometry/IMatrix.cs b/src/SharpMath/Geometry/IMatrix.cs index 3a7c286..0119a45 100644 --- a/src/SharpMath/Geometry/IMatrix.cs +++ b/src/SharpMath/Geometry/IMatrix.cs @@ -1,13 +1,25 @@ -// IMatrix.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 +namespace SharpMath.Geometry; -namespace SharpMath.Geometry +/// +/// Represents a read-only matrix. +/// +public interface IMatrix { - public interface IMatrix - { - uint ColumnCount { get; } - double this[uint row, uint column] { get; set; } - double this[uint index] { get; set; } - uint RowCount { get; } - } -} \ No newline at end of file + /// + /// Gets the number of rows. + /// + int RowCount { get; } + + /// + /// Gets the number of columns. + /// + int ColumnCount { get; } + + /// + /// Gets the entry at the specified row and column. + /// + /// The zero-based row index. + /// The zero-based column index. + /// An index is out of range. + double this[int row, int column] { get; } +} diff --git a/src/SharpMath/Geometry/ISquareMatrix.cs b/src/SharpMath/Geometry/ISquareMatrix.cs index a071128..1b21b05 100644 --- a/src/SharpMath/Geometry/ISquareMatrix.cs +++ b/src/SharpMath/Geometry/ISquareMatrix.cs @@ -1,29 +1,68 @@ -// ISquareMatrix.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 +namespace SharpMath.Geometry; -namespace SharpMath.Geometry +/// +/// Represents a read-only square matrix. +/// +/// +/// compares with the tolerances described in . The structural +/// predicates (diagonal, triangular, symmetric) treat an entry as zero if it is negligible relative to the largest +/// entry (), so they do not depend on the scale of the matrix. +/// +public interface ISquareMatrix : IMatrix { - public interface ISquareMatrix : IMatrix - { - double Determinant { get; } - uint Dimension { get; } - bool IsAntiSymmetric { get; } - bool IsDiagonal { get; } - bool IsIdentity { get; } - bool IsOrthogonal { get; } - bool IsSingular { get; } - bool IsSkewSymmetric { get; } - bool IsSymmetric { get; } - bool IsTriangle { get; } - double Trace { get; } - } - - public interface ISquareMatrix : ISquareMatrix where T : ISquareMatrix - { - T Adjugate { get; } - T CofactorMatrix { get; } - T Inverse { get; } - T Negate { get; } - T Transpose { get; } - } -} \ No newline at end of file + /// + /// Gets the number of rows and columns. + /// + int Dimension { get; } + + /// + /// Gets the determinant. + /// + double Determinant { get; } + + /// + /// Gets the trace, i.e. the sum of the diagonal entries. + /// + double Trace { get; } + + /// + /// Gets a value indicating whether the matrix is the identity matrix. + /// + bool IsIdentity { get; } + + /// + /// Gets a value indicating whether all entries outside of the diagonal are zero. + /// + bool IsDiagonal { get; } + + /// + /// Gets a value indicating whether the matrix is upper or lower triangular. + /// + bool IsTriangular { get; } + + /// + /// Gets a value indicating whether the matrix equals its transpose. + /// + bool IsSymmetric { get; } + + /// + /// Gets a value indicating whether the matrix equals its negated transpose. + /// + bool IsSkewSymmetric { get; } + + /// + /// Gets a value indicating whether the matrix multiplied with its transpose is the identity matrix. + /// + bool IsOrthogonal { get; } + + /// + /// Gets a value indicating whether the matrix has no inverse. + /// + /// + /// The check is scale-aware and numerically robust: an LU decomposition with partial pivoting finds a pivot not + /// larger than n · ε · s, where ε is the machine epsilon of , s the + /// largest absolute entry and n the dimension. Uniformly scaled matrices such as 0.001 · I and + /// transformations with large translations are therefore regular. uses the same rule. + /// + bool IsSingular { get; } +} diff --git a/src/SharpMath/Geometry/IVector.cs b/src/SharpMath/Geometry/IVector.cs index b91e55f..e63ada7 100644 --- a/src/SharpMath/Geometry/IVector.cs +++ b/src/SharpMath/Geometry/IVector.cs @@ -1,40 +1,39 @@ -// IVector.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 +namespace SharpMath.Geometry; -namespace SharpMath.Geometry +/// +/// Represents a read-only vector of fixed dimension. +/// +public interface IVector { - public interface IVector - { - /// - /// Gets the dimension of the . - /// - uint Dimension { get; } + /// + /// Gets the number of components of the vector. + /// + int Dimension { get; } - /// - /// Gets a value indicating whether the is normalized, or not. - /// - bool IsNormalized { get; } + /// + /// Gets the component at the specified index. + /// + /// The zero-based index of the component. + /// is outside of [0, ). + double this[int index] { get; } - /// - /// Gets a value indicating whether the has all of its components set to zero, or not. - /// - bool IsZero { get; } + /// + /// Gets the Euclidean length of the vector. + /// + double Length { get; } - /// - /// Gets or sets the value of the coordinate at the specified index. - /// - /// The index. - /// The value of the coordinate at the specified index. - double this[uint index] { get; set; } + /// + /// Gets the squared Euclidean length of the vector. + /// + double LengthSquared { get; } - /// - /// Gets the length of the . - /// - double Magnitude { get; } + /// + /// Gets a value indicating whether all components are zero within . + /// + bool IsZero { get; } - /// - /// Gets the squared length of the . - /// - double SquareMagnitude { get; } - } -} \ No newline at end of file + /// + /// Gets a value indicating whether the vector has a length of 1 within the default tolerances. + /// + bool IsNormalized { get; } +} diff --git a/src/SharpMath/Geometry/IntersectionKind.cs b/src/SharpMath/Geometry/IntersectionKind.cs new file mode 100644 index 0000000..e447bec --- /dev/null +++ b/src/SharpMath/Geometry/IntersectionKind.cs @@ -0,0 +1,32 @@ +namespace SharpMath.Geometry; + +/// +/// Describes how two lines or segments intersect. +/// +public enum IntersectionKind +{ + /// + /// The objects do not intersect (e.g. distinct parallel lines or disjoint segments). + /// + None, + + /// + /// The objects intersect in exactly one point. + /// + Point, + + /// + /// The lines are identical. + /// + Coincident, + + /// + /// The three-dimensional lines are neither parallel nor intersecting. + /// + Skew, + + /// + /// The collinear segments overlap in a segment of positive length. + /// + Overlap +} diff --git a/src/SharpMath/Geometry/Line2D.cs b/src/SharpMath/Geometry/Line2D.cs index 0c9fbcb..3b2f64a 100644 --- a/src/SharpMath/Geometry/Line2D.cs +++ b/src/SharpMath/Geometry/Line2D.cs @@ -1,229 +1,268 @@ -// Line2D.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 +using System.Globalization; -using System; +namespace SharpMath.Geometry; -namespace SharpMath.Geometry +/// +/// Represents an immutable infinite line in the plane, stored in normalized general form A·x + B·y + C = 0. +/// +/// +/// The coefficients are normalized so that A² + B² = 1 and the first non-zero of A and B is +/// positive. Every line therefore has exactly one representation, so exact equality means "same line" (up to +/// rounding); use to compare lines built in different ways. Vertical +/// lines are supported. Create instances with the static factory methods; is not a valid +/// line. +/// +public readonly struct Line2D : IEquatable { + private Line2D(double a, double b, double c) + { + A = a; + B = b; + C = c; + } + /// - /// Represents a line in a 2-dimensional room. + /// Gets the normalized coefficient of x. /// - [Serializable] - public struct Line2D : IEquatable - { - /// - /// Initializes a new instance of the class. - /// - /// The slope of the . - /// The offset of the . - public Line2D(double slope, double offset) - { - Slope = slope; - Offset = offset; - } + public double A { get; } - /// - /// Initializes a new instance of the class. - /// - /// The first that should be used to create the . - /// The second that should be used to create the . - public Line2D(Point2D a, Point2D b) - { - var vectorA = a.PositionVector; - var vectorB = b.PositionVector; + /// + /// Gets the normalized coefficient of y. + /// + public double B { get; } - Slope = (vectorB.Y - vectorA.Y) / (vectorB.X - vectorA.X); - Offset = vectorA.Y - Slope * vectorA.X; // Insert a point - } + /// + /// Gets the normalized constant term. |C| is the distance of the line to the origin. + /// + public double C { get; } - /// - /// Gets or sets the slope of the . - /// - public double Slope { get; } - - /// - /// Gets or sets the offset of the . - /// - public double Offset { get; } - - /// - /// Determines whether the specified is equal to this instance. - /// - /// The to compare with this instance. - /// - /// true if the specified is equal to this instance; otherwise, false. - /// - public bool Equals(Line2D other) - { - return ReferenceEquals(other, null) - ? ReferenceEquals(this, null) - : other.Offset == Offset && other.Slope == Slope; - } + /// + /// Gets the unit normal vector (A, B). + /// + public Vector2 Normal => new(A, B); - /// - /// Creates a using the two specified instances. - /// - /// The first . - /// The second . - /// - public static Line2D FromPoints(Point2D a, Point2D b) - { - return new Line2D(a, b); - } + /// + /// Gets the unit direction vector (−B, A). + /// + public Vector2 Direction => new(-B, A); - /// - /// Determines whether the specified is on the , or not. - /// - /// The point. - /// true if the is on the ; otherwise false. - public bool IsPointOnLine(Point2D point) - { - return Math.Abs(GetPoint(point.X).Y - point.Y) < FloatingNumber.Epsilon; - } + /// + /// Gets a value indicating whether the line is parallel to the Y axis. + /// + public bool IsVertical => Tolerance.IsZero(B); - /// - /// Determines whether this is parallel to the specified . - /// - /// The other . - /// - /// true if this is parallel to the specified , otherwise - /// false. - /// - public bool IsParallelTo(Line2D line) - { - return FloatingNumber.AreApproximatelyEqual(Slope, line.Slope); - } + /// + /// Gets a value indicating whether the line is parallel to the X axis. + /// + public bool IsHorizontal => Tolerance.IsZero(A); - /// - /// Determines whether the specified instances are parallel to each other, or not. - /// - /// The first . - /// The second . - /// true if the instances are parallel to each other, otherwise false. - public static bool AreParallel(Line2D firstLine, Line2D secondLine) - { - return firstLine.IsParallelTo(secondLine); - } + /// + /// Gets the slope m of y = m·x + b, or if the line is vertical. + /// + public double Slope => IsVertical ? double.NaN : -A / B; - /// - /// Determines whether this intersects with the specified , or not. - /// - /// The other . - /// - /// true if this intersects with the specified , otherwise - /// false. - /// - public bool IntersectsWith(Line2D line) - { - return !IsParallelTo(line); - } + /// + /// Gets the y-intercept b of y = m·x + b, or if the line is vertical. + /// + public double YIntercept => IsVertical ? double.NaN : -C / B; - /// - /// Determines whether the specified instances intersect, or not. - /// - /// The first . - /// The second . - /// true if the instances intersect, otherwise false. - public static bool AreIntersecting(Line2D firstLine, Line2D secondLine) + /// + /// Creates the line a·x + b·y + c = 0. + /// + /// The coefficient of x. + /// The coefficient of y. + /// The constant term. + /// The line. + /// and are both zero, or a value is not finite. + public static Line2D FromGeneralForm(double a, double b, double c) + { + if (!double.IsFinite(a) || !double.IsFinite(b) || !double.IsFinite(c)) + throw new ArgumentException("The coefficients must be finite.", double.IsFinite(a) ? double.IsFinite(b) ? nameof(c) : nameof(b) : nameof(a)); + var norm = Math.Sqrt(a * a + b * b); + if (norm == 0) + throw new ArgumentException("The coefficients a and b must not both be zero.", nameof(b)); + + a /= norm; + b /= norm; + c /= norm; + if (a < 0 || (a == 0 && b < 0)) { - return firstLine.IntersectsWith(secondLine); + a = -a; + b = -b; + c = -c; } - /// - /// Gets the intersection of this and the specified . - /// - /// The other . - /// The intersection . - public Point2D GetIntersectionPoint(Line2D line) - { - if (IsParallelTo(line)) - return new Point2D(double.NaN, double.NaN); + // Avoid negative zeros so that equal lines also compare equal bit by bit. + return new Line2D(a + 0.0, b + 0.0, c + 0.0); + } - var pointX = (line.Offset - Offset) / (Slope - line.Slope); - return new Point2D(pointX, Slope * pointX + Offset); - } + /// + /// Creates the line y = slope·x + yIntercept. + /// + /// The slope. + /// The y-intercept. + /// The line. + /// A value is not finite. + public static Line2D FromSlopeIntercept(double slope, double yIntercept) => FromGeneralForm(slope, -1, yIntercept); - /// - /// Gets the intersection of the specified instances. - /// - /// The first . - /// The second . - /// The intersection . - public static Point2D GetIntersectionPoint(Line2D firstLine, Line2D secondLine) - { - return firstLine.GetIntersectionPoint(secondLine); - } + /// + /// Creates the line through two points. + /// + /// The first point. + /// The second point. + /// The line. + /// The points are (approximately) equal. + public static Line2D FromPoints(Point2D first, Point2D second) + { + if (first.ApproximatelyEquals(second)) + throw new ArgumentException("The points must be distinct to define a line.", nameof(second)); + return FromPointAndDirection(first, second - first); + } - /// - /// Gets the on the with the specified X-coordinate. - /// - /// The X-coordinate. - /// The with the specified X-coordinate. - public Point2D GetPoint(double x) - { - return new Point2D(x, Slope * x + Offset); - } + /// + /// Creates the line through a point with the specified direction. + /// + /// A point on the line. + /// The direction; must not be the zero vector. + /// The line. + /// is the zero vector. + public static Line2D FromPointAndDirection(Point2D point, Vector2 direction) + { + if (direction.LengthSquared == 0) + throw new ArgumentException("The direction must not be the zero vector.", nameof(direction)); + return FromGeneralForm(direction.Y, -direction.X, direction.X * point.Y - direction.Y * point.X); + } - /// - /// Determines whether the specified is equal to this instance. - /// - /// The to compare with this instance. - /// - /// true if the specified is equal to this instance; otherwise, false. - /// - public override bool Equals(object obj) - { - return ReferenceEquals(obj, null) - ? ReferenceEquals(this, null) - : obj is Line2D && ((Line2D) obj).Offset == Offset && ((Line2D) obj).Slope == Slope; - } + /// + /// Calculates y for the specified x. + /// + /// The X coordinate. + /// The Y coordinate, or if the line is vertical. + public double SolveForY(double x) => IsVertical ? double.NaN : (-C - A * x) / B; - /// - /// Returns a hash code for this instance. - /// - /// - /// A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table. - /// - public override int GetHashCode() - { - return Offset.GetHashCode() ^ Slope.GetHashCode(); - } + /// + /// Calculates x for the specified y. + /// + /// The Y coordinate. + /// The X coordinate, or if the line is horizontal. + public double SolveForX(double y) => IsHorizontal ? double.NaN : (-C - B * y) / A; - /// - /// Returns a that represents this instance. - /// - /// - /// A that represents this instance. - /// - public override string ToString() - { - return $"Line2D [Offset={Offset}; Slope={Slope}]"; - } + /// + /// Gets the point at the signed distance along , measured from + /// the point of the line closest to the origin. + /// + /// The signed distance along the line. + /// The point. + public Point2D PointAt(double distance) => ClosestPointToOrigin + Direction * distance; - /// - /// Determines whether the two specified instances are equal to each other. - /// - /// The first . - /// The to compare with the other . - /// - /// true if the two specified are equal to each other; otherwise, false. - /// - public static bool operator ==(Line2D left, Line2D right) - { - return left.Equals(right); - } + /// + /// Calculates the signed distance of a point to the line; positive on the side the points to. + /// + /// The point. + /// The signed distance. + public double SignedDistanceTo(Point2D point) => A * point.X + B * point.Y + C; - /// - /// Determines whether the two specified instances are not equal to each other. - /// - /// The first . - /// The to compare with the other . - /// - /// true if the two specified are not equal to each other; otherwise, false. - /// - public static bool operator !=(Line2D left, Line2D right) + /// + /// Calculates the distance of a point to the line. + /// + /// The point. + /// The distance. + public double DistanceTo(Point2D point) => Math.Abs(SignedDistanceTo(point)); + + /// + /// Calculates the point on the line that is closest to the specified point. + /// + /// The point. + /// The orthogonal projection of onto the line. + public Point2D ClosestPoint(Point2D point) => point - Normal * SignedDistanceTo(point); + + /// + /// Determines whether a point lies on the line within the default tolerances. + /// + /// The point. + /// if the point lies on the line; otherwise, . + public bool Contains(Point2D point) => + Tolerance.IsNegligible(SignedDistanceTo(point), + Math.Max(Math.Abs(C), Math.Max(Math.Abs(point.X), Math.Abs(point.Y)))); + + /// + /// Determines whether this line is parallel to (or identical with) another line. + /// + /// The other line. + /// if the lines are parallel; otherwise, . + public bool IsParallelTo(Line2D other) => Tolerance.IsNegligible(ParallelDeterminant(other), 1); + + /// + /// Intersects this line with another line. + /// + /// The other line. + /// The kind of intersection and, for a single intersection point, the point. + public LineIntersection2D Intersect(Line2D other) + { + var determinant = ParallelDeterminant(other); + if (Tolerance.IsNegligible(determinant, 1)) { - return !left.Equals(right); + return Contains(other.ClosestPointToOrigin) + ? new LineIntersection2D(IntersectionKind.Coincident, null) + : new LineIntersection2D(IntersectionKind.None, null); } + + var x = (B * other.C - other.B * C) / determinant; + var y = (other.A * C - A * other.C) / determinant; + return new LineIntersection2D(IntersectionKind.Point, new Point2D(x, y)); } -} \ No newline at end of file + + /// + /// Determines whether this line equals another line within the default tolerances (coefficient-wise). + /// + /// The other line. + /// if the lines are approximately equal; otherwise, . + public bool ApproximatelyEquals(Line2D other) => + ApproximatelyEquals(other, Tolerance.DefaultAbsolute, Tolerance.DefaultRelative); + + /// + /// Determines whether this line equals another line within the specified tolerances (coefficient-wise). + /// + /// The other line. + /// The absolute tolerance. + /// The relative tolerance. + /// if the lines are approximately equal; otherwise, . + public bool ApproximatelyEquals(Line2D other, double absolute, double relative) => + Tolerance.AreEqual(A, other.A, absolute, relative) && Tolerance.AreEqual(B, other.B, absolute, relative) && + Tolerance.AreEqual(C, other.C, absolute, relative); + + /// + public override string ToString() => + string.Create(CultureInfo.InvariantCulture, $"Line2D [{A}·x + {B}·y + {C} = 0]"); + + /// + public bool Equals(Line2D other) => A.Equals(other.A) && B.Equals(other.B) && C.Equals(other.C); + + /// + public override bool Equals(object? obj) => obj is Line2D other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(A, B, C); + + /// + /// Determines whether two lines have exactly the same normalized coefficients. + /// + /// The first line. + /// The second line. + /// if the lines are equal; otherwise, . + public static bool operator ==(Line2D left, Line2D right) => + left.A == right.A && left.B == right.B && left.C == right.C; + + /// + /// Determines whether two lines differ. + /// + /// The first line. + /// The second line. + /// if the lines differ; otherwise, . + public static bool operator !=(Line2D left, Line2D right) => !(left == right); + + private Point2D ClosestPointToOrigin => new(-C * A, -C * B); + + // Zero exactly when the (unit) normals are parallel. + private double ParallelDeterminant(Line2D other) => A * other.B - B * other.A; +} diff --git a/src/SharpMath/Geometry/Line3D.cs b/src/SharpMath/Geometry/Line3D.cs index a10b255..f19d34f 100644 --- a/src/SharpMath/Geometry/Line3D.cs +++ b/src/SharpMath/Geometry/Line3D.cs @@ -1,154 +1,212 @@ -// Line3D.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 +using System.Globalization; -using System; +namespace SharpMath.Geometry; -namespace SharpMath.Geometry +/// +/// Represents an immutable infinite line in three-dimensional space. +/// +/// +/// The line is stored canonically: is the point of the line closest to the origin and +/// is a unit vector whose first non-zero component is positive. Every line therefore has +/// exactly one representation, so exact equality means "same line" (up to rounding); use +/// to compare lines built in different ways. Create instances with the +/// static factory methods; is not a valid line. +/// +public readonly struct Line3D : IEquatable { + private Line3D(Point3D point, Vector3 direction) + { + Point = point; + Direction = direction; + } + + /// + /// Gets the point of the line that is closest to the origin. + /// + public Point3D Point { get; } + + /// + /// Gets the unit direction of the line. + /// + public Vector3 Direction { get; } + /// - /// Represents a 3D-line. + /// Creates the line through two points. /// - [Serializable] - public struct Line3D : IEquatable + /// The first point. + /// The second point. + /// The line. + /// The points are (approximately) equal. + public static Line3D FromPoints(Point3D first, Point3D second) { - /// - /// Initializes a new instance of the class. - /// - /// A point on the . - /// The direction . - public Line3D(Point3D point, Vector3 direction) - { - Point = point; - if (direction == Vector3.Zero) - throw new InvalidOperationException("The direction vector of the line must not be the zero vector."); - Direction = direction; - } - - /// - /// Gets or sets a that represents the direction of the . - /// - public Vector3 Direction { get; } - - /// - /// Gets or sets a point on the that the line intersects with. - /// - public Point3D Point { get; } - - /// - /// Determines whether the specified is equal to this instance. - /// - /// The to compare with this instance. - /// - /// true if the specified is equal to this instance; otherwise, false. - /// - public bool Equals(Line3D other) - { - return ReferenceEquals(other, null) - ? ReferenceEquals(this, null) - : other.Direction == Direction && other.Point == Point; - } - - /// - /// Creates a from two instances. - /// - /// The first . - /// The second . - /// The created . - public static Line3D FromPoints(Point3D firstPoint, Point3D secondPoint) - { - return new Line3D(firstPoint, (secondPoint - firstPoint).PositionVector); - } - - /// - /// Gets a on the . - /// - /// The scalar that the direction vector should be multiplied with. - /// The relating on the . - public Point3D GetPoint(double lambda) - { - var resultVector = Point.PositionVector + Direction * lambda; - return new Point3D(resultVector.X, resultVector.Y, resultVector.Z); - } - - /// - /// Determines whether the specified is on the , or not. - /// - /// The . - /// true, if the is on the ; otherwise false. - public bool IsPointOnLine(Point3D point) - { - double lambda = 0; - for (uint i = 0; i < 3; ++i) - { - var value = (point[i] - Point[i]) / Direction[i]; - if (i != 0 && !FloatingNumber.AreApproximatelyEqual(value, lambda)) - return false; - lambda = value; - } - - return true; - } - - /// - /// Determines whether the specified is equal to this instance. - /// - /// The to compare with this instance. - /// - /// true if the specified is equal to this instance; otherwise, false. - /// - public override bool Equals(object obj) - { - return ReferenceEquals(obj, null) - ? ReferenceEquals(this, null) - : obj is Line3D && ((Line3D) obj).Direction == Direction && ((Line3D) obj).Point == Point; - } - - /// - /// Returns a hash code for this instance. - /// - /// - /// A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table. - /// - public override int GetHashCode() - { - return Direction.GetHashCode() ^ Point.GetHashCode(); - } - - /// - /// Returns a that represents this instance. - /// - /// - /// A that represents this instance. - /// - public override string ToString() - { - return string.Format("Line3D [Direction={0}; Point={1}]", Direction, Point); - } - - /// - /// Determines whether the two specified instances are equal to each other. - /// - /// The first . - /// The to compare with the other . - /// - /// true if the two specified are equal to each other; otherwise, false. - /// - public static bool operator ==(Line3D left, Line3D right) - { - return left.Equals(right); - } - - /// - /// Determines whether the two specified instances are not equal to each other. - /// - /// The first . - /// The to compare with the other . - /// - /// true if the two specified are not equal to each other; otherwise, false. - /// - public static bool operator !=(Line3D left, Line3D right) - { - return !left.Equals(right); - } + if (first.ApproximatelyEquals(second)) + throw new ArgumentException("The points must be distinct to define a line.", nameof(second)); + return FromPointAndDirection(first, second - first); } -} \ No newline at end of file + + /// + /// Creates the line through a point with the specified direction. + /// + /// A point on the line. + /// The direction; must not be the zero vector. + /// The line. + /// is the zero vector or not finite. + public static Line3D FromPointAndDirection(Point3D point, Vector3 direction) + { + if (direction.LengthSquared == 0 || !double.IsFinite(direction.LengthSquared)) + throw new ArgumentException("The direction must be a finite, non-zero vector.", nameof(direction)); + + var unit = direction.Normalize(); + if (unit.X < 0 || (unit.X == 0 && (unit.Y < 0 || (unit.Y == 0 && unit.Z < 0)))) + unit = -unit; + unit = new Vector3(unit.X + 0.0, unit.Y + 0.0, unit.Z + 0.0); + + var position = point.ToVector(); + var closest = position - unit * position.Dot(unit); + return new Line3D(new Point3D(closest.X + 0.0, closest.Y + 0.0, closest.Z + 0.0), unit); + } + + /// + /// Gets the point at the signed distance along , measured from + /// . + /// + /// The signed distance along the line. + /// The point. + public Point3D PointAt(double distance) => Point + Direction * distance; + + /// + /// Calculates the point on the line that is closest to the specified point. + /// + /// The point. + /// The orthogonal projection of onto the line. + public Point3D ClosestPoint(Point3D point) => Point + Direction * (point - Point).Dot(Direction); + + /// + /// Calculates the distance of a point to the line. + /// + /// The point. + /// The distance. + public double DistanceTo(Point3D point) => (point - Point).Cross(Direction).Length; + + /// + /// Determines whether a point lies on the line within the default tolerances. + /// + /// The point. + /// if the point lies on the line; otherwise, . + public bool Contains(Point3D point) => + Tolerance.IsNegligible(DistanceTo(point), Math.Max(point.ToVector().Length, Point.ToVector().Length)); + + /// + /// Determines whether this line is parallel to (or identical with) another line. + /// + /// The other line. + /// if the lines are parallel; otherwise, . + public bool IsParallelTo(Line3D other) => Tolerance.IsNegligible(Direction.Cross(other.Direction).Length, 1); + + /// + /// Calculates the shortest distance between this line and another line. + /// + /// The other line. + /// The distance; 0 if the lines intersect. + public double DistanceTo(Line3D other) + { + var (onThis, onOther) = ClosestPoints(other); + return onThis.Distance(onOther); + } + + /// + /// Calculates the pair of points, one on each line, that are closest to each other. + /// + /// The other line. + /// + /// The closest points. For parallel lines, of this line and its projection onto the other line + /// are returned. + /// + public (Point3D OnThis, Point3D OnOther) ClosestPoints(Line3D other) + { + if (IsParallelTo(other)) + return (Point, other.ClosestPoint(Point)); + + // The connection of the closest points is perpendicular to both directions, i.e. parallel to n = d × e. + // |n|² is computed directly instead of as 1 − (d·e)², which cancels for nearly parallel lines. + var d = Direction; + var e = other.Direction; + var n = d.Cross(e); + var denominator = n.LengthSquared; + var w = other.Point - Point; + var s = w.Cross(e).Dot(n) / denominator; + var t = w.Cross(d).Dot(n) / denominator; + return (Point + d * s, other.Point + e * t); + } + + /// + /// Intersects this line with another line. + /// + /// The other line. + /// The kind of intersection and, for a single intersection point, the point. + public LineIntersection3D Intersect(Line3D other) + { + if (IsParallelTo(other)) + return Contains(other.Point) + ? new LineIntersection3D(IntersectionKind.Coincident, null) + : new LineIntersection3D(IntersectionKind.None, null); + + var (onThis, onOther) = ClosestPoints(other); + var scale = Math.Max(onThis.ToVector().Length, onOther.ToVector().Length); + if (!Tolerance.IsNegligible(onThis.Distance(onOther), scale)) + return new LineIntersection3D(IntersectionKind.Skew, null); + + var midpoint = new Point3D((onThis.X + onOther.X) / 2, (onThis.Y + onOther.Y) / 2, (onThis.Z + onOther.Z) / 2); + return new LineIntersection3D(IntersectionKind.Point, midpoint); + } + + /// + /// Determines whether this line equals another line within the default tolerances. + /// + /// The other line. + /// if the lines are approximately equal; otherwise, . + public bool ApproximatelyEquals(Line3D other) => + ApproximatelyEquals(other, Tolerance.DefaultAbsolute, Tolerance.DefaultRelative); + + /// + /// Determines whether this line equals another line within the specified tolerances. + /// + /// The other line. + /// The absolute tolerance. + /// The relative tolerance. + /// if the lines are approximately equal; otherwise, . + public bool ApproximatelyEquals(Line3D other, double absolute, double relative) => + Point.ApproximatelyEquals(other.Point, absolute, relative) && + Direction.ApproximatelyEquals(other.Direction, absolute, relative); + + /// + public override string ToString() => + string.Create(CultureInfo.InvariantCulture, + $"Line3D [({Point.X}, {Point.Y}, {Point.Z}) + t·({Direction.X}, {Direction.Y}, {Direction.Z})]"); + + /// + public bool Equals(Line3D other) => Point.Equals(other.Point) && Direction.Equals(other.Direction); + + /// + public override bool Equals(object? obj) => obj is Line3D other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(Point, Direction); + + /// + /// Determines whether two lines have exactly the same canonical representation. + /// + /// The first line. + /// The second line. + /// if the lines are equal; otherwise, . + public static bool operator ==(Line3D left, Line3D right) => + left.Point == right.Point && left.Direction == right.Direction; + + /// + /// Determines whether two lines differ. + /// + /// The first line. + /// The second line. + /// if the lines differ; otherwise, . + public static bool operator !=(Line3D left, Line3D right) => !(left == right); +} diff --git a/src/SharpMath/Geometry/LineIntersection2D.cs b/src/SharpMath/Geometry/LineIntersection2D.cs new file mode 100644 index 0000000..5379568 --- /dev/null +++ b/src/SharpMath/Geometry/LineIntersection2D.cs @@ -0,0 +1,11 @@ +namespace SharpMath.Geometry; + +/// +/// The result of intersecting two s. +/// +/// +/// How the lines intersect: , or +/// . +/// +/// The intersection point if is ; otherwise, . +public readonly record struct LineIntersection2D(IntersectionKind Kind, Point2D? Point); diff --git a/src/SharpMath/Geometry/LineIntersection3D.cs b/src/SharpMath/Geometry/LineIntersection3D.cs new file mode 100644 index 0000000..a4770fb --- /dev/null +++ b/src/SharpMath/Geometry/LineIntersection3D.cs @@ -0,0 +1,12 @@ +namespace SharpMath.Geometry; + +/// +/// The result of intersecting two s. +/// +/// +/// How the lines intersect: (distinct parallel lines), +/// , or +/// . +/// +/// The intersection point if is ; otherwise, . +public readonly record struct LineIntersection3D(IntersectionKind Kind, Point3D? Point); diff --git a/src/SharpMath/Geometry/LineSegment2D.cs b/src/SharpMath/Geometry/LineSegment2D.cs new file mode 100644 index 0000000..11e039d --- /dev/null +++ b/src/SharpMath/Geometry/LineSegment2D.cs @@ -0,0 +1,216 @@ +using System.Globalization; + +namespace SharpMath.Geometry; + +/// +/// Represents an immutable line segment between two points in the plane. +/// +/// +/// A segment whose end points coincide is allowed and behaves like a point. Equality is exact and depends on the +/// order of the end points. +/// +public readonly struct LineSegment2D : IEquatable +{ + /// + /// Initializes a new instance of the struct. + /// + /// The start point. + /// The end point. + public LineSegment2D(Point2D start, Point2D end) + { + Start = start; + End = end; + } + + /// + /// Gets the start point. + /// + public Point2D Start { get; } + + /// + /// Gets the end point. + /// + public Point2D End { get; } + + /// + /// Gets the vector from to . + /// + public Vector2 Direction => End - Start; + + /// + /// Gets the length. + /// + public double Length => Direction.Length; + + /// + /// Gets the midpoint. + /// + public Point2D Midpoint => new((Start.X + End.X) / 2, (Start.Y + End.Y) / 2); + + /// + /// Returns the infinite line through the segment. + /// + /// The line. + /// The end points coincide. + public Line2D ToLine() => + IsDegenerate + ? throw new InvalidOperationException("A segment of length zero does not define a line.") + : Line2D.FromPointAndDirection(Start, Direction); + + /// + /// Calculates the point on the segment that is closest to the specified point. + /// + /// The point. + /// The closest point. + public Point2D ClosestPoint(Point2D point) => Start + Direction * ClosestParameter(point); + + /// + /// Calculates the distance of a point to the segment. + /// + /// The point. + /// The distance. + public double DistanceTo(Point2D point) => point.Distance(ClosestPoint(point)); + + /// + /// Determines whether a point lies on the segment within the default tolerances. + /// + /// The point. + /// if the point lies on the segment; otherwise, . + public bool Contains(Point2D point) => + SegmentMath.IsOnSegment(point, Start, End, SegmentMath.DistanceTolerance(Scale(point))); + + /// + /// Intersects this segment with another segment. + /// + /// The other segment. + /// The kind of intersection and the intersection point or overlapping segment. + public SegmentIntersection2D Intersect(LineSegment2D other) + { + if (IsDegenerate) + return other.Contains(Start) ? PointResult(Start) : NoIntersection; + if (other.IsDegenerate) + return Contains(other.Start) ? PointResult(other.Start) : NoIntersection; + + var r = Direction; + var s = other.Direction; + var offset = other.Start - Start; + var denominator = r.Cross(s); + + // Parallelism is judged by the angle, distances by the coordinate scale, so tiny segments behave like big ones. + if (r.IsParallelTo(s)) + { + var distanceToLine = Math.Abs(offset.Cross(r)) / r.Length; + if (distanceToLine > SegmentMath.DistanceTolerance(Scale(other.Start))) + return NoIntersection; + + var lengthSquared = r.LengthSquared; + var t0 = offset.Dot(r) / lengthSquared; + var t1 = t0 + s.Dot(r) / lengthSquared; + var from = Math.Max(0, Math.Min(t0, t1)); + var to = Math.Min(1, Math.Max(t0, t1)); + var tolerance = Tolerance.DefaultRelative; + if (from > to + tolerance) + return NoIntersection; + if (to - from <= tolerance) + return PointResult(Start + r * ((from + to) / 2)); + return new SegmentIntersection2D(IntersectionKind.Overlap, null, + new LineSegment2D(Start + r * from, Start + r * to)); + } + + var t = offset.Cross(s) / denominator; + var u = offset.Cross(r) / denominator; + var epsilon = Tolerance.DefaultRelative; + if (t < -epsilon || t > 1 + epsilon || u < -epsilon || u > 1 + epsilon) + return NoIntersection; + return PointResult(Start + r * Math.Clamp(t, 0, 1)); + } + + /// + /// Intersects this segment with an infinite line. + /// + /// The line. + /// + /// The kind of intersection: (with this segment) if the segment lies on the + /// line, a point, or none. + /// + public SegmentIntersection2D Intersect(Line2D line) + { + if (IsDegenerate) + return line.Contains(Start) ? PointResult(Start) : NoIntersection; + + var intersection = ToLine().Intersect(line); + return intersection.Kind switch + { + IntersectionKind.Coincident => new SegmentIntersection2D(IntersectionKind.Overlap, null, this), + IntersectionKind.Point when Contains(intersection.Point.GetValueOrDefault()) => + PointResult(ClosestPoint(intersection.Point.GetValueOrDefault())), + _ => NoIntersection + }; + } + + /// + /// Determines whether this segment equals another segment within the default tolerances (same order of end points). + /// + /// The other segment. + /// if the segments are approximately equal; otherwise, . + public bool ApproximatelyEquals(LineSegment2D other) => + ApproximatelyEquals(other, Tolerance.DefaultAbsolute, Tolerance.DefaultRelative); + + /// + /// Determines whether this segment equals another segment within the specified tolerances (same order of end + /// points). + /// + /// The other segment. + /// The absolute tolerance. + /// The relative tolerance. + /// if the segments are approximately equal; otherwise, . + public bool ApproximatelyEquals(LineSegment2D other, double absolute, double relative) => + Start.ApproximatelyEquals(other.Start, absolute, relative) && End.ApproximatelyEquals(other.End, absolute, relative); + + /// + public override string ToString() => + string.Create(CultureInfo.InvariantCulture, $"LineSegment2D [({Start.X}, {Start.Y}) → ({End.X}, {End.Y})]"); + + /// + public bool Equals(LineSegment2D other) => Start.Equals(other.Start) && End.Equals(other.End); + + /// + public override bool Equals(object? obj) => obj is LineSegment2D other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(Start, End); + + /// + /// Determines whether two segments are equal. + /// + /// The first segment. + /// The second segment. + /// if the segments are equal; otherwise, . + public static bool operator ==(LineSegment2D left, LineSegment2D right) => + left.Start == right.Start && left.End == right.End; + + /// + /// Determines whether two segments differ. + /// + /// The first segment. + /// The second segment. + /// if the segments differ; otherwise, . + public static bool operator !=(LineSegment2D left, LineSegment2D right) => !(left == right); + + private static SegmentIntersection2D NoIntersection => new(IntersectionKind.None, null, null); + + private bool IsDegenerate => Start == End; + + private static SegmentIntersection2D PointResult(Point2D point) => new(IntersectionKind.Point, point, null); + + private double ClosestParameter(Point2D point) + { + var direction = Direction; + var lengthSquared = direction.LengthSquared; + return lengthSquared == 0 ? 0 : Math.Clamp((point - Start).Dot(direction) / lengthSquared, 0, 1); + } + + private double Scale(Point2D point) => + Math.Max(SegmentMath.CoordinateScale(point), + Math.Max(SegmentMath.CoordinateScale(Start), SegmentMath.CoordinateScale(End))); +} diff --git a/src/SharpMath/Geometry/Matrix1x1.cs b/src/SharpMath/Geometry/Matrix1x1.cs deleted file mode 100644 index e6d6bc9..0000000 --- a/src/SharpMath/Geometry/Matrix1x1.cs +++ /dev/null @@ -1,310 +0,0 @@ -// Matrix1x1.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; -using System.Collections; -using System.Collections.Generic; - -// ReSharper disable InconsistentNaming - -namespace SharpMath.Geometry -{ - /// - /// Represents a 1x1 matrix. - /// - [Serializable] - public struct Matrix1x1 : IEnumerable, ISquareMatrix, IEquatable - { - /// - /// Initializes a struct. - /// - /// The value at row 1 and column 1. - public Matrix1x1(double m11) - { - M11 = m11; - } - - /// - /// Gets the zero . - /// - public static Matrix1x1 Zero => new Matrix1x1(); - - /// - /// Gets the identity . - /// - public static Matrix1x1 Identity => MatrixUtils.GetIdentity(); - - /// - /// Gets or sets the value at row 1 and column 1. - /// - public double M11 { get; set; } - - /// - /// Gets a value indicating whether the is singular, or not. If true, this - /// doesn't have an inverse. - /// - public bool IsSingular => Determinant.IsApproximatelyEqualTo(0); - - /// - /// Gets the inverse of the . - /// - public Matrix1x1 Inverse => MatrixUtils.GaussJordan(this, Identity); - - /// - /// Gets the cofactor of the . - /// - public Matrix1x1 CofactorMatrix => this.BuildCofactorMatrix(); - - /// - /// Gets the adjugate of the . - /// - public Matrix1x1 Adjugate => CofactorMatrix.Transpose; - - /// - /// Gets the negated of the . - /// - public Matrix1x1 Negate => this.GetNegate(); - - /// - /// Gets the transpose of the . - /// - public Matrix1x1 Transpose => this.GetTranspose(); - - public IEnumerator GetEnumerator() - { - return new MatrixEnumerator(this); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return new MatrixEnumerator(this); - } - - public bool Equals(Matrix1x1 other) - { - return this == other; - } - - /// - /// Gets or sets the value at the specified index. The values are accessed as: row + column. - /// - /// The index. - /// The value at the specified index. - public double this[uint index] - { - get - { - switch (index) - { - case 0: - return M11; - default: - throw new IndexOutOfRangeException("The index must be 0."); - } - } - - set - { - switch (index) - { - case 0: - M11 = value; - break; - default: - throw new IndexOutOfRangeException("The index must be 0."); - } - } - } - - /// - /// Gets or sets the value at the specified row and column. - /// - /// The row. - /// The column. - /// The value at the specified row and column. - public double this[uint row, uint column] - { - get => this[row + column]; - set => this[row + column] = value; - } - - /// - /// Gets the dimension of the . - /// - public uint Dimension => 1; - - /// - /// Gets the row count of the . - /// - public uint RowCount => 1; - - /// - /// Gets the column count of the . - /// - public uint ColumnCount => 1; - - /// - /// Gets a value indicating whether the is orthogonal, or not. - /// - public bool IsOrthogonal => this * Transpose == Identity; - - /// - /// Gets a value indicating whether the is the identity , or not. - /// - public bool IsIdentity => M11.IsApproximatelyEqualTo(1); - - /// - /// Gets the determinant of the . - /// - public double Determinant => this.GetDeterminant(); // Determinant == M11 - - /// - /// Gets the trace of the . - /// - public double Trace => M11; - - /// - /// Gets a value indicating whether the is symmetric, or not. - /// - public bool IsSymmetric => this == Transpose; - - /// - /// Gets a value indicating whether the is skew symmetric, or not. - /// - public bool IsSkewSymmetric => Negate == Transpose; - - /// - /// Gets a value indicating whether the is antisymmetric, or not. - /// - public bool IsAntiSymmetric => this == Transpose.Negate; - - /// - /// Gets a value indicating whether the is a diagonal matrix, or not. - /// - public bool IsDiagonal => false; - - /// - /// Gets a value indicating whether the is a triangle matrix, or not. - /// - public bool IsTriangle => false; - - /// - /// Implements the operator +. - /// - /// The first . - /// The second . - /// - /// The result of the operator. - /// - public static Matrix1x1 operator +(Matrix1x1 firstMatrix, Matrix1x1 secondMatrix) - { - return MatrixUtils.Add(firstMatrix, secondMatrix); - } - - /// - /// Implements the operator -. - /// - /// The first . - /// The second . - /// - /// The result of the operator. - /// - public static Matrix1x1 operator -(Matrix1x1 firstMatrix, Matrix1x1 secondMatrix) - { - return MatrixUtils.Subtract(firstMatrix, secondMatrix); - } - - /// - /// Implements the operator * to multiply a with the specified scalar. - /// - /// The scalar. - /// The . - /// - /// The result of the operator. - /// - public static Matrix1x1 operator *(double scalar, Matrix1x1 matrix) - { - return matrix.Multiply(scalar); - } - - /// - /// Implements the operator * to multiply a with the specified scalar. - /// - /// The . - /// The scalar. - /// - /// The result of the operator. - /// - public static Matrix1x1 operator *(Matrix1x1 matrix, double scalar) - { - return matrix.Multiply(scalar); - } - - /// - /// Implements the operator *. - /// - /// The first . - /// The second . - /// - /// The result of the operator. - /// - public static Matrix1x1 operator *(Matrix1x1 firstMatrix, Matrix1x1 secondMatrix) - { - return MatrixUtils.Multiply(firstMatrix, secondMatrix); - } - - /// - /// Determines whether the specified , is equal to the instance. - /// - /// The to compare with the instance. - /// - /// true if the specified is equal to the instance; otherwise, false. - /// - public override bool Equals(object obj) - { - if (!(obj is Matrix1x1)) - return false; - return this == (Matrix1x1) obj; - } - - /// - /// Returns a hash code for the instance. - /// - /// - /// A hash code for the instance, suitable for use in hashing algorithms and data structures like a hash table. - /// - public override int GetHashCode() - { - unchecked - { - return 17 * 23 + this[0, 0].GetHashCode(); - } - } - - /// - /// Implements the operator ==. - /// - /// The left . - /// The right . - /// - /// The result of the operator. - /// - public static bool operator ==(Matrix1x1 left, Matrix1x1 right) - { - return FloatingNumber.AreApproximatelyEqual(left[0, 0], right[0, 0]); - } - - /// - /// Implements the operator !=. - /// - /// The left . - /// The right . - /// - /// The result of the operator. - /// - public static bool operator !=(Matrix1x1 left, Matrix1x1 right) - { - return !FloatingNumber.AreApproximatelyEqual(left[0, 0], right[0, 0]); - } - } -} \ No newline at end of file diff --git a/src/SharpMath/Geometry/Matrix1x3.cs b/src/SharpMath/Geometry/Matrix1x3.cs deleted file mode 100644 index cf025c2..0000000 --- a/src/SharpMath/Geometry/Matrix1x3.cs +++ /dev/null @@ -1,106 +0,0 @@ -// Matrix1x3.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; -using System.Collections; -using System.Collections.Generic; - -// ReSharper disable InconsistentNaming - -namespace SharpMath.Geometry -{ - /// - /// Represents a 1x3 matrix for representing three-dimensional vectors horizontally. - /// - [Serializable] - public struct Matrix1x3 : IEnumerable, IMatrix - { - /// - /// Gets the value at row 1 and column 1. - /// - public double M11 { get; set; } - - /// - /// Gets the value at row 1 and column 2. - /// - public double M12 { get; set; } - - /// - /// Gets the value at row 1 and column 3. - /// - public double M13 { get; set; } - - public IEnumerator GetEnumerator() - { - return new MatrixEnumerator(this); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return new MatrixEnumerator(this); - } - - /// - /// Gets or sets the value at the specified index. The values are accessed as: (row +) column. - /// - /// The index. - /// The value at the specified index. - public double this[uint index] - { - get - { - switch (index) - { - case 0: - return M11; - case 1: - return M12; - case 2: - return M13; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 2."); - } - } - set - { - switch (index) - { - case 0: - M11 = value; - break; - case 1: - M12 = value; - break; - case 2: - M13 = value; - break; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 2."); - } - } - } - - /// - /// Gets or sets the value at the specified row and column. - /// - /// The row. - /// The column. - /// The value at the specified row and column. - public double this[uint row, uint column] - { - get => this[row + column]; - - set => this[row + column] = value; - } - - /// - /// Gets the column count of the . - /// - public uint ColumnCount => 3; - - /// - /// Gets the row count of the . - /// - public uint RowCount => 1; - } -} \ No newline at end of file diff --git a/src/SharpMath/Geometry/Matrix1x4.cs b/src/SharpMath/Geometry/Matrix1x4.cs deleted file mode 100644 index affa01c..0000000 --- a/src/SharpMath/Geometry/Matrix1x4.cs +++ /dev/null @@ -1,116 +0,0 @@ -// Matrix1x4.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; -using System.Collections; -using System.Collections.Generic; - -// ReSharper disable InconsistentNaming - -namespace SharpMath.Geometry -{ - /// - /// Represents a 1x4 matrix for representing four-dimensional vectors horizontally. - /// - [Serializable] - public struct Matrix1x4 : IEnumerable, IMatrix - { - /// - /// Gets the value at row 1 and column 1. - /// - public double M11 { get; set; } - - /// - /// Gets the value at row 1 and column 2. - /// - public double M12 { get; set; } - - /// - /// Gets the value at row 1 and column 3. - /// - public double M13 { get; set; } - - /// - /// Gets the value at row 1 and column 4. - /// - public double M14 { get; set; } - - public IEnumerator GetEnumerator() - { - return new MatrixEnumerator(this); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return new MatrixEnumerator(this); - } - - /// - /// Gets or sets the value at the specified index. The values are accessed as: (row +) column. - /// - /// The index. - /// The value at the specified index. - public double this[uint index] - { - get - { - switch (index) - { - case 0: - return M11; - case 1: - return M12; - case 2: - return M13; - case 3: - return M14; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 3."); - } - } - set - { - switch (index) - { - case 0: - M11 = value; - break; - case 1: - M12 = value; - break; - case 2: - M13 = value; - break; - case 3: - M14 = value; - break; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 3."); - } - } - } - - /// - /// Gets or sets the value at the specified row and column. - /// - /// The row. - /// The column. - /// The value at the specified row and column. - public double this[uint row, uint column] - { - get => this[row + column]; - - set => this[row + column] = value; - } - - /// - /// Gets the column count of the . - /// - public uint ColumnCount => 4; - - /// - /// Gets the row count of the . - /// - public uint RowCount => 1; - } -} \ No newline at end of file diff --git a/src/SharpMath/Geometry/Matrix2x2.cs b/src/SharpMath/Geometry/Matrix2x2.cs index 658ca14..04be2c5 100644 --- a/src/SharpMath/Geometry/Matrix2x2.cs +++ b/src/SharpMath/Geometry/Matrix2x2.cs @@ -1,351 +1,356 @@ -// Matrix2x2.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 +namespace SharpMath.Geometry; + +/// +/// Represents an immutable 2x2 matrix. +/// +/// +/// Vectors are column vectors: a matrix transforms a vector as matrix * vector. Equality is exact; use +/// for tolerance-based comparisons. +/// +public readonly struct Matrix2x2 : ISquareMatrix, IEquatable +{ + /// + /// Initializes a new instance of the struct from its entries in row-major order. + /// + /// The entry in row 1, column 1. + /// The entry in row 1, column 2. + /// The entry in row 2, column 1. + /// The entry in row 2, column 2. + public Matrix2x2(double m11, double m12, double m21, double m22) + { + M11 = m11; + M12 = m12; + M21 = m21; + M22 = m22; + } + + /// + /// Initializes a new instance of the struct from its rows. + /// + /// The first row. + /// The second row. + public Matrix2x2(Vector2 row1, Vector2 row2) : this(row1.X, row1.Y, row2.X, row2.Y) + { + } -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; + /// + /// Gets the entry in row 1, column 1. + /// + public double M11 { get; } -// ReSharper disable InconsistentNaming + /// + /// Gets the entry in row 1, column 2. + /// + public double M12 { get; } + + /// + /// Gets the entry in row 2, column 1. + /// + public double M21 { get; } -namespace SharpMath.Geometry -{ /// - /// Represents a 2x2 matrix. + /// Gets the entry in row 2, column 2. /// - [Serializable] - public struct Matrix2x2 : IEnumerable, ISquareMatrix + public double M22 { get; } + + /// + /// Gets the zero matrix. + /// + public static Matrix2x2 Zero => default; + + /// + /// Gets the identity matrix. + /// + public static Matrix2x2 Identity => new(1, 0, 0, 1); + + /// + /// Gets the entry at the specified row-major index (row · 2 + column). + /// + /// The zero-based index between 0 and 3. + /// is out of range. + public double this[int index] => index switch { - /// - /// Initializes a struct. - /// - /// The value at row 1 and column 1. - /// The value at row 1 and column 2. - /// The value at row 2 and column 1. - /// The value at row 2 and column 2. - public Matrix2x2(double m11, double m12, double m21, double m22) + 0 => M11, + 1 => M12, + 2 => M21, + 3 => M22, + _ => throw MatrixMath.IndexOutOfRange(nameof(index), index, 4) + }; + + /// + public double this[int row, int column] + { + get { - M11 = m11; - M12 = m12; - M21 = m21; - M22 = m22; + MatrixMath.CheckIndices(row, column, 2); + return this[row * 2 + column]; } + } - /// - /// Gets the zero . - /// - public static Matrix2x2 Zero => new Matrix2x2(); - - /// - /// Gets the identity . - /// - public static Matrix2x2 Identity => MatrixUtils.GetIdentity(); - - /// - /// Gets or sets the value at row 1 and column 1. - /// - public double M11 { get; set; } - - /// - /// Gets or sets the value at row 1 and column 2. - /// - public double M12 { get; set; } - - /// - /// Gets or sets the value at row 2 and column 1. - /// - public double M21 { get; set; } - - /// - /// Gets or sets the value at row 2 and column 2. - /// - public double M22 { get; set; } - - /// - /// Gets a value indicating whether the is singular, or not. If true, this - /// doesn't have an inverse. - /// - public bool IsSingular => Determinant.IsApproximatelyEqualTo(0); - - /// - /// Gets the inverse of the . - /// - public Matrix2x2 Inverse => MatrixUtils.GaussJordan(this, Identity); - - /// - /// Gets the cofactor of the . - /// - public Matrix2x2 CofactorMatrix => this.BuildCofactorMatrix(); - - /// - /// Gets the adjugate of the . - /// - public Matrix2x2 Adjugate => CofactorMatrix.Transpose; - - /// - /// Gets the negated of the . - /// - public Matrix2x2 Negate => this.GetNegate(); - - /// - /// Gets the transpose of the . - /// - public Matrix2x2 Transpose => this.GetTranspose(); - - public IEnumerator GetEnumerator() - { - return new MatrixEnumerator(this); - } + /// + public int RowCount => 2; - IEnumerator IEnumerable.GetEnumerator() - { - return new MatrixEnumerator(this); - } + /// + public int ColumnCount => 2; - /// - /// Gets or sets the value at the specified index. The values are accessed as: (row*2) + column. - /// - /// The index. - /// The value at the specified index. - public double this[uint index] - { - get - { - switch (index) - { - case 0: - return M11; - case 1: - return M12; - case 2: - return M21; - case 3: - return M22; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 3."); - } - } - - set - { - switch (index) - { - case 0: - M11 = value; - break; - case 1: - M12 = value; - break; - case 2: - M21 = value; - break; - case 3: - M22 = value; - break; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 3."); - } - } - } + /// + public int Dimension => 2; - /// - /// Gets or sets the value at the specified row and column. - /// - /// The row. - /// The column. - /// The value at the specified row and column. - public double this[uint row, uint column] - { - get => this[row * 2 + column]; - set => this[row * 2 + column] = value; - } + /// + public double Determinant => M11 * M22 - M12 * M21; - /// - /// Gets the dimension of the . - /// - public uint Dimension => 2; - - /// - /// Gets the row count of the . - /// - public uint RowCount => 2; - - /// - /// Gets the column count of the . - /// - public uint ColumnCount => 2; - - /// - /// Gets a value indicating whether the is orthogonal, or not. - /// - public bool IsOrthogonal => this * Transpose == Identity; - - /// - /// Gets a value indicating whether the is the identity , or not. - /// - public bool IsIdentity => M11.IsApproximatelyEqualTo(1) && M22.IsApproximatelyEqualTo(0); - - /// - /// Gets the determinant of the . - /// - public double Determinant => this.GetDeterminant(); - - /// - /// Gets the trace of the . - /// - public double Trace => M11 + M22; - - /// - /// Gets a value indicating whether the is symmetric, or not. - /// - public bool IsSymmetric => this == Transpose; - - /// - /// Gets a value indicating whether the is skew symmetric, or not. - /// - public bool IsSkewSymmetric => Negate == Transpose; - - /// - /// Gets a value indicating whether the is antisymmetric, or not. - /// - public bool IsAntiSymmetric => this == Transpose.Negate; - - /// - /// Gets a value indicating whether the is a diagonal matrix, or not. - /// - public bool IsDiagonal => this.GetIsDiagonal(); - - /// - /// Gets a value indicating whether the is a triangle matrix, or not. - /// - public bool IsTriangle => this.GetIsTriangle(); - - /// - /// Implements the operator +. - /// - /// The first . - /// The second . - /// - /// The result of the operator. - /// - public static Matrix2x2 operator +(Matrix2x2 firstMatrix, Matrix2x2 secondMatrix) - { - return MatrixUtils.Add(firstMatrix, secondMatrix); - } + /// + public double Trace => M11 + M22; - /// - /// Implements the operator -. - /// - /// The first . - /// The second . - /// - /// The result of the operator. - /// - public static Matrix2x2 operator -(Matrix2x2 firstMatrix, Matrix2x2 secondMatrix) - { - return MatrixUtils.Subtract(firstMatrix, secondMatrix); - } + /// + public bool IsIdentity => Is(MatrixMath.Predicate.Identity); - /// - /// Implements the operator * to multiply a with the specified scalar. - /// - /// The scalar. - /// The . - /// - /// The result of the operator. - /// - public static Matrix2x2 operator *(double scalar, Matrix2x2 matrix) - { - return matrix.Multiply(scalar); - } + /// + public bool IsDiagonal => Is(MatrixMath.Predicate.Diagonal); - /// - /// Implements the operator * to multiply a with the specified scalar. - /// - /// The . - /// The scalar. - /// - /// The result of the operator. - /// - public static Matrix2x2 operator *(Matrix2x2 matrix, double scalar) - { - return matrix.Multiply(scalar); - } + /// + public bool IsTriangular => Is(MatrixMath.Predicate.Triangular); - /// - /// Implements the operator *. - /// - /// The first . - /// The second . - /// - /// The result of the operator. - /// - public static Matrix2x2 operator *(Matrix2x2 firstMatrix, Matrix2x2 secondMatrix) - { - return MatrixUtils.Multiply(firstMatrix, secondMatrix); - } + /// + public bool IsSymmetric => Is(MatrixMath.Predicate.Symmetric); - /// - /// Determines whether the specified , is equal to the instance. - /// - /// The to compare with the instance. - /// - /// true if the specified is equal to the instance; otherwise, false. - /// - public override bool Equals(object obj) - { - if (!(obj is Matrix2x2)) - return false; - return this == (Matrix2x2) obj; - } + /// + public bool IsSkewSymmetric => Is(MatrixMath.Predicate.SkewSymmetric); - /// - /// Returns a hash code for the instance. - /// - /// - /// A hash code for the instance, suitable for use in hashing algorithms and data structures like a hash table. - /// - public override int GetHashCode() - { - unchecked - { - var hash = 17; - for (uint y = 0; y < RowCount; ++y) - for (uint x = 0; x < ColumnCount; ++x) - hash = hash * 23 + this[y, x].GetHashCode(); - return hash; - } - } + /// + public bool IsOrthogonal => (this * Transpose()).IsIdentity; - public bool Equals(Matrix2x2 other) - { - return this == other; - } + /// + public bool IsSingular => Is(MatrixMath.Predicate.Singular); - /// - /// Implements the operator ==. - /// - /// The left . - /// The right . - /// - /// The result of the operator. - /// - public static bool operator ==(Matrix2x2 left, Matrix2x2 right) - { - return left.SequenceEqual(right); - } + /// + /// Creates a matrix that rotates vectors counterclockwise by the specified angle. + /// + /// The angle in radians. + /// The rotation matrix. + public static Matrix2x2 Rotation(double angle) + { + var (sin, cos) = Math.SinCos(angle); + return new Matrix2x2(cos, -sin, sin, cos); + } + + /// + /// Creates a matrix that scales vectors. + /// + /// The scale factor along X. + /// The scale factor along Y. + /// The scaling matrix. + public static Matrix2x2 Scaling(double scaleX, double scaleY) => new(scaleX, 0, 0, scaleY); + + /// + /// Creates a matrix that scales vectors uniformly. + /// + /// The scale factor. + /// The scaling matrix. + public static Matrix2x2 Scaling(double scale) => Scaling(scale, scale); + + /// + /// Creates a matrix from any with two rows and two columns. + /// + /// The matrix to copy. + /// The copied matrix. + /// The matrix is not 2x2. + public static Matrix2x2 FromMatrix(IMatrix matrix) + { + ArgumentNullException.ThrowIfNull(matrix); + if (matrix.RowCount != 2 || matrix.ColumnCount != 2) + throw new ArgumentException("The matrix must have 2 rows and 2 columns.", nameof(matrix)); + return new Matrix2x2(matrix[0, 0], matrix[0, 1], matrix[1, 0], matrix[1, 1]); + } + + /// + /// Returns the transpose. + /// + /// The transposed matrix. + public Matrix2x2 Transpose() => new(M11, M21, M12, M22); + + /// + /// Returns the matrix of cofactors. + /// + /// The cofactor matrix. + public Matrix2x2 Cofactors() => new(M22, -M21, -M12, M11); + + /// + /// Returns the adjugate, i.e. the transposed cofactor matrix. + /// + /// The adjugate. + public Matrix2x2 Adjugate() => new(M22, -M12, -M21, M11); - /// - /// Implements the operator !=. - /// - /// The left . - /// The right . - /// - /// The result of the operator. - /// - public static bool operator !=(Matrix2x2 left, Matrix2x2 right) + /// + /// Returns the inverse. + /// + /// The inverse matrix. + /// The matrix is singular (see ). + public Matrix2x2 Inverse() => TryInvert(out var inverse) ? inverse : throw MatrixMath.SingularMatrix(); + + /// + /// Tries to calculate the inverse. + /// + /// The inverse, or if the matrix is singular. + /// if the matrix is invertible; otherwise, . + public bool TryInvert(out Matrix2x2 inverse) + { + if (IsSingular) { - return !left.SequenceEqual(right); + inverse = default; + return false; } + + // Expanding along the first row reuses the minors already computed for the adjugate. + var adjugate = Adjugate(); + inverse = adjugate / (M11 * adjugate.M11 + M12 * adjugate.M21); + return true; } -} \ No newline at end of file + + /// + /// Determines whether this matrix equals another matrix within the default tolerances (entry-wise). + /// + /// The other matrix. + /// if the matrices are approximately equal; otherwise, . + public bool ApproximatelyEquals(Matrix2x2 other) => + ApproximatelyEquals(other, Tolerance.DefaultAbsolute, Tolerance.DefaultRelative); + + /// + /// Determines whether this matrix equals another matrix within the specified tolerances (entry-wise). + /// + /// The other matrix. + /// The absolute tolerance. + /// The relative tolerance. + /// if the matrices are approximately equal; otherwise, . + public bool ApproximatelyEquals(Matrix2x2 other, double absolute, double relative) + { + Span entries = stackalloc double[4]; + Span otherEntries = stackalloc double[4]; + CopyTo(entries); + other.CopyTo(otherEntries); + return MatrixMath.ApproximatelyEqual(entries, otherEntries, absolute, relative); + } + + /// + public override string ToString() + { + Span entries = stackalloc double[4]; + CopyTo(entries); + return MatrixMath.Format(nameof(Matrix2x2), entries, 2, 2); + } + + /// + /// Copies the entries in row-major order. + /// + internal void CopyTo(Span destination) + { + destination[0] = M11; + destination[1] = M12; + destination[2] = M21; + destination[3] = M22; + } + + private bool Is(MatrixMath.Predicate predicate) + { + Span entries = stackalloc double[4]; + CopyTo(entries); + return MatrixMath.Evaluate(predicate, entries, 2); + } + + /// + public bool Equals(Matrix2x2 other) => + M11.Equals(other.M11) && M12.Equals(other.M12) && M21.Equals(other.M21) && M22.Equals(other.M22); + + /// + public override bool Equals(object? obj) => obj is Matrix2x2 other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(M11, M12, M21, M22); + + /// + /// Adds two matrices. + /// + /// The first matrix. + /// The second matrix. + /// The sum. + public static Matrix2x2 operator +(Matrix2x2 left, Matrix2x2 right) => + new(left.M11 + right.M11, left.M12 + right.M12, left.M21 + right.M21, left.M22 + right.M22); + + /// + /// Subtracts a matrix from another matrix. + /// + /// The minuend. + /// The subtrahend. + /// The difference. + public static Matrix2x2 operator -(Matrix2x2 left, Matrix2x2 right) => + new(left.M11 - right.M11, left.M12 - right.M12, left.M21 - right.M21, left.M22 - right.M22); + + /// + /// Negates a matrix. + /// + /// The matrix. + /// The negated matrix. + public static Matrix2x2 operator -(Matrix2x2 matrix) => new(-matrix.M11, -matrix.M12, -matrix.M21, -matrix.M22); + + /// + /// Multiplies a matrix with a scalar. + /// + /// The matrix. + /// The scalar. + /// The scaled matrix. + public static Matrix2x2 operator *(Matrix2x2 matrix, double scalar) => + new(matrix.M11 * scalar, matrix.M12 * scalar, matrix.M21 * scalar, matrix.M22 * scalar); + + /// + /// Multiplies a scalar with a matrix. + /// + /// The scalar. + /// The matrix. + /// The scaled matrix. + public static Matrix2x2 operator *(double scalar, Matrix2x2 matrix) => matrix * scalar; + + /// + /// Divides a matrix by a scalar. + /// + /// The matrix. + /// The scalar. + /// The scaled matrix. + public static Matrix2x2 operator /(Matrix2x2 matrix, double scalar) => + new(matrix.M11 / scalar, matrix.M12 / scalar, matrix.M21 / scalar, matrix.M22 / scalar); + + /// + /// Multiplies two matrices. + /// + /// The left matrix. + /// The right matrix. + /// The product left · right. + public static Matrix2x2 operator *(Matrix2x2 left, Matrix2x2 right) => + new(left.M11 * right.M11 + left.M12 * right.M21, left.M11 * right.M12 + left.M12 * right.M22, + left.M21 * right.M11 + left.M22 * right.M21, left.M21 * right.M12 + left.M22 * right.M22); + + /// + /// Transforms a column vector. + /// + /// The matrix. + /// The vector. + /// The product matrix · vector. + public static Vector2 operator *(Matrix2x2 matrix, Vector2 vector) => + new(matrix.M11 * vector.X + matrix.M12 * vector.Y, matrix.M21 * vector.X + matrix.M22 * vector.Y); + + /// + /// Determines whether two matrices are exactly equal (entry-wise ==). + /// + /// The first matrix. + /// The second matrix. + /// if all entries are equal; otherwise, . + public static bool operator ==(Matrix2x2 left, Matrix2x2 right) => + left.M11 == right.M11 && left.M12 == right.M12 && left.M21 == right.M21 && left.M22 == right.M22; + + /// + /// Determines whether two matrices differ in at least one entry. + /// + /// The first matrix. + /// The second matrix. + /// if any entry differs; otherwise, . + public static bool operator !=(Matrix2x2 left, Matrix2x2 right) => !(left == right); +} diff --git a/src/SharpMath/Geometry/Matrix3x1.cs b/src/SharpMath/Geometry/Matrix3x1.cs deleted file mode 100644 index 16248c4..0000000 --- a/src/SharpMath/Geometry/Matrix3x1.cs +++ /dev/null @@ -1,106 +0,0 @@ -// Matrix3x1.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; -using System.Collections; -using System.Collections.Generic; - -// ReSharper disable InconsistentNaming - -namespace SharpMath.Geometry -{ - /// - /// Represents a 3x1 matrix for representing three-dimensional vectors vertically. - /// - [Serializable] - public struct Matrix3x1 : IEnumerable, IMatrix - { - /// - /// Gets the value at row 1 and column 1. - /// - public double M11 { get; set; } - - /// - /// Gets the value at row 2 and column 1. - /// - public double M21 { get; set; } - - /// - /// Gets the value at row 3 and column 1. - /// - public double M31 { get; set; } - - public IEnumerator GetEnumerator() - { - return new MatrixEnumerator(this); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return new MatrixEnumerator(this); - } - - /// - /// Gets or sets the value at the specified index. The values are accessed as: row (+ column). - /// - /// The index. - /// The value at the specified index. - public double this[uint index] - { - get - { - switch (index) - { - case 0: - return M11; - case 1: - return M21; - case 2: - return M31; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 2."); - } - } - set - { - switch (index) - { - case 0: - M11 = value; - break; - case 1: - M21 = value; - break; - case 2: - M31 = value; - break; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 2."); - } - } - } - - /// - /// Gets or sets the value at the specified row and column. - /// - /// The row. - /// The column. - /// The value at the specified row and column. - public double this[uint row, uint column] - { - get => this[row + column]; - - set => this[row + column] = value; - } - - /// - /// Gets the column count of the . - /// - public uint ColumnCount => 1; - - /// - /// Gets the row count of the . - /// - public uint RowCount => 3; - } -} \ No newline at end of file diff --git a/src/SharpMath/Geometry/Matrix3x3.Operations.cs b/src/SharpMath/Geometry/Matrix3x3.Operations.cs new file mode 100644 index 0000000..51cd6ed --- /dev/null +++ b/src/SharpMath/Geometry/Matrix3x3.Operations.cs @@ -0,0 +1,67 @@ +namespace SharpMath.Geometry; + +public readonly partial struct Matrix3x3 +{ + /// + public double Determinant => + M11 * (M22 * M33 - M23 * M32) - M12 * (M21 * M33 - M23 * M31) + M13 * (M21 * M32 - M22 * M31); + + /// + /// Returns the adjugate, i.e. the transposed cofactor matrix. + /// + /// The adjugate. + public Matrix3x3 Adjugate() => new( + M22 * M33 - M23 * M32, M13 * M32 - M12 * M33, M12 * M23 - M13 * M22, + M23 * M31 - M21 * M33, M11 * M33 - M13 * M31, M13 * M21 - M11 * M23, + M21 * M32 - M22 * M31, M12 * M31 - M11 * M32, M11 * M22 - M12 * M21); + + /// + /// Creates a matrix that rotates two-dimensional points counterclockwise around the origin in homogeneous + /// coordinates. + /// + /// The angle in radians. + /// The rotation matrix. + public static Matrix3x3 Rotation(double angle) + { + var (sin, cos) = Math.SinCos(angle); + return new Matrix3x3(cos, -sin, 0, sin, cos, 0, 0, 0, 1); + } + + /// + /// Creates a matrix that scales two-dimensional points in homogeneous coordinates. + /// + /// The scale factor along X. + /// The scale factor along Y. + /// The scaling matrix. + public static Matrix3x3 Scaling(double scaleX, double scaleY) => new(scaleX, 0, 0, 0, scaleY, 0, 0, 0, 1); + + /// + /// Creates a matrix that scales two-dimensional points uniformly in homogeneous coordinates. + /// + /// The scale factor. + /// The scaling matrix. + public static Matrix3x3 Scaling(double scale) => Scaling(scale, scale); + + /// + /// Creates a matrix that translates two-dimensional points in homogeneous coordinates. + /// + /// The translation along X. + /// The translation along Y. + /// The translation matrix. + public static Matrix3x3 Translation(double x, double y) => new(1, 0, x, 0, 1, y, 0, 0, 1); + + /// + /// Transforms a two-dimensional vector in homogeneous coordinates (matrix · (x, y, 1)). The result is divided + /// by the resulting W component if it is not 1. + /// + /// The matrix. + /// The vector. + /// The transformed vector. + public static Vector2 operator *(Matrix3x3 matrix, Vector2 vector) + { + var x = matrix.M11 * vector.X + matrix.M12 * vector.Y + matrix.M13; + var y = matrix.M21 * vector.X + matrix.M22 * vector.Y + matrix.M23; + var w = matrix.M31 * vector.X + matrix.M32 * vector.Y + matrix.M33; + return w == 1 ? new Vector2(x, y) : new Vector2(x / w, y / w); + } +} diff --git a/src/SharpMath/Geometry/Matrix3x3.cs b/src/SharpMath/Geometry/Matrix3x3.cs index 0feeec6..1e20c94 100644 --- a/src/SharpMath/Geometry/Matrix3x3.cs +++ b/src/SharpMath/Geometry/Matrix3x3.cs @@ -1,547 +1,408 @@ -// Matrix3x3.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 +namespace SharpMath.Geometry; + +/// +/// Represents an immutable 3x3 matrix. +/// +/// +/// Vectors are column vectors: a matrix transforms a vector as matrix * vector, and transformations compose +/// from right to left (T * R * S * v scales first). Equality is exact; use +/// for tolerance-based comparisons. +/// +public readonly partial struct Matrix3x3 : ISquareMatrix, IEquatable +{ + /// + /// Initializes a new instance of the struct from its entries in row-major order. + /// + /// The entry in row 1, column 1. + /// The entry in row 1, column 2. + /// The entry in row 1, column 3. + /// The entry in row 2, column 1. + /// The entry in row 2, column 2. + /// The entry in row 2, column 3. + /// The entry in row 3, column 1. + /// The entry in row 3, column 2. + /// The entry in row 3, column 3. + public Matrix3x3(double m11, double m12, double m13, double m21, double m22, double m23, double m31, double m32, double m33) + { + M11 = m11; + M12 = m12; + M13 = m13; + M21 = m21; + M22 = m22; + M23 = m23; + M31 = m31; + M32 = m32; + M33 = m33; + } -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; + /// + /// Initializes a new instance of the struct from its rows. + /// + /// Row 1. + /// Row 2. + /// Row 3. + public Matrix3x3(Vector3 row1, Vector3 row2, Vector3 row3) + : this(row1.X, row1.Y, row1.Z, row2.X, row2.Y, row2.Z, row3.X, row3.Y, row3.Z) + { + } -// ReSharper disable InconsistentNaming + /// + /// Gets the entry in row 1, column 1. + /// + public double M11 { get; } -namespace SharpMath.Geometry -{ /// - /// Represents a 3x3 matrix and provides functions to transform 2-dimensional vectors. + /// Gets the entry in row 1, column 2. /// - // ReSharper disable once InconsistentNaming - [Serializable] - public struct Matrix3x3 : IEnumerable, IEquatable, ISquareMatrix - { - /// - /// Initializes a struct. - /// - /// The value at row 1 and column 1. - /// The value at row 1 and column 2. - /// The value at row 1 and column 3. - /// The value at row 2 and column 1. - /// The value at row 2 and column 2 - /// The value at row 2 and column 3. - /// The value at row 3 and column 1. - /// The value at row 3 and column 2. - /// The value at row 3 and column 3. - public Matrix3x3(double m11, double m12, double m13, double m21, double m22, double m23, double m31, double m32, - double m33) - { - M11 = m11; - M12 = m12; - M13 = m13; - M21 = m21; - M22 = m22; - M23 = m23; - M31 = m31; - M32 = m32; - M33 = m33; - } + public double M12 { get; } - /// - /// Initializes a struct. - /// - /// The first row . - /// The second row . - /// The third row . - public Matrix3x3(Vector3 row1, Vector3 row2, Vector3 row3) - : this(row1.X, row2.X, row3.X, row1.Y, row2.Y, row3.Y, row1.Z, row2.Z, row3.Z) - { - } + /// + /// Gets the entry in row 1, column 3. + /// + public double M13 { get; } - /// - /// Gets the zero . - /// - public static Matrix3x3 Zero => new Matrix3x3(); - - /// - /// Gets the identity . - /// - public static Matrix3x3 Identity => MatrixUtils.GetIdentity(); - - /// - /// Gets or sets the value at row 1 and column 1. - /// - public double M11 { get; set; } - - /// - /// Gets or sets the value at row 1 and column 2. - /// - public double M12 { get; set; } - - /// - /// Gets or sets the value at row 1 and column 3. - /// - public double M13 { get; set; } - - /// - /// Gets or sets the value at row 2 and column 1. - /// - public double M21 { get; set; } - - /// - /// Gets or sets the value at row 2 and column 2. - /// - public double M22 { get; set; } - - /// - /// Gets or sets the value at row 2 and column 3. - /// - public double M23 { get; set; } - - /// - /// Gets or sets the value at row 3 and column 1. - /// - public double M31 { get; set; } - - /// - /// Gets or sets the value at row 3 and column 2. - /// - public double M32 { get; set; } - - /// - /// Gets or sets the value at row 3 and column 3. - /// - public double M33 { get; set; } - - /// - /// Gets a value indicating whether the is singular, or not. If true, this - /// doesn't have an inverse. - /// - public bool IsSingular => Determinant.IsApproximatelyEqualTo(0); - - /// - /// Gets the inverse of the . - /// - public Matrix3x3 Inverse => MatrixUtils.GaussJordan(this, Identity); - - /// - /// Gets the cofactor of the . - /// - public Matrix3x3 CofactorMatrix => this.BuildCofactorMatrix(); - - /// - /// Gets the adjugate of the . - /// - public Matrix3x3 Adjugate => CofactorMatrix.GetTranspose(); - - /// - /// Gets the negated of the . - /// - public Matrix3x3 Negate => this.GetNegate(); - - /// - /// Gets the transpose of the . - /// - public Matrix3x3 Transpose => this.GetTranspose(); - - public IEnumerator GetEnumerator() - { - return new MatrixEnumerator(this); - } + /// + /// Gets the entry in row 2, column 1. + /// + public double M21 { get; } - IEnumerator IEnumerable.GetEnumerator() - { - return new MatrixEnumerator(this); - } + /// + /// Gets the entry in row 2, column 2. + /// + public double M22 { get; } - public bool Equals(Matrix3x3 other) - { - return this == other; - } + /// + /// Gets the entry in row 2, column 3. + /// + public double M23 { get; } - /// - /// Gets or sets the value at the specified index. The values are accessed as: (row*3) + column. - /// - /// The index. - /// The value at the specified index. - public double this[uint index] - { - get - { - switch (index) - { - case 0: - return M11; - case 1: - return M12; - case 2: - return M13; - case 3: - return M21; - case 4: - return M22; - case 5: - return M23; - case 6: - return M31; - case 7: - return M32; - case 8: - return M33; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 8."); - } - } - set - { - switch (index) - { - case 0: - M11 = value; - break; - case 1: - M12 = value; - break; - case 2: - M13 = value; - break; - case 3: - M21 = value; - break; - case 4: - M22 = value; - break; - case 5: - M23 = value; - break; - case 6: - M31 = value; - break; - case 7: - M32 = value; - break; - case 8: - M33 = value; - break; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 8."); - } - } - } + /// + /// Gets the entry in row 3, column 1. + /// + public double M31 { get; } - /// - /// Gets or sets the value at the specified row and column. - /// - /// The row. - /// The column. - /// The value at the specified row and column. - public double this[uint row, uint column] - { - get => this[row * 3 + column]; - set => this[row * 3 + column] = value; - } + /// + /// Gets the entry in row 3, column 2. + /// + public double M32 { get; } + + /// + /// Gets the entry in row 3, column 3. + /// + public double M33 { get; } + + /// + /// Gets the zero matrix. + /// + public static Matrix3x3 Zero => default; - /// - /// Gets the dimension of the . - /// - public uint Dimension => 3; - - /// - /// Gets the row count of the . - /// - public uint RowCount => 3; - - /// - /// Gets the column count of the . - /// - public uint ColumnCount => 3; - - /// - /// Gets a value indicating whether the is orthogonal, or not. - /// - public bool IsOrthogonal => this * Transpose == Identity; - - /// - /// Gets a value indicating whether the is the identity , or not. - /// - public bool IsIdentity - => M11.IsApproximatelyEqualTo(1) && M22.IsApproximatelyEqualTo(1) && M33.IsApproximatelyEqualTo(1); - - /// - /// Gets the determinant of the . - /// - public double Determinant => this.GetDeterminant(); - - /// - /// Gets the trace of the . - /// - public double Trace => M11 + M22 + M33; - - /// - /// Gets a value indicating whether the is symmetric, or not. - /// - public bool IsSymmetric => this == Transpose; - - /// - /// Gets a value indicating whether the is skew symmetric, or not. - /// - public bool IsSkewSymmetric => Negate == Transpose; - - /// - /// Gets a value indicating whether the is antisymmetric, or not. - /// - public bool IsAntiSymmetric => this == Transpose.Negate; - - /// - /// Gets a value indicating whether the is a diagonal matrix, or not. - /// - public bool IsDiagonal => this.GetIsDiagonal(); - - /// - /// Gets a value indicating whether the is a triangle matrix, or not. - /// - public bool IsTriangle => this.GetIsTriangle(); - - /// - /// Creates a from a object. - /// - /// The to convert. - /// The that has been created. - public static Matrix3x3 FromMatrix(IMatrix matrix) + /// + /// Gets the identity matrix. + /// + public static Matrix3x3 Identity => new(1, 0, 0, 0, 1, 0, 0, 0, 1); + + /// + /// Gets the entry at the specified row-major index (row · 3 + column). + /// + /// The zero-based index between 0 and 8. + /// is out of range. + public double this[int index] => index switch + { + 0 => M11, + 1 => M12, + 2 => M13, + 3 => M21, + 4 => M22, + 5 => M23, + 6 => M31, + 7 => M32, + 8 => M33, + _ => throw MatrixMath.IndexOutOfRange(nameof(index), index, 9) + }; + + /// + public double this[int row, int column] + { + get { - if (matrix.RowCount != 3 || matrix.ColumnCount != 3) - throw new ArgumentException("The square matrix cannot be converted into a Matrix3x3"); - - var resultMatrix = new Matrix3x3(); - for (uint y = 0; y < resultMatrix.Dimension; ++y) - for (uint x = 0; x < resultMatrix.Dimension; ++x) - resultMatrix[y, x] = matrix[y, x]; - return resultMatrix; + MatrixMath.CheckIndices(row, column, 3); + return this[row * 3 + column]; } + } - /// - /// Creates a that represents a rotation. - /// - /// The angle to rotate around in radians. - /// A that represents a rotation using the specified angle. - public static Matrix3x3 Rotation(double angle) - { - // the is actually the same as Matrix4x4.RotationZ as we are rotating around the Z-axis that is 0 in 2D-space. + /// + public int RowCount => 3; - var sin = Math.Sin(angle); - var cos = Math.Cos(angle); + /// + public int ColumnCount => 3; - var matrix = Identity; - matrix[0, 0] = cos; - matrix[1, 0] = sin; - matrix[0, 1] = -sin; - matrix[1, 1] = cos; + /// + public int Dimension => 3; - return matrix; - } + /// + public double Trace => M11 + M22 + M33; - /// - /// Creates a that represents a scalation. - /// - /// The scalation factor of the X-component. - /// The scalation factor of the Y-component. - /// A that represents a scalation using the specified factors. - public static Matrix3x3 Scalation(double scaleX, double scaleY) - { - var matrix = Identity; - matrix[0, 0] = scaleX; - matrix[1, 1] = scaleY; + /// + public bool IsIdentity => Is(MatrixMath.Predicate.Identity); - return matrix; - } + /// + public bool IsDiagonal => Is(MatrixMath.Predicate.Diagonal); - /// - /// Creates a that represents a translation. - /// - /// The translation of the X-component. - /// The translation of the Y-component. - /// A that represents a translation using the specified addends. - public static Matrix3x3 Translation(double x, double y) - { - var matrix = Identity; - matrix[0, 2] = x; - matrix[1, 2] = y; + /// + public bool IsTriangular => Is(MatrixMath.Predicate.Triangular); - return matrix; - } + /// + public bool IsSymmetric => Is(MatrixMath.Predicate.Symmetric); - /// - /// Implements the operator +. - /// - /// The first . - /// The second . - /// - /// The result of the operator. - /// - public static Matrix3x3 operator +(Matrix3x3 firstMatrix, Matrix3x3 secondMatrix) - { - return MatrixUtils.Add(firstMatrix, secondMatrix); - } + /// + public bool IsSkewSymmetric => Is(MatrixMath.Predicate.SkewSymmetric); - /// - /// Implements the operator -. - /// - /// The first . - /// The second . - /// - /// The result of the operator. - /// - public static Matrix3x3 operator -(Matrix3x3 firstMatrix, Matrix3x3 secondMatrix) - { - return MatrixUtils.Subtract(firstMatrix, secondMatrix); - } + /// + public bool IsOrthogonal => (this * Transpose()).IsIdentity; - /// - /// Implements the operator * to multiply a with the specified scalar. - /// - /// The scalar. - /// The . - /// - /// The result of the operator. - /// - public static Matrix3x3 operator *(double scalar, Matrix3x3 matrix) - { - return matrix.Multiply(scalar); - } + /// + public bool IsSingular => Is(MatrixMath.Predicate.Singular); - /// - /// Implements the operator * to multiply a with the specified scalar. - /// - /// The . - /// The scalar. - /// - /// The result of the operator. - /// - public static Matrix3x3 operator *(Matrix3x3 matrix, double scalar) - { - return matrix.Multiply(scalar); - } + /// + /// Creates a matrix from any with 3 rows and 3 columns. + /// + /// The matrix to copy. + /// The copied matrix. + /// The matrix is not 3x3. + public static Matrix3x3 FromMatrix(IMatrix matrix) + { + ArgumentNullException.ThrowIfNull(matrix); + if (matrix.RowCount != 3 || matrix.ColumnCount != 3) + throw new ArgumentException("The matrix must have 3 rows and 3 columns.", nameof(matrix)); + return new Matrix3x3(matrix[0, 0], matrix[0, 1], matrix[0, 2], matrix[1, 0], matrix[1, 1], matrix[1, 2], matrix[2, 0], matrix[2, 1], matrix[2, 2]); + } - /// - /// Implements the operator * to transform a with a . - /// - /// The . - /// The . - /// - /// The result of the operator. - /// - public static Vector2 operator *(Matrix3x3 matrix, Vector2 vector) - { - var resultMatrix = MatrixUtils.Multiply(matrix, - new Vector3(vector.X, vector.Y, 1).AsVerticalMatrix()); - return new Vector2(resultMatrix[0], resultMatrix[1]); - } + /// + /// Returns the transpose. + /// + /// The transposed matrix. + public Matrix3x3 Transpose() => new(M11, M21, M31, M12, M22, M32, M13, M23, M33); - /// - /// Implements the operator * to transform a with a . - /// - /// The . - /// The . - /// - /// The result of the operator. - /// - public static Vector2 operator *(Vector2 vector, Matrix3x3 matrix) - { - return matrix * vector; - } + /// + /// Returns the matrix of cofactors. + /// + /// The cofactor matrix. + public Matrix3x3 Cofactors() => Adjugate().Transpose(); - /// - /// Implements the operator * to transform a with a . - /// - /// The . - /// The . - /// - /// The result of the operator. - /// - public static Vector3 operator *(Matrix3x3 matrix, Vector3 vector) - { - var resultMatrix = MatrixUtils.Multiply(matrix, vector.AsVerticalMatrix()); - return new Vector3(resultMatrix[0], resultMatrix[1], resultMatrix[2]); - } + /// + /// Returns the inverse. + /// + /// The inverse matrix. + /// The matrix is singular (see ). + public Matrix3x3 Inverse() => TryInvert(out var inverse) ? inverse : throw MatrixMath.SingularMatrix(); - /// - /// Implements the operator * to transform a with a . - /// - /// The . - /// The . - /// - /// The result of the operator. - /// - public static Vector3 operator *(Vector3 vector, Matrix3x3 matrix) + /// + /// Tries to calculate the inverse. + /// + /// The inverse, or if the matrix is singular. + /// if the matrix is invertible; otherwise, . + public bool TryInvert(out Matrix3x3 inverse) + { + if (IsSingular) { - return matrix * vector; + inverse = default; + return false; } - /// - /// Implements the operator *. - /// - /// The first . - /// The second . - /// - /// The result of the operator. - /// - public static Matrix3x3 operator *(Matrix3x3 firstMatrix, Matrix3x3 secondMatrix) - { - return MatrixUtils.Multiply(firstMatrix, secondMatrix); - } + // Expanding along the first row reuses the minors already computed for the adjugate. + var adjugate = Adjugate(); + inverse = adjugate / (M11 * adjugate.M11 + M12 * adjugate.M21 + M13 * adjugate.M31); + return true; + } - /// - /// Determines whether the specified , is equal to the instance. - /// - /// The to compare with the instance. - /// - /// true if the specified is equal to the instance; otherwise, false. - /// - public override bool Equals(object obj) - { - if (!(obj is Matrix3x3)) - return false; - return this == (Matrix3x3) obj; - } + /// + /// Determines whether this matrix equals another matrix within the default tolerances (entry-wise). + /// + /// The other matrix. + /// if the matrices are approximately equal; otherwise, . + public bool ApproximatelyEquals(Matrix3x3 other) => + ApproximatelyEquals(other, Tolerance.DefaultAbsolute, Tolerance.DefaultRelative); - /// - /// Returns a hash code for the instance. - /// - /// - /// A hash code for the instance, suitable for use in hashing algorithms and data structures like a hash table. - /// - public override int GetHashCode() - { - unchecked - { - var hash = 17; - for (uint y = 0; y < RowCount; ++y) - for (uint x = 0; x < ColumnCount; ++x) - hash = hash * 23 + this[y, x].GetHashCode(); - return hash; - } - } + /// + /// Determines whether this matrix equals another matrix within the specified tolerances (entry-wise). + /// + /// The other matrix. + /// The absolute tolerance. + /// The relative tolerance. + /// if the matrices are approximately equal; otherwise, . + public bool ApproximatelyEquals(Matrix3x3 other, double absolute, double relative) + { + Span entries = stackalloc double[9]; + Span otherEntries = stackalloc double[9]; + CopyTo(entries); + other.CopyTo(otherEntries); + return MatrixMath.ApproximatelyEqual(entries, otherEntries, absolute, relative); + } - /// - /// Implements the operator ==. - /// - /// The left . - /// The right . - /// - /// The result of the operator. - /// - public static bool operator ==(Matrix3x3 left, Matrix3x3 right) - { - return left.SequenceEqual(right); - } + /// + public override string ToString() + { + Span entries = stackalloc double[9]; + CopyTo(entries); + return MatrixMath.Format(nameof(Matrix3x3), entries, 3, 3); + } - /// - /// Implements the operator !=. - /// - /// The left . - /// The right . - /// - /// The result of the operator. - /// - public static bool operator !=(Matrix3x3 left, Matrix3x3 right) - { - return !left.SequenceEqual(right); - } + /// + /// Copies the entries in row-major order. + /// + internal void CopyTo(Span destination) + { + destination[0] = M11; + destination[1] = M12; + destination[2] = M13; + destination[3] = M21; + destination[4] = M22; + destination[5] = M23; + destination[6] = M31; + destination[7] = M32; + destination[8] = M33; } -} \ No newline at end of file + + private bool Is(MatrixMath.Predicate predicate) + { + Span entries = stackalloc double[9]; + CopyTo(entries); + return MatrixMath.Evaluate(predicate, entries, 3); + } + + /// + public bool Equals(Matrix3x3 other) => + M11.Equals(other.M11) && + M12.Equals(other.M12) && + M13.Equals(other.M13) && + M21.Equals(other.M21) && + M22.Equals(other.M22) && + M23.Equals(other.M23) && + M31.Equals(other.M31) && + M32.Equals(other.M32) && + M33.Equals(other.M33); + + /// + public override bool Equals(object? obj) => obj is Matrix3x3 other && Equals(other); + + /// + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(M11); + hash.Add(M12); + hash.Add(M13); + hash.Add(M21); + hash.Add(M22); + hash.Add(M23); + hash.Add(M31); + hash.Add(M32); + hash.Add(M33); + return hash.ToHashCode(); + } + + /// + /// Adds two matrices. + /// + /// The first matrix. + /// The second matrix. + /// The sum. + public static Matrix3x3 operator +(Matrix3x3 left, Matrix3x3 right) => + new(left.M11 + right.M11, left.M12 + right.M12, left.M13 + right.M13, left.M21 + right.M21, left.M22 + right.M22, left.M23 + right.M23, left.M31 + right.M31, left.M32 + right.M32, left.M33 + right.M33); + + /// + /// Subtracts a matrix from another matrix. + /// + /// The minuend. + /// The subtrahend. + /// The difference. + public static Matrix3x3 operator -(Matrix3x3 left, Matrix3x3 right) => + new(left.M11 - right.M11, left.M12 - right.M12, left.M13 - right.M13, left.M21 - right.M21, left.M22 - right.M22, left.M23 - right.M23, left.M31 - right.M31, left.M32 - right.M32, left.M33 - right.M33); + + /// + /// Negates a matrix. + /// + /// The matrix. + /// The negated matrix. + public static Matrix3x3 operator -(Matrix3x3 matrix) => + new(-matrix.M11, -matrix.M12, -matrix.M13, -matrix.M21, -matrix.M22, -matrix.M23, -matrix.M31, -matrix.M32, -matrix.M33); + + /// + /// Multiplies a matrix with a scalar. + /// + /// The matrix. + /// The scalar. + /// The scaled matrix. + public static Matrix3x3 operator *(Matrix3x3 matrix, double scalar) => + new(matrix.M11 * scalar, matrix.M12 * scalar, matrix.M13 * scalar, matrix.M21 * scalar, matrix.M22 * scalar, matrix.M23 * scalar, matrix.M31 * scalar, matrix.M32 * scalar, matrix.M33 * scalar); + + /// + /// Multiplies a scalar with a matrix. + /// + /// The scalar. + /// The matrix. + /// The scaled matrix. + public static Matrix3x3 operator *(double scalar, Matrix3x3 matrix) => matrix * scalar; + + /// + /// Divides a matrix by a scalar. + /// + /// The matrix. + /// The scalar. + /// The scaled matrix. + public static Matrix3x3 operator /(Matrix3x3 matrix, double scalar) => + new(matrix.M11 / scalar, matrix.M12 / scalar, matrix.M13 / scalar, matrix.M21 / scalar, matrix.M22 / scalar, matrix.M23 / scalar, matrix.M31 / scalar, matrix.M32 / scalar, matrix.M33 / scalar); + + /// + /// Multiplies two matrices. + /// + /// The left matrix. + /// The right matrix. + /// The product left · right. + public static Matrix3x3 operator *(Matrix3x3 left, Matrix3x3 right) => + new(left.M11 * right.M11 + left.M12 * right.M21 + left.M13 * right.M31, + left.M11 * right.M12 + left.M12 * right.M22 + left.M13 * right.M32, + left.M11 * right.M13 + left.M12 * right.M23 + left.M13 * right.M33, + left.M21 * right.M11 + left.M22 * right.M21 + left.M23 * right.M31, + left.M21 * right.M12 + left.M22 * right.M22 + left.M23 * right.M32, + left.M21 * right.M13 + left.M22 * right.M23 + left.M23 * right.M33, + left.M31 * right.M11 + left.M32 * right.M21 + left.M33 * right.M31, + left.M31 * right.M12 + left.M32 * right.M22 + left.M33 * right.M32, + left.M31 * right.M13 + left.M32 * right.M23 + left.M33 * right.M33); + + /// + /// Transforms a column vector. + /// + /// The matrix. + /// The vector. + /// The product matrix · vector. + public static Vector3 operator *(Matrix3x3 matrix, Vector3 vector) => + new(matrix.M11 * vector.X + matrix.M12 * vector.Y + matrix.M13 * vector.Z, + matrix.M21 * vector.X + matrix.M22 * vector.Y + matrix.M23 * vector.Z, + matrix.M31 * vector.X + matrix.M32 * vector.Y + matrix.M33 * vector.Z); + + /// + /// Determines whether two matrices are exactly equal (entry-wise ==). + /// + /// The first matrix. + /// The second matrix. + /// if all entries are equal; otherwise, . + public static bool operator ==(Matrix3x3 left, Matrix3x3 right) => + left.M11 == right.M11 && + left.M12 == right.M12 && + left.M13 == right.M13 && + left.M21 == right.M21 && + left.M22 == right.M22 && + left.M23 == right.M23 && + left.M31 == right.M31 && + left.M32 == right.M32 && + left.M33 == right.M33; + + /// + /// Determines whether two matrices differ in at least one entry. + /// + /// The first matrix. + /// The second matrix. + /// if any entry differs; otherwise, . + public static bool operator !=(Matrix3x3 left, Matrix3x3 right) => !(left == right); +} diff --git a/src/SharpMath/Geometry/Matrix4x1.cs b/src/SharpMath/Geometry/Matrix4x1.cs deleted file mode 100644 index ad40448..0000000 --- a/src/SharpMath/Geometry/Matrix4x1.cs +++ /dev/null @@ -1,116 +0,0 @@ -// Matrix4x1.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; -using System.Collections; -using System.Collections.Generic; - -// ReSharper disable InconsistentNaming - -namespace SharpMath.Geometry -{ - /// - /// Represents a 4x1 matrix for representing four-dimensional vectors vertically. - /// - [Serializable] - public struct Matrix4x1 : IEnumerable, IMatrix - { - /// - /// Gets the value at row 1 and column 1. - /// - public double M11 { get; set; } - - /// - /// Gets the value at row 2 and column 1. - /// - public double M21 { get; set; } - - /// - /// Gets the value at row 3 and column 1. - /// - public double M31 { get; set; } - - /// - /// Gets the value at row 4 and column 1. - /// - public double M41 { get; set; } - - public IEnumerator GetEnumerator() - { - return new MatrixEnumerator(this); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return new MatrixEnumerator(this); - } - - /// - /// Gets or sets the value at the specified index. The values are accessed as: row (+ column). - /// - /// The index. - /// The value at the specified index. - public double this[uint index] - { - get - { - switch (index) - { - case 0: - return M11; - case 1: - return M21; - case 2: - return M31; - case 3: - return M41; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 3."); - } - } - set - { - switch (index) - { - case 0: - M11 = value; - break; - case 1: - M21 = value; - break; - case 2: - M31 = value; - break; - case 3: - M41 = value; - break; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 3."); - } - } - } - - /// - /// Gets or sets the value at the specified row and column. - /// - /// The row. - /// The column. - /// The value at the specified row and column. - public double this[uint row, uint column] - { - get => this[row + column]; - - set => this[row + column] = value; - } - - /// - /// Gets the column count of the . - /// - public uint ColumnCount => 1; - - /// - /// Gets the row count of the . - /// - public uint RowCount => 4; - } -} \ No newline at end of file diff --git a/src/SharpMath/Geometry/Matrix4x4.Operations.cs b/src/SharpMath/Geometry/Matrix4x4.Operations.cs new file mode 100644 index 0000000..6d120d6 --- /dev/null +++ b/src/SharpMath/Geometry/Matrix4x4.Operations.cs @@ -0,0 +1,245 @@ +namespace SharpMath.Geometry; + +public readonly partial struct Matrix4x4 +{ + /// + public double Determinant + { + get + { + var s0 = M11 * M22 - M21 * M12; + var s1 = M11 * M23 - M21 * M13; + var s2 = M11 * M24 - M21 * M14; + var s3 = M12 * M23 - M22 * M13; + var s4 = M12 * M24 - M22 * M14; + var s5 = M13 * M24 - M23 * M14; + var c5 = M33 * M44 - M43 * M34; + var c4 = M32 * M44 - M42 * M34; + var c3 = M32 * M43 - M42 * M33; + var c2 = M31 * M44 - M41 * M34; + var c1 = M31 * M43 - M41 * M33; + var c0 = M31 * M42 - M41 * M32; + return s0 * c5 - s1 * c4 + s2 * c3 + s3 * c2 - s4 * c1 + s5 * c0; + } + } + + /// + /// Returns the adjugate, i.e. the transposed cofactor matrix. + /// + /// The adjugate. + public Matrix4x4 Adjugate() + { + // Laplace expansion by 2x2 minors of the upper (s) and lower (c) two rows. + var s0 = M11 * M22 - M21 * M12; + var s1 = M11 * M23 - M21 * M13; + var s2 = M11 * M24 - M21 * M14; + var s3 = M12 * M23 - M22 * M13; + var s4 = M12 * M24 - M22 * M14; + var s5 = M13 * M24 - M23 * M14; + var c5 = M33 * M44 - M43 * M34; + var c4 = M32 * M44 - M42 * M34; + var c3 = M32 * M43 - M42 * M33; + var c2 = M31 * M44 - M41 * M34; + var c1 = M31 * M43 - M41 * M33; + var c0 = M31 * M42 - M41 * M32; + + return new Matrix4x4( + M22 * c5 - M23 * c4 + M24 * c3, -M12 * c5 + M13 * c4 - M14 * c3, + M42 * s5 - M43 * s4 + M44 * s3, -M32 * s5 + M33 * s4 - M34 * s3, + -M21 * c5 + M23 * c2 - M24 * c1, M11 * c5 - M13 * c2 + M14 * c1, + -M41 * s5 + M43 * s2 - M44 * s1, M31 * s5 - M33 * s2 + M34 * s1, + M21 * c4 - M22 * c2 + M24 * c0, -M11 * c4 + M12 * c2 - M14 * c0, + M41 * s4 - M42 * s2 + M44 * s0, -M31 * s4 + M32 * s2 - M34 * s0, + -M21 * c3 + M22 * c1 - M23 * c0, M11 * c3 - M12 * c1 + M13 * c0, + -M41 * s3 + M42 * s1 - M43 * s0, M31 * s3 - M32 * s1 + M33 * s0); + } + + /// + /// Creates a matrix that translates points. + /// + /// The translation along X. + /// The translation along Y. + /// The translation along Z. + /// The translation matrix. + public static Matrix4x4 Translation(double x, double y, double z) => + new(1, 0, 0, x, 0, 1, 0, y, 0, 0, 1, z, 0, 0, 0, 1); + + /// + /// Creates a matrix that translates points. + /// + /// The translation. + /// The translation matrix. + public static Matrix4x4 Translation(Vector3 translation) => + Translation(translation.X, translation.Y, translation.Z); + + /// + /// Creates a matrix that scales points. + /// + /// The scale factor along X. + /// The scale factor along Y. + /// The scale factor along Z. + /// The scaling matrix. + public static Matrix4x4 Scaling(double scaleX, double scaleY, double scaleZ) => + new(scaleX, 0, 0, 0, 0, scaleY, 0, 0, 0, 0, scaleZ, 0, 0, 0, 0, 1); + + /// + /// Creates a matrix that scales points uniformly. + /// + /// The scale factor. + /// The scaling matrix. + public static Matrix4x4 Scaling(double scale) => Scaling(scale, scale, scale); + + /// + /// Creates a matrix that rotates points around the X axis (counterclockwise when looking from +X towards the + /// origin). + /// + /// The angle in radians. + /// The rotation matrix. + public static Matrix4x4 RotationX(double angle) + { + var (sin, cos) = Math.SinCos(angle); + return new Matrix4x4(1, 0, 0, 0, 0, cos, -sin, 0, 0, sin, cos, 0, 0, 0, 0, 1); + } + + /// + /// Creates a matrix that rotates points around the Y axis (counterclockwise when looking from +Y towards the + /// origin). + /// + /// The angle in radians. + /// The rotation matrix. + public static Matrix4x4 RotationY(double angle) + { + var (sin, cos) = Math.SinCos(angle); + return new Matrix4x4(cos, 0, sin, 0, 0, 1, 0, 0, -sin, 0, cos, 0, 0, 0, 0, 1); + } + + /// + /// Creates a matrix that rotates points around the Z axis (counterclockwise when looking from +Z towards the + /// origin). + /// + /// The angle in radians. + /// The rotation matrix. + public static Matrix4x4 RotationZ(double angle) + { + var (sin, cos) = Math.SinCos(angle); + return new Matrix4x4(cos, -sin, 0, 0, sin, cos, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + } + + /// + /// Creates a matrix that rotates around Z, then Y, then X (RotationX · RotationY · RotationZ). + /// + /// The angle around the X axis in radians. + /// The angle around the Y axis in radians. + /// The angle around the Z axis in radians. + /// The rotation matrix. + public static Matrix4x4 Rotation(double angleX, double angleY, double angleZ) => + RotationX(angleX) * RotationY(angleY) * RotationZ(angleZ); + + /// + /// Creates a right-handed perspective projection matrix that maps the view frustum to clip space with a depth range + /// of [0, 1]. + /// + /// The vertical field of view in radians, in (0, π). + /// The aspect ratio (width / height). Must be positive. + /// The distance to the near plane. Must be positive. + /// The distance to the far plane. Must be greater than . + /// The projection matrix. + /// An argument is out of range. + public static Matrix4x4 PerspectiveFieldOfView(double fieldOfView, double aspectRatio, double nearPlane, + double farPlane) + { + ProjectionData.Validate(fieldOfView, aspectRatio, nearPlane, farPlane); + + var yScale = 1 / Math.Tan(fieldOfView / 2); + var xScale = yScale / aspectRatio; + var range = farPlane / (nearPlane - farPlane); + return new Matrix4x4( + xScale, 0, 0, 0, + 0, yScale, 0, 0, + 0, 0, range, range * nearPlane, + 0, 0, -1, 0); + } + + /// + /// Creates a right-handed perspective projection matrix from projection data (see + /// ). + /// + /// The projection data. + /// The projection matrix. + public static Matrix4x4 PerspectiveFieldOfView(ProjectionData projectionData) => + PerspectiveFieldOfView(projectionData.FieldOfView, projectionData.AspectRatio, projectionData.NearPlane, + projectionData.FarPlane); + + /// + /// Creates a right-handed view matrix for a camera at looking at + /// . The camera looks along its local −Z axis. + /// + /// The camera position. + /// The point the camera looks at. + /// The approximate up direction; must not be parallel to the viewing direction. + /// The view matrix. + /// + /// equals , or is zero or parallel to the + /// viewing direction. + /// + public static Matrix4x4 LookAt(Vector3 eye, Vector3 target, Vector3 up) + { + var (xAxis, yAxis, zAxis) = OrthonormalBasis(eye - target, up, nameof(target)); + return new Matrix4x4( + xAxis.X, xAxis.Y, xAxis.Z, -xAxis.Dot(eye), + yAxis.X, yAxis.Y, yAxis.Z, -yAxis.Dot(eye), + zAxis.X, zAxis.Y, zAxis.Z, -zAxis.Dot(eye), + 0, 0, 0, 1); + } + + /// + /// Creates a world matrix that places an object at , oriented so that its local −Z axis + /// points along and its local Y axis approximately along . + /// + /// The position of the object. + /// The forward direction of the object. + /// The approximate up direction; must not be parallel to . + /// The world matrix. + /// + /// is zero, or is zero or parallel to it. + /// + public static Matrix4x4 World(Vector3 position, Vector3 forward, Vector3 up) + { + var (xAxis, yAxis, zAxis) = OrthonormalBasis(-forward, up, nameof(forward)); + return new Matrix4x4( + xAxis.X, yAxis.X, zAxis.X, position.X, + xAxis.Y, yAxis.Y, zAxis.Y, position.Y, + xAxis.Z, yAxis.Z, zAxis.Z, position.Z, + 0, 0, 0, 1); + } + + /// + /// Transforms a three-dimensional vector in homogeneous coordinates (matrix · (x, y, z, 1)). The result is + /// divided by the resulting W component if it is not 1 (perspective divide). + /// + /// The matrix. + /// The vector. + /// The transformed vector. + public static Vector3 operator *(Matrix4x4 matrix, Vector3 vector) + { + var x = matrix.M11 * vector.X + matrix.M12 * vector.Y + matrix.M13 * vector.Z + matrix.M14; + var y = matrix.M21 * vector.X + matrix.M22 * vector.Y + matrix.M23 * vector.Z + matrix.M24; + var z = matrix.M31 * vector.X + matrix.M32 * vector.Y + matrix.M33 * vector.Z + matrix.M34; + var w = matrix.M41 * vector.X + matrix.M42 * vector.Y + matrix.M43 * vector.Z + matrix.M44; + return w == 1 ? new Vector3(x, y, z) : new Vector3(x / w, y / w, z / w); + } + + private static (Vector3 XAxis, Vector3 YAxis, Vector3 ZAxis) OrthonormalBasis(Vector3 backward, Vector3 up, + string directionParameter) + { + if (backward.LengthSquared == 0) + throw new ArgumentException("The viewing direction must not be the zero vector.", directionParameter); + var zAxis = backward.Normalize(); + var x = up.Cross(zAxis); + if (up.LengthSquared == 0 || x.IsZero || up.IsParallelTo(zAxis)) + throw new ArgumentException("The up vector must not be zero or parallel to the viewing direction.", + nameof(up)); + var xAxis = x.Normalize(); + return (xAxis, zAxis.Cross(xAxis), zAxis); + } +} diff --git a/src/SharpMath/Geometry/Matrix4x4.cs b/src/SharpMath/Geometry/Matrix4x4.cs index fd0ed17..1e53d91 100644 --- a/src/SharpMath/Geometry/Matrix4x4.cs +++ b/src/SharpMath/Geometry/Matrix4x4.cs @@ -1,803 +1,501 @@ -// Matrix4x4.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; - -// ReSharper disable InconsistentNaming - -namespace SharpMath.Geometry +namespace SharpMath.Geometry; + +/// +/// Represents an immutable 4x4 matrix. +/// +/// +/// Vectors are column vectors: a matrix transforms a vector as matrix * vector, and transformations compose +/// from right to left (T * R * S * v scales first). Equality is exact; use +/// for tolerance-based comparisons. +/// +public readonly partial struct Matrix4x4 : ISquareMatrix, IEquatable { /// - /// Represents a 4x4 matrix and provides functions to transform 3-dimensional vectors. + /// Initializes a new instance of the struct from its entries in row-major order. /// - // ReSharper disable once InconsistentNaming - [Serializable] - public struct Matrix4x4 : IEnumerable, IEquatable, ISquareMatrix + /// The entry in row 1, column 1. + /// The entry in row 1, column 2. + /// The entry in row 1, column 3. + /// The entry in row 1, column 4. + /// The entry in row 2, column 1. + /// The entry in row 2, column 2. + /// The entry in row 2, column 3. + /// The entry in row 2, column 4. + /// The entry in row 3, column 1. + /// The entry in row 3, column 2. + /// The entry in row 3, column 3. + /// The entry in row 3, column 4. + /// The entry in row 4, column 1. + /// The entry in row 4, column 2. + /// The entry in row 4, column 3. + /// The entry in row 4, column 4. + public Matrix4x4(double m11, double m12, double m13, double m14, double m21, double m22, double m23, double m24, double m31, double m32, double m33, double m34, double m41, double m42, double m43, double m44) { - /// - /// Initializes a struct. - /// - /// The value at row 1 and column 1. - /// The value at row 1 and column 2. - /// The value at row 1 and column 3. - /// The value at row 1 and column 4. - /// The value at row 2 and column 1. - /// The value at row 2 and column 2 - /// The value at row 2 and column 3. - /// The value at row 2 and column 4. - /// The value at row 3 and column 1. - /// The value at row 3 and column 2. - /// The value at row 3 and column 3. - /// The value at row 3 and column 4. - /// The value at row 4 and column 1. - /// The value at row 4 and column 2. - /// The value at row 4 and column 3. - /// The value at row 4 and column 4. - public Matrix4x4(double m11, double m12, double m13, double m14, double m21, double m22, double m23, double m24, - double m31, double m32, double m33, double m34, double m41, double m42, double m43, double m44) - { - M11 = m11; - M12 = m12; - M13 = m13; - M14 = m14; - M21 = m21; - M22 = m22; - M23 = m23; - M24 = m24; - M31 = m31; - M32 = m32; - M33 = m33; - M34 = m34; - M41 = m41; - M42 = m42; - M43 = m43; - M44 = m44; - } + M11 = m11; + M12 = m12; + M13 = m13; + M14 = m14; + M21 = m21; + M22 = m22; + M23 = m23; + M24 = m24; + M31 = m31; + M32 = m32; + M33 = m33; + M34 = m34; + M41 = m41; + M42 = m42; + M43 = m43; + M44 = m44; + } - /// - /// Initializes a struct. - /// - /// The first row . - /// The second row . - /// The third row . - /// The fourth row . - public Matrix4x4(Vector4 row1, Vector4 row2, Vector4 row3, Vector4 row4) - : this( - row1.X, row2.X, row3.X, row4.X, row1.Y, row2.Y, row3.Y, row4.Y, row1.Z, row2.Z, row3.Z, row4.Z, row1.W, - row2.W, row3.W, row4.W) - { - } + /// + /// Initializes a new instance of the struct from its rows. + /// + /// Row 1. + /// Row 2. + /// Row 3. + /// Row 4. + public Matrix4x4(Vector4 row1, Vector4 row2, Vector4 row3, Vector4 row4) + : this(row1.X, row1.Y, row1.Z, row1.W, row2.X, row2.Y, row2.Z, row2.W, row3.X, row3.Y, row3.Z, row3.W, row4.X, row4.Y, row4.Z, row4.W) + { + } - /// - /// Gets the zero . - /// - public static Matrix4x4 Zero => new Matrix4x4(); - - /// - /// Gets or sets the identity . - /// - public static Matrix4x4 Identity => MatrixUtils.GetIdentity(); - - /// - /// Gets or sets the value at row 1 and column 1. - /// - public double M11 { get; set; } - - /// - /// Gets or sets the value at row 1 and column 2. - /// - public double M12 { get; set; } - - /// - /// Gets or sets the value at row 1 and column 3. - /// - public double M13 { get; set; } - - /// - /// Gets or sets the value at row 1 and column 4. - /// - public double M14 { get; set; } - - /// - /// Gets or sets the value at row 2 and column 1. - /// - public double M21 { get; set; } - - /// - /// Gets or sets the value at row 2 and column 2. - /// - public double M22 { get; set; } - - /// - /// Gets or sets the value at row 2 and column 3. - /// - public double M23 { get; set; } - - /// - /// Gets or sets the value at row 2 and column 4. - /// - public double M24 { get; set; } - - /// - /// Gets or sets the value at row 3 and column 1. - /// - public double M31 { get; set; } - - /// - /// Gets or sets the value at row 3 and column 2. - /// - public double M32 { get; set; } - - /// - /// Gets or sets the value at row 3 and column 3. - /// - public double M33 { get; set; } - - /// - /// Gets or sets the value at row 3 and column 4. - /// - public double M34 { get; set; } - - /// - /// Gets or sets the value at row 4 and column 1. - /// - public double M41 { get; set; } - - /// - /// Gets or sets the value at row 4 and column 2. - /// - public double M42 { get; set; } - - /// - /// Gets or sets the value at row 4 and column 3. - /// - public double M43 { get; set; } - - /// - /// Gets or sets the value at row 4 and column 4. - /// - public double M44 { get; set; } - - /// - /// Gets a value indicating whether the is singular, or not. If true, this - /// doesn't have an inverse. - /// - public bool IsSingular => Determinant.IsApproximatelyEqualTo(0); - - /// - /// Gets the inverse of the . - /// - public Matrix4x4 Inverse => MatrixUtils.GaussJordan(this, Identity); - - /// - /// Gets the cofactor of the . - /// - public Matrix4x4 CofactorMatrix => this.BuildCofactorMatrix(); - - /// - /// Gets the adjugate of the . - /// - public Matrix4x4 Adjugate => CofactorMatrix.GetTranspose(); - - /// - /// Gets the negated of the . - /// - public Matrix4x4 Negate => this.GetNegate(); - - /// - /// Gets the transpose of the . - /// - public Matrix4x4 Transpose => this.GetTranspose(); - - public IEnumerator GetEnumerator() - { - return new MatrixEnumerator(this); - } + /// + /// Gets the entry in row 1, column 1. + /// + public double M11 { get; } - IEnumerator IEnumerable.GetEnumerator() - { - return new MatrixEnumerator(this); - } + /// + /// Gets the entry in row 1, column 2. + /// + public double M12 { get; } - public bool Equals(Matrix4x4 other) - { - return this == other; - } + /// + /// Gets the entry in row 1, column 3. + /// + public double M13 { get; } - /// - /// Gets or sets the value at the specified index. The values are accessed as: (row*4) + column. - /// - /// The index. - /// The value at the specified index. - public double this[uint index] - { - get - { - switch (index) - { - case 0: - return M11; - case 1: - return M12; - case 2: - return M13; - case 3: - return M14; - case 4: - return M21; - case 5: - return M22; - case 6: - return M23; - case 7: - return M24; - case 8: - return M31; - case 9: - return M32; - case 10: - return M33; - case 11: - return M34; - case 12: - return M41; - case 13: - return M42; - case 14: - return M43; - case 15: - return M44; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 15."); - } - } - set - { - switch (index) - { - case 0: - M11 = value; - break; - case 1: - M12 = value; - break; - case 2: - M13 = value; - break; - case 3: - M14 = value; - break; - case 4: - M21 = value; - break; - case 5: - M22 = value; - break; - case 6: - M23 = value; - break; - case 7: - M24 = value; - break; - case 8: - M31 = value; - break; - case 9: - M32 = value; - break; - case 10: - M33 = value; - break; - case 11: - M34 = value; - break; - case 12: - M41 = value; - break; - case 13: - M42 = value; - break; - case 14: - M43 = value; - break; - case 15: - M44 = value; - break; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 15."); - } - } - } + /// + /// Gets the entry in row 1, column 4. + /// + public double M14 { get; } - /// - /// Gets or sets the value at the specified row and column. - /// - /// The row. - /// The column. - /// The value at the specified row and column. - public double this[uint row, uint column] - { - get => this[row * 4 + column]; - set => this[row * 4 + column] = value; - } + /// + /// Gets the entry in row 2, column 1. + /// + public double M21 { get; } - /// - /// Gets the dimension of the . - /// - public uint Dimension => 4; - - /// - /// Gets the row count of the . - /// - public uint RowCount => 4; - - /// - /// Gets the column count of the . - /// - public uint ColumnCount => 4; - - /// - /// Gets a value indicating whether the is orthogonal, or not. - /// - public bool IsOrthogonal => this * Transpose == Identity; - - /// - /// Gets a value indicating whether the is the identity , or not. - /// - public bool IsIdentity - => M11.IsApproximatelyEqualTo(1) && M22.IsApproximatelyEqualTo(1) && M33.IsApproximatelyEqualTo(1); - - /// - /// Gets the determinant of the . - /// - public double Determinant => this.GetDeterminant(); - - /// - /// Gets the trace of the . - /// - public double Trace => M11 + M22 + M33 + M44; - - /// - /// Gets a value indicating whether the is symmetric, or not. - /// - public bool IsSymmetric => this == Transpose; - - /// - /// Gets a value indicating whether the is skew symmetric, or not. - /// - public bool IsSkewSymmetric => Negate == Transpose; - - /// - /// Gets a value indicating whether the is antisymmetric, or not. - /// - public bool IsAntiSymmetric => this == Transpose.Negate; - - /// - /// Gets a value indicating whether the is a diagonal matrix, or not. - /// - public bool IsDiagonal => this.GetIsDiagonal(); - - /// - /// Gets a value indicating whether the is a triangle matrix, or not. - /// - public bool IsTriangle => this.GetIsTriangle(); - - /// - /// Creates a from an abstract object. - /// - /// The to convert. - /// The that has been created. - public static Matrix4x4 FromMatrix(IMatrix matrix) - { - if (matrix.RowCount != 4 || matrix.ColumnCount != 4) - throw new ArgumentException("The square matrix cannot be converted into a Matrix4x4"); - - var resultMatrix = new Matrix4x4(); - for (uint y = 0; y < resultMatrix.Dimension; ++y) - for (uint x = 0; x < resultMatrix.Dimension; ++x) - resultMatrix[y, x] = matrix[y, x]; - return resultMatrix; - } + /// + /// Gets the entry in row 2, column 2. + /// + public double M22 { get; } - /// - /// Creates a that represents a scalation. - /// - /// The scalation factor of the X-component. - /// The scalation factor of the Y-component. - /// The scalation factor of the Z-component. - /// A that represents a scalation using the specified factors. - public static Matrix4x4 Scalation(double scaleX, double scaleY, double scaleZ) - { - var matrix = Identity; - matrix[0, 0] = scaleX; - matrix[1, 1] = scaleY; - matrix[2, 2] = scaleZ; + /// + /// Gets the entry in row 2, column 3. + /// + public double M23 { get; } - return matrix; - } + /// + /// Gets the entry in row 2, column 4. + /// + public double M24 { get; } - /// - /// Creates a that represents a scalation. - /// - /// The scalation factor of all components. - /// A that represents a scalation using the specified factor. - public static Matrix4x4 Scalation(double scale) - { - return Scalation(scale, scale, scale); - } + /// + /// Gets the entry in row 3, column 1. + /// + public double M31 { get; } - /// - /// Creates a by multiplying the built instances that represent - /// rotations around the relating angles. - /// - /// The angle to rotate around the X-axis in radians. - /// The angle to rotate around the Y-axis in radians. - /// The angle to rotate around the Z-axis in radians. - /// - public static Matrix4x4 Rotation(double angleX, double angleY, double angleZ) - { - return RotationX(angleX) * RotationY(angleY) * RotationZ(angleZ); - } + /// + /// Gets the entry in row 3, column 2. + /// + public double M32 { get; } - /// - /// Creates a that represents a rotation around the X-axis. - /// - /// The angle to rotate around in radians. - /// A that represents a rotation around the X-axis using the specified angle. - // https://ksgamedev.files.wordpress.com/2010/01/matrix-rotation.png - public static Matrix4x4 RotationX(double angle) - { - var sin = Math.Sin(angle); - var cos = Math.Cos(angle); + /// + /// Gets the entry in row 3, column 3. + /// + public double M33 { get; } - var matrix = Identity; - matrix[1, 1] = cos; - matrix[1, 2] = -sin; - matrix[2, 1] = sin; - matrix[2, 2] = cos; + /// + /// Gets the entry in row 3, column 4. + /// + public double M34 { get; } - return matrix; - } + /// + /// Gets the entry in row 4, column 1. + /// + public double M41 { get; } - /// - /// Creates a that represents a rotation around the Y-axis. - /// - /// The angle to rotate around in radians. - /// A that represents a rotation around the Y-axis using the specified angle. - public static Matrix4x4 RotationY(double angle) - { - var sin = Math.Sin(angle); - var cos = Math.Cos(angle); + /// + /// Gets the entry in row 4, column 2. + /// + public double M42 { get; } - var matrix = Identity; - matrix[0, 0] = cos; - matrix[0, 2] = sin; - matrix[2, 0] = -sin; - matrix[2, 2] = cos; + /// + /// Gets the entry in row 4, column 3. + /// + public double M43 { get; } - return matrix; - } + /// + /// Gets the entry in row 4, column 4. + /// + public double M44 { get; } - /// - /// Creates a that represents a rotation around the Z-axis. - /// - /// The angle to rotate around in radians. - /// A that represents a rotation around the Z-axis using the specified angle. - public static Matrix4x4 RotationZ(double angle) - { - var sin = Math.Sin(angle); - var cos = Math.Cos(angle); + /// + /// Gets the zero matrix. + /// + public static Matrix4x4 Zero => default; - var matrix = Identity; - matrix[0, 0] = cos; - matrix[0, 1] = -sin; - matrix[1, 0] = sin; - matrix[1, 1] = cos; + /// + /// Gets the identity matrix. + /// + public static Matrix4x4 Identity => new(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); - return matrix; + /// + /// Gets the entry at the specified row-major index (row · 4 + column). + /// + /// The zero-based index between 0 and 15. + /// is out of range. + public double this[int index] => index switch + { + 0 => M11, + 1 => M12, + 2 => M13, + 3 => M14, + 4 => M21, + 5 => M22, + 6 => M23, + 7 => M24, + 8 => M31, + 9 => M32, + 10 => M33, + 11 => M34, + 12 => M41, + 13 => M42, + 14 => M43, + 15 => M44, + _ => throw MatrixMath.IndexOutOfRange(nameof(index), index, 16) + }; + + /// + public double this[int row, int column] + { + get + { + MatrixMath.CheckIndices(row, column, 4); + return this[row * 4 + column]; } + } - /// - /// Creates a that represents a translation. - /// - /// The translation of the X-component. - /// The translation of the Y-component. - /// The translation of the Z-component. - /// A that represents a translation using the specified addends. - public static Matrix4x4 Translation(double x, double y, double z) - { - var matrix = Identity; - matrix[0, 3] = x; - matrix[1, 3] = y; - matrix[2, 3] = z; + /// + public int RowCount => 4; - return matrix; - } + /// + public int ColumnCount => 4; - /// - /// Creates a perspective projection based on a field of view. - /// - /// The to use for the calculations. - /// The created perspective projection using the specified . - public static Matrix4x4 PerspectiveFieldOfView(ProjectionData projectionData) - { - // https://msdn.microsoft.com/en-us/library/bb205351%28v=vs.85%29.aspx - /* - xScale 0 0 0 - 0 yScale 0 0 - 0 0 zf/(zn-zf) -1 - 0 0 zn*zf/(zn-zf) 0 - - yScale = cot(fovY/2) - xScale = yScale / aspect ratio - */ - - var yScale = 1.0f / (float) Math.Tan(projectionData.FieldOfView / 2f); - var xScale = yScale / projectionData.AspectRatio; - - var invertedDepth = -projectionData.Depth; - var matrix = Identity; - matrix[0, 0] = xScale; - matrix[1, 1] = yScale; - matrix[2, 2] = projectionData.FarPlane / invertedDepth; - matrix[2, 3] = -1.0f; - matrix[3, 2] = projectionData.NearPlane * projectionData.FarPlane / invertedDepth; - - return matrix; - } + /// + public int Dimension => 4; - /// - /// Creates a view using the specified parameters. - /// - /// The camera position. - /// The target position of the camera. - /// The up vector. - /// The created view . - public static Matrix4x4 View(Vector3 cameraPosition, Vector3 cameraTarget, Vector3 upVector) - { - var vector = Vector3.FromVector((cameraPosition - cameraTarget).Normalize()); - var vector2 = Vector3.FromVector(Vector3.VectorProduct(upVector, vector)); - var vector3 = Vector3.FromVector(Vector3.VectorProduct(vector, vector2)); + /// + public double Trace => M11 + M22 + M33 + M44; - var matrix = Identity; - matrix[0, 0] = vector2.X; - matrix[0, 1] = vector3.X; - matrix[0, 2] = vector.X; + /// + public bool IsIdentity => Is(MatrixMath.Predicate.Identity); - matrix[1, 0] = vector2.Y; - matrix[1, 1] = vector3.Y; - matrix[1, 2] = vector.Y; + /// + public bool IsDiagonal => Is(MatrixMath.Predicate.Diagonal); - matrix[2, 0] = vector2.Z; - matrix[2, 1] = vector3.Z; - matrix[2, 2] = vector.Z; + /// + public bool IsTriangular => Is(MatrixMath.Predicate.Triangular); - matrix[3, 0] = -vector2.DotProduct(cameraPosition); - matrix[3, 1] = -vector3.DotProduct(cameraPosition); - matrix[3, 2] = -vector.DotProduct(cameraPosition); - matrix[3, 3] = 1f; + /// + public bool IsSymmetric => Is(MatrixMath.Predicate.Symmetric); - return matrix; - } + /// + public bool IsSkewSymmetric => Is(MatrixMath.Predicate.SkewSymmetric); - /// - /// Creates a world by using the specified components. - /// - /// The position. - /// The target vector. - /// The up vector. - /// The created world . - public static Matrix4x4 World(Vector3 position, Vector3 forward, Vector3 up) - { - var xVector = Vector3.VectorProduct(forward, up); - var yVector = Vector3.VectorProduct(xVector, forward); - var zVector = Vector3.FromVector(forward.Normalize()); - xVector.Normalize(); - yVector.Normalize(); - - var result = new Matrix4x4 - { - [0, 0] = xVector.X, - [0, 1] = xVector.Y, - [0, 2] = xVector.Z, - [1, 0] = yVector.X, - [1, 1] = yVector.Y, - [1, 2] = yVector.Z, - [2, 0] = -zVector.X, - [2, 1] = -zVector.Y, - [2, 2] = -zVector.Z, - [3, 0] = position.X, - [3, 1] = position.Y, - [3, 2] = position.Z, - [3, 3] = 1f - }; - - return result; - } + /// + public bool IsOrthogonal => (this * Transpose()).IsIdentity; - /// - /// Implements the operator +. - /// - /// The first . - /// The second . - /// - /// The result of the operator. - /// - public static Matrix4x4 operator +(Matrix4x4 firstMatrix, Matrix4x4 secondMatrix) - { - return MatrixUtils.Add(firstMatrix, secondMatrix); - } + /// + public bool IsSingular => Is(MatrixMath.Predicate.Singular); - /// - /// Implements the operator -. - /// - /// The first . - /// The second . - /// - /// The result of the operator. - /// - public static Matrix4x4 operator -(Matrix4x4 firstMatrix, Matrix4x4 secondMatrix) - { - return MatrixUtils.Subtract(firstMatrix, secondMatrix); - } + /// + /// Creates a matrix from any with 4 rows and 4 columns. + /// + /// The matrix to copy. + /// The copied matrix. + /// The matrix is not 4x4. + public static Matrix4x4 FromMatrix(IMatrix matrix) + { + ArgumentNullException.ThrowIfNull(matrix); + if (matrix.RowCount != 4 || matrix.ColumnCount != 4) + throw new ArgumentException("The matrix must have 4 rows and 4 columns.", nameof(matrix)); + return new Matrix4x4(matrix[0, 0], matrix[0, 1], matrix[0, 2], matrix[0, 3], matrix[1, 0], matrix[1, 1], matrix[1, 2], matrix[1, 3], matrix[2, 0], matrix[2, 1], matrix[2, 2], matrix[2, 3], matrix[3, 0], matrix[3, 1], matrix[3, 2], matrix[3, 3]); + } - /// - /// Implements the operator * to multiply a with the specified scalar. - /// - /// The scalar. - /// The . - /// - /// The result of the operator. - /// - public static Matrix4x4 operator *(double scalar, Matrix4x4 matrix) - { - return matrix.Multiply(scalar); - } + /// + /// Returns the transpose. + /// + /// The transposed matrix. + public Matrix4x4 Transpose() => new(M11, M21, M31, M41, M12, M22, M32, M42, M13, M23, M33, M43, M14, M24, M34, M44); - /// - /// Implements the operator * to multiply a with the specified scalar. - /// - /// The . - /// The scalar. - /// - /// The result of the operator. - /// - public static Matrix4x4 operator *(Matrix4x4 matrix, double scalar) - { - return matrix.Multiply(scalar); - } + /// + /// Returns the matrix of cofactors. + /// + /// The cofactor matrix. + public Matrix4x4 Cofactors() => Adjugate().Transpose(); - /// - /// Implements the operator *. - /// - /// The first . - /// The second . - /// - /// The result of the operator. - /// - public static Matrix4x4 operator *(Matrix4x4 firstMatrix, Matrix4x4 secondMatrix) - { - return MatrixUtils.Multiply(firstMatrix, secondMatrix); - } + /// + /// Returns the inverse. + /// + /// The inverse matrix. + /// The matrix is singular (see ). + public Matrix4x4 Inverse() => TryInvert(out var inverse) ? inverse : throw MatrixMath.SingularMatrix(); - /// - /// Implements the operator * to transform a with a . - /// - /// The . - /// The . - /// - /// The result of the operator. - /// - public static Vector3 operator *(Matrix4x4 matrix, Vector3 vector) + /// + /// Tries to calculate the inverse. + /// + /// The inverse, or if the matrix is singular. + /// if the matrix is invertible; otherwise, . + public bool TryInvert(out Matrix4x4 inverse) + { + if (IsSingular) { - var resultMatrix = MatrixUtils.Multiply(matrix, - new Vector4(vector.X, vector.Y, vector.Z, 1).AsVerticalMatrix()); - var resultVector = new Vector4(resultMatrix[0], resultMatrix[1], resultMatrix[2], resultMatrix[3]); - resultVector.X /= resultVector.W; - resultVector.Y /= resultVector.W; - resultVector.Z /= resultVector.W; - return resultVector.Convert(); + inverse = default; + return false; } - /// - /// Implements the operator * to transform a with a . - /// - /// The . - /// The . - /// - /// The result of the operator. - /// - public static Vector3 operator *(Vector3 vector, Matrix4x4 matrix) - { - return matrix * vector; - } + // Expanding along the first row reuses the minors already computed for the adjugate. + var adjugate = Adjugate(); + inverse = adjugate / (M11 * adjugate.M11 + M12 * adjugate.M21 + M13 * adjugate.M31 + M14 * adjugate.M41); + return true; + } - /// - /// Implements the operator * to transform a with a . - /// - /// The . - /// The . - /// - /// The result of the operator. - /// - public static Vector4 operator *(Matrix4x4 matrix, Vector4 vector) - { - var resultMatrix = MatrixUtils.Multiply(matrix, vector.AsVerticalMatrix()); - return new Vector4(resultMatrix[0], resultMatrix[1], resultMatrix[2], resultMatrix[3]); - } + /// + /// Determines whether this matrix equals another matrix within the default tolerances (entry-wise). + /// + /// The other matrix. + /// if the matrices are approximately equal; otherwise, . + public bool ApproximatelyEquals(Matrix4x4 other) => + ApproximatelyEquals(other, Tolerance.DefaultAbsolute, Tolerance.DefaultRelative); - /// - /// Implements the operator * to transform a with a . - /// - /// The . - /// The . - /// - /// The result of the operator. - /// - public static Vector4 operator *(Vector4 vector, Matrix4x4 matrix) - { - return matrix * vector; - } + /// + /// Determines whether this matrix equals another matrix within the specified tolerances (entry-wise). + /// + /// The other matrix. + /// The absolute tolerance. + /// The relative tolerance. + /// if the matrices are approximately equal; otherwise, . + public bool ApproximatelyEquals(Matrix4x4 other, double absolute, double relative) + { + Span entries = stackalloc double[16]; + Span otherEntries = stackalloc double[16]; + CopyTo(entries); + other.CopyTo(otherEntries); + return MatrixMath.ApproximatelyEqual(entries, otherEntries, absolute, relative); + } - /// - /// Determines whether the specified , is equal to the instance. - /// - /// The to compare with the instance. - /// - /// true if the specified is equal to the instance; otherwise, false. - /// - public override bool Equals(object obj) - { - if (!(obj is Matrix4x4)) - return false; - return this == (Matrix4x4) obj; - } + /// + public override string ToString() + { + Span entries = stackalloc double[16]; + CopyTo(entries); + return MatrixMath.Format(nameof(Matrix4x4), entries, 4, 4); + } - /// - /// Returns a hash code for the instance. - /// - /// - /// A hash code for the instance, suitable for use in hashing algorithms and data structures like a hash table. - /// - public override int GetHashCode() - { - unchecked - { - var hash = 17; - for (uint y = 0; y < RowCount; ++y) - for (uint x = 0; x < ColumnCount; ++x) - hash = hash * 23 + this[y, x].GetHashCode(); - return hash; - } - } + /// + /// Copies the entries in row-major order. + /// + internal void CopyTo(Span destination) + { + destination[0] = M11; + destination[1] = M12; + destination[2] = M13; + destination[3] = M14; + destination[4] = M21; + destination[5] = M22; + destination[6] = M23; + destination[7] = M24; + destination[8] = M31; + destination[9] = M32; + destination[10] = M33; + destination[11] = M34; + destination[12] = M41; + destination[13] = M42; + destination[14] = M43; + destination[15] = M44; + } - /// - /// Implements the operator ==. - /// - /// The left . - /// The right . - /// - /// The result of the operator. - /// - public static bool operator ==(Matrix4x4 left, Matrix4x4 right) - { - return left.SequenceEqual(right); - } + private bool Is(MatrixMath.Predicate predicate) + { + Span entries = stackalloc double[16]; + CopyTo(entries); + return MatrixMath.Evaluate(predicate, entries, 4); + } - /// - /// Implements the operator !=. - /// - /// The left . - /// The right . - /// - /// The result of the operator. - /// - public static bool operator !=(Matrix4x4 left, Matrix4x4 right) - { - return !left.SequenceEqual(right); - } + /// + public bool Equals(Matrix4x4 other) => + M11.Equals(other.M11) && + M12.Equals(other.M12) && + M13.Equals(other.M13) && + M14.Equals(other.M14) && + M21.Equals(other.M21) && + M22.Equals(other.M22) && + M23.Equals(other.M23) && + M24.Equals(other.M24) && + M31.Equals(other.M31) && + M32.Equals(other.M32) && + M33.Equals(other.M33) && + M34.Equals(other.M34) && + M41.Equals(other.M41) && + M42.Equals(other.M42) && + M43.Equals(other.M43) && + M44.Equals(other.M44); + + /// + public override bool Equals(object? obj) => obj is Matrix4x4 other && Equals(other); + + /// + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(M11); + hash.Add(M12); + hash.Add(M13); + hash.Add(M14); + hash.Add(M21); + hash.Add(M22); + hash.Add(M23); + hash.Add(M24); + hash.Add(M31); + hash.Add(M32); + hash.Add(M33); + hash.Add(M34); + hash.Add(M41); + hash.Add(M42); + hash.Add(M43); + hash.Add(M44); + return hash.ToHashCode(); } -} \ No newline at end of file + + /// + /// Adds two matrices. + /// + /// The first matrix. + /// The second matrix. + /// The sum. + public static Matrix4x4 operator +(Matrix4x4 left, Matrix4x4 right) => + new(left.M11 + right.M11, left.M12 + right.M12, left.M13 + right.M13, left.M14 + right.M14, left.M21 + right.M21, left.M22 + right.M22, left.M23 + right.M23, left.M24 + right.M24, left.M31 + right.M31, left.M32 + right.M32, left.M33 + right.M33, left.M34 + right.M34, left.M41 + right.M41, left.M42 + right.M42, left.M43 + right.M43, left.M44 + right.M44); + + /// + /// Subtracts a matrix from another matrix. + /// + /// The minuend. + /// The subtrahend. + /// The difference. + public static Matrix4x4 operator -(Matrix4x4 left, Matrix4x4 right) => + new(left.M11 - right.M11, left.M12 - right.M12, left.M13 - right.M13, left.M14 - right.M14, left.M21 - right.M21, left.M22 - right.M22, left.M23 - right.M23, left.M24 - right.M24, left.M31 - right.M31, left.M32 - right.M32, left.M33 - right.M33, left.M34 - right.M34, left.M41 - right.M41, left.M42 - right.M42, left.M43 - right.M43, left.M44 - right.M44); + + /// + /// Negates a matrix. + /// + /// The matrix. + /// The negated matrix. + public static Matrix4x4 operator -(Matrix4x4 matrix) => + new(-matrix.M11, -matrix.M12, -matrix.M13, -matrix.M14, -matrix.M21, -matrix.M22, -matrix.M23, -matrix.M24, -matrix.M31, -matrix.M32, -matrix.M33, -matrix.M34, -matrix.M41, -matrix.M42, -matrix.M43, -matrix.M44); + + /// + /// Multiplies a matrix with a scalar. + /// + /// The matrix. + /// The scalar. + /// The scaled matrix. + public static Matrix4x4 operator *(Matrix4x4 matrix, double scalar) => + new(matrix.M11 * scalar, matrix.M12 * scalar, matrix.M13 * scalar, matrix.M14 * scalar, matrix.M21 * scalar, matrix.M22 * scalar, matrix.M23 * scalar, matrix.M24 * scalar, matrix.M31 * scalar, matrix.M32 * scalar, matrix.M33 * scalar, matrix.M34 * scalar, matrix.M41 * scalar, matrix.M42 * scalar, matrix.M43 * scalar, matrix.M44 * scalar); + + /// + /// Multiplies a scalar with a matrix. + /// + /// The scalar. + /// The matrix. + /// The scaled matrix. + public static Matrix4x4 operator *(double scalar, Matrix4x4 matrix) => matrix * scalar; + + /// + /// Divides a matrix by a scalar. + /// + /// The matrix. + /// The scalar. + /// The scaled matrix. + public static Matrix4x4 operator /(Matrix4x4 matrix, double scalar) => + new(matrix.M11 / scalar, matrix.M12 / scalar, matrix.M13 / scalar, matrix.M14 / scalar, matrix.M21 / scalar, matrix.M22 / scalar, matrix.M23 / scalar, matrix.M24 / scalar, matrix.M31 / scalar, matrix.M32 / scalar, matrix.M33 / scalar, matrix.M34 / scalar, matrix.M41 / scalar, matrix.M42 / scalar, matrix.M43 / scalar, matrix.M44 / scalar); + + /// + /// Multiplies two matrices. + /// + /// The left matrix. + /// The right matrix. + /// The product left · right. + public static Matrix4x4 operator *(Matrix4x4 left, Matrix4x4 right) => + new(left.M11 * right.M11 + left.M12 * right.M21 + left.M13 * right.M31 + left.M14 * right.M41, + left.M11 * right.M12 + left.M12 * right.M22 + left.M13 * right.M32 + left.M14 * right.M42, + left.M11 * right.M13 + left.M12 * right.M23 + left.M13 * right.M33 + left.M14 * right.M43, + left.M11 * right.M14 + left.M12 * right.M24 + left.M13 * right.M34 + left.M14 * right.M44, + left.M21 * right.M11 + left.M22 * right.M21 + left.M23 * right.M31 + left.M24 * right.M41, + left.M21 * right.M12 + left.M22 * right.M22 + left.M23 * right.M32 + left.M24 * right.M42, + left.M21 * right.M13 + left.M22 * right.M23 + left.M23 * right.M33 + left.M24 * right.M43, + left.M21 * right.M14 + left.M22 * right.M24 + left.M23 * right.M34 + left.M24 * right.M44, + left.M31 * right.M11 + left.M32 * right.M21 + left.M33 * right.M31 + left.M34 * right.M41, + left.M31 * right.M12 + left.M32 * right.M22 + left.M33 * right.M32 + left.M34 * right.M42, + left.M31 * right.M13 + left.M32 * right.M23 + left.M33 * right.M33 + left.M34 * right.M43, + left.M31 * right.M14 + left.M32 * right.M24 + left.M33 * right.M34 + left.M34 * right.M44, + left.M41 * right.M11 + left.M42 * right.M21 + left.M43 * right.M31 + left.M44 * right.M41, + left.M41 * right.M12 + left.M42 * right.M22 + left.M43 * right.M32 + left.M44 * right.M42, + left.M41 * right.M13 + left.M42 * right.M23 + left.M43 * right.M33 + left.M44 * right.M43, + left.M41 * right.M14 + left.M42 * right.M24 + left.M43 * right.M34 + left.M44 * right.M44); + + /// + /// Transforms a column vector. + /// + /// The matrix. + /// The vector. + /// The product matrix · vector. + public static Vector4 operator *(Matrix4x4 matrix, Vector4 vector) => + new(matrix.M11 * vector.X + matrix.M12 * vector.Y + matrix.M13 * vector.Z + matrix.M14 * vector.W, + matrix.M21 * vector.X + matrix.M22 * vector.Y + matrix.M23 * vector.Z + matrix.M24 * vector.W, + matrix.M31 * vector.X + matrix.M32 * vector.Y + matrix.M33 * vector.Z + matrix.M34 * vector.W, + matrix.M41 * vector.X + matrix.M42 * vector.Y + matrix.M43 * vector.Z + matrix.M44 * vector.W); + + /// + /// Determines whether two matrices are exactly equal (entry-wise ==). + /// + /// The first matrix. + /// The second matrix. + /// if all entries are equal; otherwise, . + public static bool operator ==(Matrix4x4 left, Matrix4x4 right) => + left.M11 == right.M11 && + left.M12 == right.M12 && + left.M13 == right.M13 && + left.M14 == right.M14 && + left.M21 == right.M21 && + left.M22 == right.M22 && + left.M23 == right.M23 && + left.M24 == right.M24 && + left.M31 == right.M31 && + left.M32 == right.M32 && + left.M33 == right.M33 && + left.M34 == right.M34 && + left.M41 == right.M41 && + left.M42 == right.M42 && + left.M43 == right.M43 && + left.M44 == right.M44; + + /// + /// Determines whether two matrices differ in at least one entry. + /// + /// The first matrix. + /// The second matrix. + /// if any entry differs; otherwise, . + public static bool operator !=(Matrix4x4 left, Matrix4x4 right) => !(left == right); +} diff --git a/src/SharpMath/Geometry/MatrixEnumerator.cs b/src/SharpMath/Geometry/MatrixEnumerator.cs deleted file mode 100644 index f96fa5c..0000000 --- a/src/SharpMath/Geometry/MatrixEnumerator.cs +++ /dev/null @@ -1,39 +0,0 @@ -// MatrixEnumerator.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System.Collections; -using System.Collections.Generic; - -namespace SharpMath.Geometry -{ - internal class MatrixEnumerator : IEnumerator - { - private readonly IMatrix _matrix; - private int _index; - - internal MatrixEnumerator(IMatrix m) - { - _matrix = m; - _index = -1; - } - - public void Dispose() - { - } - - object IEnumerator.Current => Current; - - public bool MoveNext() - { - _index++; - return _index < _matrix.ColumnCount * _matrix.RowCount; - } - - public void Reset() - { - _index = -1; - } - - public double Current => _matrix[(uint) _index % _matrix.ColumnCount, (uint) _index / _matrix.RowCount]; - } -} diff --git a/src/SharpMath/Geometry/MatrixMath.cs b/src/SharpMath/Geometry/MatrixMath.cs new file mode 100644 index 0000000..2472008 --- /dev/null +++ b/src/SharpMath/Geometry/MatrixMath.cs @@ -0,0 +1,183 @@ +using System.Globalization; +using System.Text; + +namespace SharpMath.Geometry; + +/// +/// Shared helpers for the matrix types. They work on row-major spans, so the fixed-size matrices can copy their +/// entries to the stack and the checks run without boxing or allocations. +/// +internal static class MatrixMath +{ + /// + /// The machine epsilon of (2⁻⁵²). + /// + public const double MachineEpsilon = 2.220446049250313e-16; + + public enum Predicate + { + Identity, + Diagonal, + Triangular, + Symmetric, + SkewSymmetric, + Singular + } + + public static bool Evaluate(Predicate predicate, ReadOnlySpan entries, int dimension) => predicate switch + { + Predicate.Identity => IsIdentity(entries, dimension), + Predicate.Diagonal => IsDiagonal(entries, dimension), + Predicate.Triangular => IsTriangular(entries, dimension), + Predicate.Symmetric => IsSymmetric(entries, dimension, -1), + Predicate.SkewSymmetric => IsSymmetric(entries, dimension, 1), + _ => IsSingular(entries, dimension) + }; + + public static double MaxAbs(ReadOnlySpan values) + { + var max = 0.0; + foreach (var value in values) + max = Math.Max(max, Math.Abs(value)); + return max; + } + + /// + /// The pivot size below which a matrix counts as singular: n · ε · s, where s is the largest + /// absolute entry. This is the usual backward-stable rank test of an LU decomposition with partial pivoting. + /// + public static double SingularPivotThreshold(int dimension, double scale) => dimension * MachineEpsilon * scale; + + /// + /// Determines singularity with an LU decomposition with partial pivoting (on a stack copy). + /// + public static bool IsSingular(ReadOnlySpan entries, int dimension) + { + var threshold = SingularPivotThreshold(dimension, MaxAbs(entries)); + if (!(threshold > 0)) + return true; + + Span a = stackalloc double[entries.Length]; + entries.CopyTo(a); + for (var k = 0; k < dimension; k++) + { + var pivotRow = k; + for (var row = k + 1; row < dimension; row++) + if (Math.Abs(a[row * dimension + k]) > Math.Abs(a[pivotRow * dimension + k])) + pivotRow = row; + + var pivot = a[pivotRow * dimension + k]; + if (!(Math.Abs(pivot) > threshold)) + return true; + + if (pivotRow != k) + for (var column = k; column < dimension; column++) + (a[k * dimension + column], a[pivotRow * dimension + column]) = + (a[pivotRow * dimension + column], a[k * dimension + column]); + + for (var row = k + 1; row < dimension; row++) + { + var factor = a[row * dimension + k] / pivot; + for (var column = k + 1; column < dimension; column++) + a[row * dimension + column] -= factor * a[k * dimension + column]; + } + } + + return false; + } + + public static bool IsIdentity(ReadOnlySpan entries, int dimension) + { + for (var row = 0; row < dimension; row++) + for (var column = 0; column < dimension; column++) + if (!Tolerance.AreEqual(entries[row * dimension + column], row == column ? 1 : 0)) + return false; + return true; + } + + // Structural checks compare entries relative to the largest entry only, so they do not depend on the scale. + private static bool IsNegligible(double value, double scale) => Math.Abs(value) <= Tolerance.DefaultRelative * scale; + + private static bool IsDiagonal(ReadOnlySpan entries, int dimension) + { + var scale = MaxAbs(entries); + for (var row = 0; row < dimension; row++) + for (var column = 0; column < dimension; column++) + if (row != column && !IsNegligible(entries[row * dimension + column], scale)) + return false; + return true; + } + + private static bool IsTriangular(ReadOnlySpan entries, int dimension) + { + var scale = MaxAbs(entries); + bool upper = true, lower = true; + for (var row = 0; row < dimension; row++) + for (var column = 0; column < dimension; column++) + { + if (row == column || IsNegligible(entries[row * dimension + column], scale)) + continue; + if (row > column) + upper = false; + else + lower = false; + } + + return upper || lower; + } + + /// + /// Checks a[i,j] = −sign · a[j,i]: −1 tests symmetry, +1 skew symmetry. + /// + private static bool IsSymmetric(ReadOnlySpan entries, int dimension, int sign) + { + var scale = MaxAbs(entries); + for (var row = 0; row < dimension; row++) + for (var column = row; column < dimension; column++) + if (!IsNegligible(entries[row * dimension + column] + sign * entries[column * dimension + row], scale)) + return false; + return true; + } + + public static bool ApproximatelyEqual(ReadOnlySpan left, ReadOnlySpan right, double absolute, + double relative) + { + for (var i = 0; i < left.Length; i++) + if (!Tolerance.AreEqual(left[i], right[i], absolute, relative)) + return false; + return true; + } + + public static string Format(string name, ReadOnlySpan entries, int rowCount, int columnCount) + { + var builder = new StringBuilder(name).Append(" ["); + for (var row = 0; row < rowCount; row++) + { + builder.Append(row == 0 ? "[" : ", ["); + for (var column = 0; column < columnCount; column++) + { + if (column > 0) + builder.Append(", "); + builder.Append(entries[row * columnCount + column].ToString(CultureInfo.InvariantCulture)); + } + + builder.Append(']'); + } + + return builder.Append(']').ToString(); + } + + public static ArgumentOutOfRangeException IndexOutOfRange(string parameterName, int value, int count) => + new(parameterName, value, $"The index must be between 0 and {count - 1}."); + + public static void CheckIndices(int row, int column, int dimension) + { + if ((uint) row >= (uint) dimension) + throw IndexOutOfRange(nameof(row), row, dimension); + if ((uint) column >= (uint) dimension) + throw IndexOutOfRange(nameof(column), column, dimension); + } + + public static InvalidOperationException SingularMatrix() => + new("The matrix is singular and has no inverse."); +} diff --git a/src/SharpMath/Geometry/MatrixMxN.cs b/src/SharpMath/Geometry/MatrixMxN.cs new file mode 100644 index 0000000..970999a --- /dev/null +++ b/src/SharpMath/Geometry/MatrixMxN.cs @@ -0,0 +1,608 @@ +using System.Diagnostics.CodeAnalysis; + +namespace SharpMath.Geometry; + +/// +/// Represents an immutable matrix of arbitrary size. +/// +/// +/// Determinant, inverse and solving use an LU decomposition with partial pivoting. A matrix is treated as singular if +/// a pivot is not larger than n · ε · s, where ε is the machine epsilon of and +/// s the largest absolute entry; the fixed-size matrices use the same rule for IsSingular. Equality is exact; use for +/// tolerance-based comparisons. +/// +public sealed class MatrixMxN : IMatrix, IEquatable +{ + private readonly double[] _values; + + /// + /// Initializes a new instance of the class by copying a two-dimensional array. + /// + /// The entries, indexed as [row, column]. + /// has no rows or no columns. + public MatrixMxN(double[,] values) + { + ArgumentNullException.ThrowIfNull(values); + RowCount = values.GetLength(0); + ColumnCount = values.GetLength(1); + if (RowCount == 0 || ColumnCount == 0) + throw new ArgumentException("A matrix needs at least one row and one column.", nameof(values)); + + _values = new double[RowCount * ColumnCount]; + for (var row = 0; row < RowCount; row++) + for (var column = 0; column < ColumnCount; column++) + _values[row * ColumnCount + column] = values[row, column]; + } + + /// + /// Initializes a new instance of the class from entries in row-major order. + /// + /// The number of rows. Must be positive. + /// The number of columns. Must be positive. + /// The entries in row-major order; exactly rowCount · columnCount values. + /// A count is not positive. + /// The number of values does not match the size. + public MatrixMxN(int rowCount, int columnCount, ReadOnlySpan rowMajorValues) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(rowCount); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(columnCount); + if (rowMajorValues.Length != rowCount * columnCount) + throw new ArgumentException($"Expected {rowCount * columnCount} values.", nameof(rowMajorValues)); + + RowCount = rowCount; + ColumnCount = columnCount; + _values = rowMajorValues.ToArray(); + } + + private MatrixMxN(int rowCount, int columnCount, double[] values) + { + RowCount = rowCount; + ColumnCount = columnCount; + _values = values; + } + + /// + public int RowCount { get; } + + /// + public int ColumnCount { get; } + + /// + /// Gets a value indicating whether the matrix has as many rows as columns. + /// + public bool IsSquare => RowCount == ColumnCount; + + /// + public double this[int row, int column] + { + get + { + if ((uint) row >= (uint) RowCount) + throw MatrixMath.IndexOutOfRange(nameof(row), row, RowCount); + if ((uint) column >= (uint) ColumnCount) + throw MatrixMath.IndexOutOfRange(nameof(column), column, ColumnCount); + return _values[row * ColumnCount + column]; + } + } + + /// + /// Creates a zero matrix. + /// + /// The number of rows. Must be positive. + /// The number of columns. Must be positive. + /// The zero matrix. + public static MatrixMxN Zero(int rowCount, int columnCount) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(rowCount); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(columnCount); + return new MatrixMxN(rowCount, columnCount, new double[rowCount * columnCount]); + } + + /// + /// Creates an identity matrix. + /// + /// The number of rows and columns. Must be positive. + /// The identity matrix. + public static MatrixMxN Identity(int dimension) + { + var identity = Zero(dimension, dimension); + for (var i = 0; i < dimension; i++) + identity._values[i * dimension + i] = 1; + return identity; + } + + /// + /// Creates a matrix by copying any , e.g. a . + /// + /// The matrix to copy. + /// The copied matrix. + public static MatrixMxN FromMatrix(IMatrix matrix) + { + ArgumentNullException.ThrowIfNull(matrix); + var values = new double[matrix.RowCount * matrix.ColumnCount]; + for (var row = 0; row < matrix.RowCount; row++) + for (var column = 0; column < matrix.ColumnCount; column++) + values[row * matrix.ColumnCount + column] = matrix[row, column]; + return new MatrixMxN(matrix.RowCount, matrix.ColumnCount, values); + } + + /// + /// Copies the entries into a new two-dimensional array. + /// + /// The entries, indexed as [row, column]. + public double[,] ToArray() + { + var array = new double[RowCount, ColumnCount]; + for (var row = 0; row < RowCount; row++) + for (var column = 0; column < ColumnCount; column++) + array[row, column] = _values[row * ColumnCount + column]; + return array; + } + + /// + /// Returns the transpose. + /// + /// The transposed matrix. + public MatrixMxN Transpose() + { + var values = new double[_values.Length]; + for (var row = 0; row < RowCount; row++) + for (var column = 0; column < ColumnCount; column++) + values[column * RowCount + row] = _values[row * ColumnCount + column]; + return new MatrixMxN(ColumnCount, RowCount, values); + } + + /// + /// Calculates the determinant. + /// + /// The determinant, computed as the signed product of the LU pivots. + /// The matrix is not square. + public double Determinant() + { + EnsureSquare(); + return LuDecomposition.Decompose(RowCount, _values).Determinant; + } + + /// + /// Returns the inverse. + /// + /// The inverse matrix. + /// The matrix is not square or singular. + public MatrixMxN Inverse() => TryInvert(out var inverse) ? inverse : throw MatrixMath.SingularMatrix(); + + /// + /// Tries to calculate the inverse. + /// + /// The inverse, or if the matrix is singular. + /// if the matrix is invertible; otherwise, . + /// The matrix is not square. + public bool TryInvert([NotNullWhen(true)] out MatrixMxN? inverse) + { + EnsureSquare(); + var lu = LuDecomposition.Decompose(RowCount, _values); + if (lu.IsSingular) + { + inverse = null; + return false; + } + + var n = RowCount; + var values = new double[n * n]; + var column = new double[n]; + for (var j = 0; j < n; j++) + { + Array.Clear(column); + column[j] = 1; + lu.Solve(column); + for (var i = 0; i < n; i++) + values[i * n + j] = column[i]; + } + + inverse = new MatrixMxN(n, n, values); + return true; + } + + /// + /// Solves the linear system this · x = rightHandSide. + /// + /// The right-hand side; one value per row. + /// The solution x. + /// The matrix is not square or singular. + /// The length of does not match. + public double[] Solve(ReadOnlySpan rightHandSide) => + TrySolve(rightHandSide, out var solution) ? solution : throw MatrixMath.SingularMatrix(); + + /// + /// Solves the linear system this · X = rightHandSide for every column of the right-hand side. + /// + /// The right-hand side with as many rows as this matrix. + /// The solution X. + /// The matrix is not square or singular. + /// The row count of does not match. + public MatrixMxN Solve(MatrixMxN rightHandSide) + { + ArgumentNullException.ThrowIfNull(rightHandSide); + EnsureSquare(); + if (rightHandSide.RowCount != RowCount) + throw new ArgumentException("The right-hand side must have as many rows as the matrix.", + nameof(rightHandSide)); + + var lu = LuDecomposition.Decompose(RowCount, _values); + if (lu.IsSingular) + throw MatrixMath.SingularMatrix(); + + int n = RowCount, m = rightHandSide.ColumnCount; + var values = new double[n * m]; + var column = new double[n]; + for (var j = 0; j < m; j++) + { + for (var i = 0; i < n; i++) + column[i] = rightHandSide._values[i * m + j]; + lu.Solve(column); + for (var i = 0; i < n; i++) + values[i * m + j] = column[i]; + } + + return new MatrixMxN(n, m, values); + } + + /// + /// Calculates the rank, i.e. the number of linearly independent rows. + /// + /// The rank. + public int Rank() + { + int rows = RowCount, columns = ColumnCount; + var a = (double[]) _values.Clone(); + var threshold = MatrixMath.SingularPivotThreshold(Math.Max(rows, columns), MatrixMath.MaxAbs(_values)); + var rank = 0; + for (var column = 0; column < columns && rank < rows; column++) + { + var pivotRow = rank; + for (var row = rank + 1; row < rows; row++) + if (Math.Abs(a[row * columns + column]) > Math.Abs(a[pivotRow * columns + column])) + pivotRow = row; + if (Math.Abs(a[pivotRow * columns + column]) <= threshold) + continue; + + LuDecomposition.SwapRows(a, columns, pivotRow, rank); + for (var row = rank + 1; row < rows; row++) + { + var factor = a[row * columns + column] / a[rank * columns + column]; + for (var k = column; k < columns; k++) + a[row * columns + k] -= factor * a[rank * columns + k]; + } + + rank++; + } + + return rank; + } + + /// + /// Determines whether this matrix equals another matrix within the default tolerances (entry-wise). + /// + /// The other matrix. + /// + /// if both matrices have the same size and approximately equal entries; otherwise, + /// . + /// + public bool ApproximatelyEquals(MatrixMxN other) => + ApproximatelyEquals(other, Tolerance.DefaultAbsolute, Tolerance.DefaultRelative); + + /// + /// Determines whether this matrix equals another matrix within the specified tolerances (entry-wise). + /// + /// The other matrix. + /// The absolute tolerance. + /// The relative tolerance. + /// + /// if both matrices have the same size and approximately equal entries; otherwise, + /// . + /// + public bool ApproximatelyEquals(MatrixMxN other, double absolute, double relative) + { + ArgumentNullException.ThrowIfNull(other); + if (RowCount != other.RowCount || ColumnCount != other.ColumnCount) + return false; + for (var i = 0; i < _values.Length; i++) + if (!Tolerance.AreEqual(_values[i], other._values[i], absolute, relative)) + return false; + return true; + } + + /// + public override string ToString() => MatrixMath.Format(nameof(MatrixMxN), _values, RowCount, ColumnCount); + + /// + public bool Equals(MatrixMxN? other) + { + if (other is null) + return false; + if (ReferenceEquals(this, other)) + return true; + return RowCount == other.RowCount && ColumnCount == other.ColumnCount && + _values.AsSpan().SequenceEqual(other._values); + } + + /// + public override bool Equals(object? obj) => Equals(obj as MatrixMxN); + + /// + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(RowCount); + hash.Add(ColumnCount); + foreach (var value in _values) + hash.Add(value); + return hash.ToHashCode(); + } + + /// + /// Adds two matrices of the same size. + /// + /// The first matrix. + /// The second matrix. + /// The sum. + /// The sizes differ. + public static MatrixMxN operator +(MatrixMxN left, MatrixMxN right) => Combine(left, right, static (a, b) => a + b); + + /// + /// Subtracts a matrix from another matrix of the same size. + /// + /// The minuend. + /// The subtrahend. + /// The difference. + /// The sizes differ. + public static MatrixMxN operator -(MatrixMxN left, MatrixMxN right) => Combine(left, right, static (a, b) => a - b); + + /// + /// Negates a matrix. + /// + /// The matrix. + /// The negated matrix. + public static MatrixMxN operator -(MatrixMxN matrix) => matrix * -1; + + /// + /// Multiplies a matrix with a scalar. + /// + /// The matrix. + /// The scalar. + /// The scaled matrix. + public static MatrixMxN operator *(MatrixMxN matrix, double scalar) + { + ArgumentNullException.ThrowIfNull(matrix); + var values = new double[matrix._values.Length]; + for (var i = 0; i < values.Length; i++) + values[i] = matrix._values[i] * scalar; + return new MatrixMxN(matrix.RowCount, matrix.ColumnCount, values); + } + + /// + /// Multiplies a scalar with a matrix. + /// + /// The scalar. + /// The matrix. + /// The scaled matrix. + public static MatrixMxN operator *(double scalar, MatrixMxN matrix) => matrix * scalar; + + /// + /// Divides a matrix by a scalar. + /// + /// The matrix. + /// The scalar. + /// The scaled matrix. + public static MatrixMxN operator /(MatrixMxN matrix, double scalar) + { + ArgumentNullException.ThrowIfNull(matrix); + var values = new double[matrix._values.Length]; + for (var i = 0; i < values.Length; i++) + values[i] = matrix._values[i] / scalar; + return new MatrixMxN(matrix.RowCount, matrix.ColumnCount, values); + } + + /// + /// Multiplies two matrices. + /// + /// The left matrix. + /// The right matrix; must have as many rows as has columns. + /// The product left · right. + /// The sizes are incompatible. + public static MatrixMxN operator *(MatrixMxN left, MatrixMxN right) + { + ArgumentNullException.ThrowIfNull(left); + ArgumentNullException.ThrowIfNull(right); + if (left.ColumnCount != right.RowCount) + throw new ArgumentException( + "The column count of the left matrix must match the row count of the right matrix.", nameof(right)); + + int rows = left.RowCount, inner = left.ColumnCount, columns = right.ColumnCount; + var values = new double[rows * columns]; + for (var row = 0; row < rows; row++) + for (var k = 0; k < inner; k++) + { + var factor = left._values[row * inner + k]; + if (factor == 0) + continue; + for (var column = 0; column < columns; column++) + values[row * columns + column] += factor * right._values[k * columns + column]; + } + + return new MatrixMxN(rows, columns, values); + } + + /// + /// Determines whether two matrices are exactly equal. + /// + /// The first matrix. + /// The second matrix. + /// if both are or equal; otherwise, . + public static bool operator ==(MatrixMxN? left, MatrixMxN? right) + { + if (left is null || right is null) + return left is null && right is null; + if (left.RowCount != right.RowCount || left.ColumnCount != right.ColumnCount) + return false; + for (var i = 0; i < left._values.Length; i++) + if (left._values[i] != right._values[i]) + return false; + return true; + } + + /// + /// Determines whether two matrices differ. + /// + /// The first matrix. + /// The second matrix. + /// if the matrices differ; otherwise, . + public static bool operator !=(MatrixMxN? left, MatrixMxN? right) => !(left == right); + + internal bool TrySolve(ReadOnlySpan rightHandSide, + [NotNullWhen(true)] out double[]? solution) + { + EnsureSquare(); + if (rightHandSide.Length != RowCount) + throw new ArgumentException("The right-hand side must have one value per row.", nameof(rightHandSide)); + + var lu = LuDecomposition.Decompose(RowCount, _values); + if (lu.IsSingular) + { + solution = null; + return false; + } + + solution = rightHandSide.ToArray(); + lu.Solve(solution); + return true; + } + + private static MatrixMxN Combine(MatrixMxN left, MatrixMxN right, Func operation) + { + ArgumentNullException.ThrowIfNull(left); + ArgumentNullException.ThrowIfNull(right); + if (left.RowCount != right.RowCount || left.ColumnCount != right.ColumnCount) + throw new ArgumentException("The matrices must have the same size.", nameof(right)); + + var values = new double[left._values.Length]; + for (var i = 0; i < values.Length; i++) + values[i] = operation(left._values[i], right._values[i]); + return new MatrixMxN(left.RowCount, left.ColumnCount, values); + } + + private void EnsureSquare() + { + if (!IsSquare) + throw new InvalidOperationException("The operation requires a square matrix."); + } + + /// + /// An LU decomposition with partial pivoting (P · A = L · U), stored compactly. + /// + private sealed class LuDecomposition + { + private readonly double[] _lu; + private readonly int _n; + private readonly int[] _permutation; + private readonly double[] _scratch; + + private LuDecomposition(int n, double[] lu, int[] permutation, double determinant, bool isSingular) + { + _n = n; + _lu = lu; + _permutation = permutation; + _scratch = new double[n]; + Determinant = determinant; + IsSingular = isSingular; + } + + /// + /// Gets a value indicating whether a pivot was not larger than + /// . + /// + public bool IsSingular { get; } + + public double Determinant { get; } + + public static LuDecomposition Decompose(int n, double[] values) + { + var lu = (double[]) values.Clone(); + var permutation = new int[n]; + for (var i = 0; i < n; i++) + permutation[i] = i; + + var threshold = MatrixMath.SingularPivotThreshold(n, MatrixMath.MaxAbs(values)); + var isSingular = !(threshold > 0); + var determinant = 1.0; + for (var k = 0; k < n; k++) + { + var pivotRow = k; + for (var row = k + 1; row < n; row++) + if (Math.Abs(lu[row * n + k]) > Math.Abs(lu[pivotRow * n + k])) + pivotRow = row; + + if (pivotRow != k) + { + SwapRows(lu, n, pivotRow, k); + (permutation[pivotRow], permutation[k]) = (permutation[k], permutation[pivotRow]); + determinant = -determinant; + } + + // Tiny pivots make the matrix numerically singular, but the determinant is still their product. + var pivot = lu[k * n + k]; + determinant *= pivot; + isSingular |= !(Math.Abs(pivot) > threshold); + if (pivot == 0) + return new LuDecomposition(n, lu, permutation, 0, true); + + for (var row = k + 1; row < n; row++) + { + var factor = lu[row * n + k] /= pivot; + for (var column = k + 1; column < n; column++) + lu[row * n + column] -= factor * lu[k * n + column]; + } + } + + return new LuDecomposition(n, lu, permutation, determinant, isSingular); + } + + public static void SwapRows(double[] values, int columns, int first, int second) + { + if (first == second) + return; + for (var column = 0; column < columns; column++) + (values[first * columns + column], values[second * columns + column]) = + (values[second * columns + column], values[first * columns + column]); + } + + /// + /// Overwrites with the solution of A · x = b. + /// + public void Solve(Span b) + { + var permuted = _scratch.AsSpan(); + for (var i = 0; i < _n; i++) + permuted[i] = b[_permutation[i]]; + + // Forward substitution with the unit lower triangle. + for (var i = 0; i < _n; i++) + { + var sum = permuted[i]; + for (var j = 0; j < i; j++) + sum -= _lu[i * _n + j] * permuted[j]; + permuted[i] = sum; + } + + // Back substitution with the upper triangle. + for (var i = _n - 1; i >= 0; i--) + { + var sum = permuted[i]; + for (var j = i + 1; j < _n; j++) + sum -= _lu[i * _n + j] * permuted[j]; + permuted[i] = sum / _lu[i * _n + i]; + } + + permuted.CopyTo(b); + } + } +} diff --git a/src/SharpMath/Geometry/MatrixUtils.cs b/src/SharpMath/Geometry/MatrixUtils.cs deleted file mode 100644 index f4b99bc..0000000 --- a/src/SharpMath/Geometry/MatrixUtils.cs +++ /dev/null @@ -1,400 +0,0 @@ -// MatrixUtils.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; -using System.Collections.Generic; -using System.Linq; -using SharpMath.Equations.Exceptions; -//using SharpMath.Equations; - -namespace SharpMath.Geometry -{ - public static class MatrixUtils - { - /// - /// Adds two instances. - /// - /// The first < see cref="IMatrix" />. - /// The second . - /// The resulting . - public static T Add(T firstMatrix, T secondMatrix) where T : IMatrix - { - for (uint y = 0; y < firstMatrix.RowCount; ++y) - for (uint x = 0; x < firstMatrix.ColumnCount; ++x) - firstMatrix[y, x] += secondMatrix[y, x]; - return firstMatrix; - } - - /// - /// Calculates the cofactor of the specified . - /// - /// The whose cofactor should be calculated. - /// The calculated cofactor . - public static T BuildCofactorMatrix(this T matrix) where T : ISquareMatrix, new() - { - var resultMatrix = new T(); - for (uint y = 0; y < matrix.RowCount; ++y) - for (uint x = 0; x < matrix.ColumnCount; ++x) - resultMatrix[y, x] = matrix.GetCofactor(y, x); - - return resultMatrix; - } - - /// - /// Creates a new object that is a copy of the current instance. - /// - /// - /// A new object that is a copy of this instance. - /// - public static T Clone(this T matrix) where T : IMatrix, new() - { - var cloneMatrix = new T(); - for (uint y = 0; y < matrix.RowCount; ++y) - for (uint x = 0; x < matrix.ColumnCount; ++x) - cloneMatrix[y, x] = matrix[y, x]; - return cloneMatrix; - } - - /// - /// Implements the Gauss-Jordan-algorithm. - /// - /// The left side . - /// The right side . - /// The resulting . - /// The linear equation system cannot be solved clearly. - public static T GaussJordan(T leftSide, T rightSide) where T : IMatrix - { - for (uint x = 0; x < leftSide.ColumnCount; x++) - { - var nextX = x; - while (leftSide[x, x].IsApproximatelyEqualTo(0)) - { - nextX++; - - if (nextX >= leftSide.ColumnCount) - throw new EquationNotSolvableException("The linear equation system cannot be solved clearly."); - - if (Math.Abs(leftSide[x, nextX]) < FloatingNumber.Epsilon) - continue; - - leftSide.InterchangeRows(nextX, x); - rightSide.InterchangeRows(nextX, x); - } - - for (uint y = 0; y < leftSide.RowCount; y++) - if (y != x && Math.Abs(leftSide[y, x]) >= FloatingNumber.Epsilon) - { - var factor = leftSide[y, x] / leftSide[x, x]; - leftSide.SubtractRows(y, x, factor); - rightSide.SubtractRows(y, x, factor); - } - } - - for (uint i = 0; i < leftSide.ColumnCount; i++) - { - var factor = 1 / leftSide[i, i]; - leftSide.MultiplyRow(i, factor); - rightSide.MultiplyRow(i, factor); - } - - return rightSide; - } - - /// - /// Calculates the cofactor of an element at the specified position in the . - /// - /// The . - /// The row of the element. - /// The column of the element. - /// The cofactor of the element in this . - public static double GetCofactor(this ISquareMatrix matrix, uint row, uint column) - { - return Math.Pow(-1, row + column) * matrix.GetSubMatrix(row, column).GetDeterminant(); - } - - //public static TOut GetCore(this ISquareMatrix matrix) where TOut : IVector, new() - //{ - // if (!matrix.Determinant.IsApproximatelyEqualTo(0)) - // throw new InvalidOperationException($"Cannot calculate core of {nameof(matrix)} as its determinant is not 0."); - - // var vector = new TOut(); - // if (matrix.Dimension != vector.Dimension) - // throw new InvalidOperationException($"Type parameter TOut is not an adequate IVector - type as its dimension does not fit the one of the resulting vector. The dimension must be {matrix.Dimension}."); - - // var equations = new List((int)matrix.ColumnCount); - // for (uint y = 0; y < matrix.RowCount; ++y) - // { - // var coefficients = new List((int)matrix.ColumnCount); - // for (uint x = 0; x < matrix.ColumnCount; ++x) - // coefficients.Add(matrix[y, x]); - - // equations.Add(new LinearEquation(coefficients.ToArray(), 0)); - // } - - // var equationSystem = new LinearEquationSystem(equations); - // double[] solutions = equationSystem.Solve(); - // for (uint i = 0; i < matrix.ColumnCount; ++i) - // vector[i] = solutions[i]; - - // return vector; - //} - - /// - /// Calculates the determinant of the . - /// - /// The whose determinant should be calculated. - /// The determinant of the . - public static double GetDeterminant(this ISquareMatrix matrix) - { - switch (matrix.Dimension) - { - case 1: - return matrix[0, 0]; - case 2: - return matrix[0, 0] * matrix[1, 1] - matrix[0, 1] * matrix[1, 0]; - default: - return LaplaceExpansion(matrix); - } - } - - /// - /// Calculates the identity of the specified type. - /// - /// - /// The type whose identity should be - /// calculated. - /// - /// The identity . - public static T GetIdentity() where T : ISquareMatrix, new() - { - var resultMatrix = new T(); - for (uint i = 0; i < resultMatrix.Dimension; ++i) - resultMatrix[i, i] = 1; - return resultMatrix; - } - - //public static T GetInverse(this ISquareMatrix matrix) where T : ISquareMatrix, new() - //{ - // if (matrix.Determinant.IsApproximatelyEqualTo(0)) - // throw new InvalidOperationException("The specified matrix does not have an inverse."); - // return GaussJordan(matrix, GetIdentity()) - //} - - /// - /// Determines whether the is a diagonal , or not. - /// - /// The . - /// - /// true, if the is a diagonal , otherwise - /// false. - /// - public static bool GetIsDiagonal(this ISquareMatrix matrix) - { - if (matrix.Dimension == 1) - return false; - - for (uint y = 0; y < matrix.RowCount; ++y) - for (uint x = 0; x < matrix.ColumnCount; ++x) - if (y == x && FloatingNumber.AreApproximatelyEqual(matrix[y, x], 0) || - y != x && !FloatingNumber.AreApproximatelyEqual(matrix[y, x], 0)) - return false; - return true; - } - - /// - /// Determines whether the is a triangle , or not. - /// - /// The . - /// - /// true, if the is a triangle , otherwise - /// false. - /// - public static bool GetIsTriangle(this ISquareMatrix matrix) - { - if (matrix.Dimension == 1) - return false; - - var upperTriangle = new List(); - var lowerTriangle = new List(); - for (uint y = 0; y < matrix.RowCount; ++y) - { - var isRightSide = false; - for (uint x = 0; x < matrix.ColumnCount; ++x) - { - if (y == x) - { - isRightSide = true; - continue; - } - - if (isRightSide) - upperTriangle.Add(matrix[y, x]); - else - lowerTriangle.Add(matrix[y, x]); - } - } - - if (upperTriangle.All(val => val.IsApproximatelyEqualTo(0)) || - lowerTriangle.All(val => val.IsApproximatelyEqualTo(0))) - return true; - return false; - } - - /// - /// Calculates the negated of the . - /// - /// The whose negated should be calculated. - /// The negated . - public static T GetNegate(this T matrix) where T : IMatrix, new() - { - var resultMatrix = new T(); - for (uint y = 0; y < matrix.RowCount; ++y) - for (uint x = 0; x < matrix.ColumnCount; ++x) - resultMatrix[y, x] = -matrix[y, x]; - return resultMatrix; - } - - /// - /// Calculates the sub of the current by removing the specified column - /// and row. - /// - /// The . - /// The row that should be removed. - /// The column that should be removed. - /// The calculated sub . - public static ISquareMatrix GetSubMatrix(this ISquareMatrix matrix, uint row, uint column) - { - ISquareMatrix resultMatrix; - switch (matrix.Dimension) - { - case 2: - resultMatrix = new Matrix1x1(); - break; - case 3: - resultMatrix = new Matrix2x2(); - break; - case 4: - resultMatrix = new Matrix3x3(); - break; - default: - throw new InvalidOperationException("Cannot build sub matrix of " + nameof(matrix) + - " as there is no lower dimension defined for it."); - } - - uint y = 0; - for (uint cy = 0; cy < matrix.RowCount; cy++) - if (cy != row) - { - uint x = 0; - for (uint cx = 0; cx < matrix.ColumnCount; ++cx) - if (cx != column) - { - resultMatrix[y, x] = matrix[cy, cx]; - x++; - } - - y++; - } - - return resultMatrix; - } - - /// - /// Calculates the transpose of the . - /// - /// The whose transpose should be calculated. - /// The transpose . - public static T GetTranspose(this T matrix) where T : ISquareMatrix, new() - { - var resultMatrix = new T(); - for (uint y = 0; y < matrix.Dimension; ++y) - for (uint x = 0; x < matrix.ColumnCount; ++x) - resultMatrix[y, x] = matrix[x, y]; - return resultMatrix; - } - - internal static void InterchangeRows(this IMatrix matrix, uint firstRowIndex, uint secondRowIndex) - { - for (uint x = 0; x < matrix.ColumnCount; ++x) - { - var firstValue = matrix[firstRowIndex, x]; - matrix[firstRowIndex, x] = matrix[secondRowIndex, x]; - matrix[secondRowIndex, x] = firstValue; - } - } - - internal static double LaplaceExpansion(this ISquareMatrix matrix) - { - double determinant = 0; - for (uint i = 0; i < matrix.Dimension; ++i) - determinant += matrix[i, 0] * matrix.GetCofactor(i, 0); - // The sigma sign is equal to a for-loop with recursion. - return determinant; - } - - /// - /// Multiplies the with a scalar. - /// - /// The to include into the product. - /// The scalar factor that the should be multiplied with. - /// Returns the product. - public static T Multiply(this T matrix, double scalar) where T : IMatrix - { - for (uint y = 0; y < matrix.RowCount; ++y) - for (uint x = 0; x < matrix.ColumnCount; ++x) - matrix[y, x] *= scalar; - return matrix; - } - - /// - /// Multiplies two instances, if they are compatible to each other. - /// - /// The first to include into the product. - /// The second to include into the product. - /// The product. - public static TOut Multiply(IMatrix firstMatrix, IMatrix secondMatrix) where TOut : IMatrix, new() - { - if (firstMatrix.ColumnCount != secondMatrix.RowCount) - throw new ArgumentException( - "The column count of the first matrix does not match the row count of the second matrix."); - - var matrixProduct = new TOut(); - if (matrixProduct.RowCount != firstMatrix.RowCount || matrixProduct.ColumnCount != secondMatrix.ColumnCount) - throw new ArgumentException( - $"Type parameter TOut is not an adequate IMatrix-type as its constraints do not fit those of the resulting matrix. The constraints must be {firstMatrix.RowCount}x{secondMatrix.ColumnCount}."); - - for (uint y = 0; y < matrixProduct.RowCount; ++y) - for (uint x = 0; x < matrixProduct.ColumnCount; ++x) - for (uint i = 0; i < firstMatrix.ColumnCount; ++i) - matrixProduct[y, x] += firstMatrix[y, i] * secondMatrix[i, x]; - - return matrixProduct; - } - - internal static void MultiplyRow(this IMatrix matrix, uint rowIndex, double factor) - { - for (uint x = 0; x < matrix.ColumnCount; ++x) - matrix[rowIndex, x] *= factor; - } - - /// - /// Adds two instances, if they are compatible to each other. - /// - /// The first . - /// The second . - /// The resulting . - public static T Subtract(T firstMatrix, T secondMatrix) where T : IMatrix - { - for (uint y = 0; y < firstMatrix.RowCount; ++y) - for (uint x = 0; x < firstMatrix.ColumnCount; ++x) - firstMatrix[y, x] -= secondMatrix[y, x]; - return firstMatrix; - } - - internal static void SubtractRows(this IMatrix matrix, uint firstRowIndex, uint secondRowIndex, double factor) - { - for (uint x = 0; x < matrix.ColumnCount; x++) - matrix[firstRowIndex, x] -= matrix[secondRowIndex, x] * factor; - } - } -} \ No newline at end of file diff --git a/src/SharpMath/Geometry/Point2D.cs b/src/SharpMath/Geometry/Point2D.cs index 4a55c5f..1b1fc34 100644 --- a/src/SharpMath/Geometry/Point2D.cs +++ b/src/SharpMath/Geometry/Point2D.cs @@ -1,215 +1,157 @@ -// Point2D.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 +using System.Globalization; + +namespace SharpMath.Geometry; + +/// +/// Represents an immutable point in the plane. +/// +/// +/// Points follow affine-space rules: point − point is a , point ± vector is a +/// point. Equality is exact; use for tolerance-based comparisons. +/// +public readonly struct Point2D : IEquatable +{ + /// + /// Initializes a new instance of the struct. + /// + /// The X coordinate. + /// The Y coordinate. + public Point2D(double x, double y) + { + X = x; + Y = y; + } + + /// + /// Gets the X coordinate. + /// + public double X { get; } -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; + /// + /// Gets the Y coordinate. + /// + public double Y { get; } -// ReSharper disable NonReadonlyMemberInGetHashCode + /// + /// Gets the origin (0, 0). + /// + public static Point2D Origin => default; -namespace SharpMath.Geometry -{ /// - /// Represents a two-dimensional point. + /// Gets the coordinate at the specified index (0 = X, 1 = Y). /// - [Serializable] - public struct Point2D : IEquatable, IEnumerable + /// The zero-based index. + /// is not 0 or 1. + public double this[int index] => index switch { - /// - /// Initializes a new instance of the class. - /// - /// The X-coordinate. - /// The Y-coordinate. - public Point2D(double x, double y) - { - X = x; - Y = y; - } - - /// - /// Initializes a new instance of the class. - /// - /// The exisiting to copy. - public Point2D(Point2D point) - { - X = point.X; - Y = point.Y; - } - - /// - /// Initializes a new instance of the class. - /// - /// The position of the to create. - public Point2D(Vector2 vector) - { - X = vector.X; - Y = vector.Y; - } - - /// - /// Gets or sets the value of the coordinate at the specified index. - /// - /// The index. - /// The value of the coordinate at the specified index. - public double this[uint index] - { - get - { - switch (index) - { - case 0: - return X; - case 1: - return Y; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 1."); - } - } - set - { - switch (index) - { - case 0: - X = value; - break; - case 1: - Y = value; - break; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 1."); - } - } - } - - /// - /// Gets or sets the value of the X-coordinate. - /// - public double X { get; set; } - - /// - /// Gets or sets the value of the Y-coordinate. - /// - public double Y { get; set; } - - /// - /// Gets the position of this . - /// - public Vector2 PositionVector => new Vector2(X, Y); - - public IEnumerator GetEnumerator() - { - for (uint i = 0; i < 2; i++) yield return this[i]; - } - - IEnumerator IEnumerable.GetEnumerator() - { - for (uint i = 0; i < 2; i++) yield return this[i]; - } - - public bool Equals(Point2D other) - { - return this == other; - } - - /// - /// Implements the operator +. - /// - /// The first . - /// The second . - /// - /// The result of the operator. - /// - public static Point2D operator +(Point2D first, Point2D second) - { - var resultPoint = new Point2D(); - for (uint i = 0; i < 2; ++i) - resultPoint[i] = first[i] + second[i]; - return resultPoint; - } - - /// - /// Implements the operator -. - /// - /// The first . - /// The second . - /// - /// The result of the operator. - /// - public static Point2D operator -(Point2D first, Point2D second) - { - var resultPoint = new Point2D(); - for (uint i = 0; i < 2; ++i) - resultPoint[i] = first[i] - second[i]; - return resultPoint; - } - - /// - /// Returns a that represents this instance. - /// - /// - /// A that represents this instance. - /// - public override string ToString() - { - return $"Point2D [X: {this[0]}, Y: {this[1]}]"; - } - - /// - /// Determines whether the specified , is equal to this instance. - /// - /// The to compare with this instance. - /// - /// true if the specified is equal to this instance; otherwise, false. - /// - public override bool Equals(object obj) - { - if (obj is Point2D) - return this == (Point2D) obj; - return false; - } - - /// - /// Returns a hash code for this instance. - /// - /// - /// A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table. - /// - public override int GetHashCode() - { - unchecked - { - var hash = 17; - hash = hash * 23 + X.GetHashCode(); - hash = hash * 23 + Y.GetHashCode(); - return hash; - } - } - - /// - /// Implements the operator ==. - /// - /// The left . - /// The right . - /// - /// The result of the operator. - /// - public static bool operator ==(Point2D left, Point2D right) - { - return left.SequenceEqual(right); - } - - /// - /// Implements the operator !=. - /// - /// The left . - /// The right . - /// - /// The result of the operator. - /// - public static bool operator !=(Point2D left, Point2D right) - { - return !left.SequenceEqual(right); - } - } -} \ No newline at end of file + 0 => X, + 1 => Y, + _ => throw new ArgumentOutOfRangeException(nameof(index), index, "The index must be 0 or 1.") + }; + + /// + /// Returns the position vector of the point. + /// + /// The vector from the origin to the point. + public Vector2 ToVector() => new(X, Y); + + /// + /// Calculates the distance to another point. + /// + /// The other point. + /// The distance. + public double Distance(Point2D other) => (other - this).Length; + + /// + /// Calculates the distance between two points. + /// + /// The first point. + /// The second point. + /// The distance. + public static double Distance(Point2D left, Point2D right) => left.Distance(right); + + /// + /// Calculates the squared distance to another point. + /// + /// The other point. + /// The squared distance. + public double DistanceSquared(Point2D other) => (other - this).LengthSquared; + + /// + /// Calculates the squared distance between two points. + /// + /// The first point. + /// The second point. + /// The squared distance. + public static double DistanceSquared(Point2D left, Point2D right) => left.DistanceSquared(right); + + /// + /// Determines whether this point equals another point within the default tolerances (coordinate-wise). + /// + /// The other point. + /// if the points are approximately equal; otherwise, . + public bool ApproximatelyEquals(Point2D other) => + ApproximatelyEquals(other, Tolerance.DefaultAbsolute, Tolerance.DefaultRelative); + + /// + /// Determines whether this point equals another point within the specified tolerances (coordinate-wise). + /// + /// The other point. + /// The absolute tolerance. + /// The relative tolerance. + /// if the points are approximately equal; otherwise, . + public bool ApproximatelyEquals(Point2D other, double absolute, double relative) => + Tolerance.AreEqual(X, other.X, absolute, relative) && Tolerance.AreEqual(Y, other.Y, absolute, relative); + + /// + public override string ToString() => string.Create(CultureInfo.InvariantCulture, $"Point2D [X: {X}, Y: {Y}]"); + + /// + public bool Equals(Point2D other) => X.Equals(other.X) && Y.Equals(other.Y); + + /// + public override bool Equals(object? obj) => obj is Point2D other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(X, Y); + + /// + /// Translates a point by a vector. + /// + /// The point. + /// The translation. + /// The translated point. + public static Point2D operator +(Point2D point, Vector2 vector) => new(point.X + vector.X, point.Y + vector.Y); + + /// + /// Translates a point by the negated vector. + /// + /// The point. + /// The translation to subtract. + /// The translated point. + public static Point2D operator -(Point2D point, Vector2 vector) => new(point.X - vector.X, point.Y - vector.Y); + + /// + /// Calculates the vector from one point to another. + /// + /// The point the vector points to. + /// The point the vector starts at. + /// The vector end − start. + public static Vector2 operator -(Point2D end, Point2D start) => new(end.X - start.X, end.Y - start.Y); + + /// + /// Determines whether two points are exactly equal (coordinate-wise ==). + /// + /// The first point. + /// The second point. + /// if all coordinates are equal; otherwise, . + public static bool operator ==(Point2D left, Point2D right) => left.X == right.X && left.Y == right.Y; + + /// + /// Determines whether two points differ in at least one coordinate. + /// + /// The first point. + /// The second point. + /// if any coordinate differs; otherwise, . + public static bool operator !=(Point2D left, Point2D right) => !(left == right); +} diff --git a/src/SharpMath/Geometry/Point3D.cs b/src/SharpMath/Geometry/Point3D.cs index 9626b32..99e3818 100644 --- a/src/SharpMath/Geometry/Point3D.cs +++ b/src/SharpMath/Geometry/Point3D.cs @@ -1,227 +1,166 @@ -// Point3D.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; - -// ReSharper disable NonReadonlyMemberInGetHashCode - -namespace SharpMath.Geometry +using System.Globalization; + +namespace SharpMath.Geometry; + +/// +/// Represents an immutable point in three-dimensional space. +/// +/// +/// Points follow affine-space rules: point − point is a , point ± vector is a +/// point. Equality is exact; use for tolerance-based comparisons. +/// +public readonly struct Point3D : IEquatable { - [Serializable] - public struct Point3D : IEquatable, IEnumerable + /// + /// Initializes a new instance of the struct. + /// + /// The X coordinate. + /// The Y coordinate. + /// The Z coordinate. + public Point3D(double x, double y, double z) { - /// - /// Initializes a new instance of the class. - /// - /// The value of the X-coordinate. - /// The value of the Y-coordinate. - /// The value of the Z-coordinate. - public Point3D(double x, double y, double z) - { - X = x; - Y = y; - Z = z; - } - - /// - /// Initializes a new instance of the class. - /// - /// The exisiting to copy. - public Point3D(Point3D point) - { - X = point.X; - Y = point.Y; - Z = point.Z; - } - - /// - /// Initializes a new instance of the class. - /// - /// The position of the to create. - public Point3D(Vector3 vector) - { - X = vector.X; - Y = vector.Y; - Z = vector.Z; - } - - /// - /// Gets or sets the value of the coordinate at the specified index. - /// - /// The index. - /// The value of the coordinate at the specified index. - public double this[uint index] - { - get - { - switch (index) - { - case 0: - return X; - case 1: - return Y; - case 2: - return Z; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 2."); - } - } - set - { - switch (index) - { - case 0: - X = value; - break; - case 1: - Y = value; - break; - case 2: - Z = value; - break; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 2."); - } - } - } - - /// - /// Gets or sets the value of the X-coordinate. - /// - public double X { get; set; } - - /// - /// Gets or sets the value of the Y-coordinate. - /// - public double Y { get; set; } - - /// - /// Gets or sets the value of the Z-coordinate. - /// - public double Z { get; set; } - - /// - /// Gets the position of this . - /// - public Vector3 PositionVector => new Vector3(X, Y, Z); - - public IEnumerator GetEnumerator() - { - for (uint i = 0; i < 3; i++) yield return this[i]; - } - - IEnumerator IEnumerable.GetEnumerator() - { - for (uint i = 0; i < 3; i++) yield return this[i]; - } - - public bool Equals(Point3D other) - { - return this == other; - } - - /// - /// Implements the operator +. - /// - /// The first . - /// The second . - /// - /// The result of the operator. - /// - public static Point3D operator +(Point3D first, Point3D second) - { - var resultPoint = new Point3D(); - for (uint i = 0; i < 3; ++i) - resultPoint[i] = first[i] + second[i]; - return resultPoint; - } - - /// - /// Implements the operator -. - /// - /// The first . - /// The second . - /// - /// The result of the operator. - /// - public static Point3D operator -(Point3D first, Point3D second) - { - var resultPoint = new Point3D(); - for (uint i = 0; i < 3; ++i) - resultPoint[i] = first[i] - second[i]; - return resultPoint; - } - - /// - /// Returns a that represents this instance. - /// - /// - /// A that represents this instance. - /// - public override string ToString() - { - return $"Point3D [X: {this[0]}, Y: {this[1]}, Z: {this[2]}]"; - } - - /// - /// Determines whether the specified , is equal to this instance. - /// - /// The to compare with this instance. - /// - /// true if the specified is equal to this instance; otherwise, false. - /// - public override bool Equals(object obj) - { - if (obj is Point3D) - return this == (Point3D) obj; - return false; - } - - /// - /// Returns a hash code for this instance. - /// - /// - /// A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table. - /// - public override int GetHashCode() - { - unchecked - { - var hash = 17; - hash = hash * 23 + X.GetHashCode(); - hash = hash * 23 + Y.GetHashCode(); - hash = hash * 23 + Z.GetHashCode(); - return hash; - } - } - - /// - /// Implements the operator ==. - /// - /// The left . - /// The right . - /// - /// The result of the operator. - /// - public static bool operator ==(Point3D left, Point3D right) - { - return left.SequenceEqual(right); - } - - /// - /// Implements the operator !=. - /// - /// The left . - /// The right . - /// - /// The result of the operator. - /// - public static bool operator !=(Point3D left, Point3D right) - { - return !left.SequenceEqual(right); - } + X = x; + Y = y; + Z = z; } -} \ No newline at end of file + + /// + /// Gets the X coordinate. + /// + public double X { get; } + + /// + /// Gets the Y coordinate. + /// + public double Y { get; } + + /// + /// Gets the Z coordinate. + /// + public double Z { get; } + + /// + /// Gets the origin (0, 0, 0). + /// + public static Point3D Origin => default; + + /// + /// Gets the coordinate at the specified index (0 = X, 1 = Y, 2 = Z). + /// + /// The zero-based index. + /// is not between 0 and 2. + public double this[int index] => index switch + { + 0 => X, + 1 => Y, + 2 => Z, + _ => throw new ArgumentOutOfRangeException(nameof(index), index, "The index must be between 0 and 2.") + }; + + /// + /// Returns the position vector of the point. + /// + /// The vector from the origin to the point. + public Vector3 ToVector() => new(X, Y, Z); + + /// + /// Calculates the distance to another point. + /// + /// The other point. + /// The distance. + public double Distance(Point3D other) => (other - this).Length; + + /// + /// Calculates the distance between two points. + /// + /// The first point. + /// The second point. + /// The distance. + public static double Distance(Point3D left, Point3D right) => left.Distance(right); + + /// + /// Calculates the squared distance to another point. + /// + /// The other point. + /// The squared distance. + public double DistanceSquared(Point3D other) => (other - this).LengthSquared; + + /// + /// Calculates the squared distance between two points. + /// + /// The first point. + /// The second point. + /// The squared distance. + public static double DistanceSquared(Point3D left, Point3D right) => left.DistanceSquared(right); + + /// + /// Determines whether this point equals another point within the default tolerances (coordinate-wise). + /// + /// The other point. + /// if the points are approximately equal; otherwise, . + public bool ApproximatelyEquals(Point3D other) => + ApproximatelyEquals(other, Tolerance.DefaultAbsolute, Tolerance.DefaultRelative); + + /// + /// Determines whether this point equals another point within the specified tolerances (coordinate-wise). + /// + /// The other point. + /// The absolute tolerance. + /// The relative tolerance. + /// if the points are approximately equal; otherwise, . + public bool ApproximatelyEquals(Point3D other, double absolute, double relative) => + Tolerance.AreEqual(X, other.X, absolute, relative) && Tolerance.AreEqual(Y, other.Y, absolute, relative) && + Tolerance.AreEqual(Z, other.Z, absolute, relative); + + /// + public override string ToString() => string.Create(CultureInfo.InvariantCulture, $"Point3D [X: {X}, Y: {Y}, Z: {Z}]"); + + /// + public bool Equals(Point3D other) => X.Equals(other.X) && Y.Equals(other.Y) && Z.Equals(other.Z); + + /// + public override bool Equals(object? obj) => obj is Point3D other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(X, Y, Z); + + /// + /// Translates a point by a vector. + /// + /// The point. + /// The translation. + /// The translated point. + public static Point3D operator +(Point3D point, Vector3 vector) => new(point.X + vector.X, point.Y + vector.Y, point.Z + vector.Z); + + /// + /// Translates a point by the negated vector. + /// + /// The point. + /// The translation to subtract. + /// The translated point. + public static Point3D operator -(Point3D point, Vector3 vector) => new(point.X - vector.X, point.Y - vector.Y, point.Z - vector.Z); + + /// + /// Calculates the vector from one point to another. + /// + /// The point the vector points to. + /// The point the vector starts at. + /// The vector end − start. + public static Vector3 operator -(Point3D end, Point3D start) => new(end.X - start.X, end.Y - start.Y, end.Z - start.Z); + + /// + /// Determines whether two points are exactly equal (coordinate-wise ==). + /// + /// The first point. + /// The second point. + /// if all coordinates are equal; otherwise, . + public static bool operator ==(Point3D left, Point3D right) => left.X == right.X && left.Y == right.Y && left.Z == right.Z; + + /// + /// Determines whether two points differ in at least one coordinate. + /// + /// The first point. + /// The second point. + /// if any coordinate differs; otherwise, . + public static bool operator !=(Point3D left, Point3D right) => !(left == right); +} diff --git a/src/SharpMath/Geometry/Polygon.cs b/src/SharpMath/Geometry/Polygon.cs index 6b6cf49..d039732 100644 --- a/src/SharpMath/Geometry/Polygon.cs +++ b/src/SharpMath/Geometry/Polygon.cs @@ -1,287 +1,286 @@ -// Polygon.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 +using System.Collections.ObjectModel; +using System.Globalization; +using System.Text; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Drawing; -using System.Linq; +namespace SharpMath.Geometry; -// ReSharper disable ForCanBeConvertedToForeach - -namespace SharpMath.Geometry +/// +/// Represents an immutable simple polygon in the plane, defined by its vertices in order. +/// +/// +/// The polygon is closed implicitly (the last vertex connects to the first). Equality is exact and requires the same +/// vertices in the same order; compares with tolerances. +/// +public sealed class Polygon : IEquatable { + private readonly Point2D[] _points; + /// - /// Represents a polygon. + /// Initializes a new instance of the class. /// - public class Polygon : IEnumerable, IEquatable + /// The vertices in order; at least three. + /// Fewer than three vertices are specified. + public Polygon(params ReadOnlySpan points) { - /// - /// Initializes a new instance of the class. - /// - /// The instances that the consists of. - public Polygon(params Point2D[] points) - { - if (points.Length < 3) - throw new ArgumentException("A polygon needs to have at least 3 points."); - Points = points.ToList(); - } + if (points.Length < 3) + throw new ArgumentException("A polygon needs at least three points.", nameof(points)); + _points = points.ToArray(); + Points = Array.AsReadOnly(_points); + } - /// - /// Gets the area of this . - /// - public double Area - { - get - { - double value = 0; - var j = 1; - for (var i = 0; i < Points.Count; ++i) - { - value += Vector2.Area(Points[i].PositionVector, Points[j].PositionVector); - j++; - if (j == Points.Count) - j = 0; - } + /// + /// Initializes a new instance of the class. + /// + /// The vertices in order; at least three. + /// Fewer than three vertices are specified. + public Polygon(IEnumerable points) : this( + (points ?? throw new ArgumentNullException(nameof(points))).ToArray().AsSpan()) + { + } - return Math.Abs(value * 0.5); - } - } + /// + /// Gets the vertices in order. + /// + public ReadOnlyCollection Points { get; } - /// - /// Gets the bounding box of this as a . - /// - public RectangleF BoundingBox + /// + /// Gets the signed area: positive if the vertices are ordered counterclockwise, negative if clockwise. + /// + public double SignedArea + { + get { - get - { - double minX = 0; - double maxX = 0; - double minY = 0; - double maxY = 0; + var sum = 0.0; + for (int i = 0, j = _points.Length - 1; i < _points.Length; j = i++) + sum += _points[j].X * _points[i].Y - _points[i].X * _points[j].Y; + return sum / 2; + } + } - foreach (var point in Points) - { - if (point.X < minX) - minX = point.X; - else if (point.X > maxX) - maxX = point.X; - - if (point.Y < minY) - minY = point.Y; - else if (point.Y > maxY) - maxY = point.Y; - } + /// + /// Gets the area (shoelace formula). + /// + public double Area => Math.Abs(SignedArea); - return RectangleF.FromLTRB((float) minX, (float) minY, (float) maxX, (float) maxY); - } + /// + /// Gets the perimeter. + /// + public double Perimeter + { + get + { + var sum = 0.0; + for (int i = 0, j = _points.Length - 1; i < _points.Length; j = i++) + sum += _points[j].Distance(_points[i]); + return sum; } + } - /// - /// Gets the center of this . - /// - public Point2D Center + /// + /// Gets the centroid (center of mass of the enclosed area). For a polygon without area (all vertices collinear), + /// the average of the vertices is returned. + /// + public Point2D Centroid + { + get { - get + double x = 0, y = 0, twiceArea = 0; + for (int i = 0, j = _points.Length - 1; i < _points.Length; j = i++) { - double x = 0; - double y = 0; - for (var i = 0; i < Points.Count - 1; ++i) - { - var factor = Points[i].X * Points[i + 1].Y - Points[i + 1].X * Points[i].Y; - x += (Points[i].X + Points[i + 1].X) * factor; - y += (Points[i].Y + Points[i + 1].Y) * factor; - } - - var areaFactor = 1 / 6d * Area; - return new Point2D(areaFactor * x, areaFactor * y); + var cross = _points[j].X * _points[i].Y - _points[i].X * _points[j].Y; + twiceArea += cross; + x += (_points[j].X + _points[i].X) * cross; + y += (_points[j].Y + _points[i].Y) * cross; } - } - /// - /// Gets the perimeter of this . - /// - public double Perimeter - { - get + if (twiceArea == 0) { - double perimeter = 0; - var j = 1; - for (var i = 0; i < Points.Count; ++i) + foreach (var point in _points) { - perimeter += (Points[j] - Points[i]).PositionVector.Magnitude; - j++; - if (j == Points.Count) - j = 0; + x += point.X; + y += point.Y; } - return perimeter; + return new Point2D(x / _points.Length, y / _points.Length); } - } - /// - /// Gets the instances that this consists of. - /// - public List Points { get; } - - /// - /// Returns an enumerator that iterates through a collection. - /// - /// - /// An object that can be used to iterate through the collection. - /// - IEnumerator IEnumerable.GetEnumerator() - { - return Points.GetEnumerator(); + return new Point2D(x / (3 * twiceArea), y / (3 * twiceArea)); } + } - /// - /// Returns an enumerator that iterates through the collection. - /// - /// - /// An enumerator that can be used to iterate through the collection. - /// - public IEnumerator GetEnumerator() - { - return Points.GetEnumerator(); - } + /// + /// Gets the axis-aligned bounding box. + /// + public BoundingBox2D BoundingBox => BoundingBox2D.FromPoints(_points); - /// - /// Determines whether the specified is equal to this instance. - /// - /// The to compare with this instance. - /// - /// true if the specified is equal to this instance; otherwise, false. - /// - public bool Equals(Polygon other) + /// + /// Determines whether a point lies inside the polygon. Points on the border count as inside. + /// + /// The point. + /// if the polygon contains the point; otherwise, . + public bool Contains(Point2D point) + { + var inside = false; + for (int i = 0, j = _points.Length - 1; i < _points.Length; j = i++) { - return ReferenceEquals(other, null) ? ReferenceEquals(this, null) : other.Points.SequenceEqual(Points); - } + Point2D a = _points[j], b = _points[i]; + if (new LineSegment2D(a, b).Contains(point)) + return true; - /// - /// Determines whether this fully contains the specified . - /// - /// The other . - /// true if the specified is inside this one; otherwise false. - public bool Contains(Polygon other) - { - return other.Points.All(ContainsPoint); + // Crossing number test with a horizontal ray to the right. + if ((b.Y > point.Y) != (a.Y > point.Y) && point.X < (a.X - b.X) * (point.Y - b.Y) / (a.Y - b.Y) + b.X) + inside = !inside; } - /// - /// Determines whether this contains the specified . - /// - /// The to check. - /// - /// true if this contains the specified ; otherwise false. - /// - public bool ContainsPoint(Point2D point) - { - double t = -1; - var points = new List(); - points.AddRange(Points); - points.Add(points[0]); + return inside; + } - for (var i = 0; i < points.Count - 1; i++) t = t * ContainsPointInternal(point, points[i], points[i + 1]); - return t >= 0; - } + /// + /// Determines whether another polygon lies completely inside this polygon (touching the border is allowed). + /// + /// The other polygon. + /// if this polygon contains the other polygon; otherwise, . + public bool Contains(Polygon other) + { + ArgumentNullException.ThrowIfNull(other); - // https://de.wikipedia.org/wiki/Punkt-in-Polygon-Test_nach_Jordan - private int ContainsPointInternal(Point2D q, Point2D p1, Point2D p2) - { - if (FloatingNumber.AreApproximatelyEqual(q.Y, p1.Y) && - FloatingNumber.AreApproximatelyEqual(q.Y, p2.Y)) - { - if (p1.X <= q.X && q.X <= p2.X || - p2.X <= q.X && q.X <= p1.X) - return 0; - return 1; - } + foreach (var point in other._points) + if (!Contains(point)) + return false; - if (p1.Y > p2.Y) + // An edge may still leave and re-enter (e.g. through a concave notch or exactly through vertices). Split every + // edge at its intersections with the border; each piece must then lie inside, so testing its midpoint suffices. + var parameters = new List(); + for (int i = 0, j = other._points.Length - 1; i < other._points.Length; j = i++) + { + var edge = new LineSegment2D(other._points[j], other._points[i]); + CollectBorderIntersections(edge, parameters); + for (var k = 1; k < parameters.Count; k++) { - var currentP1 = p1; - p1 = p2; - p2 = currentP1; + var middle = (parameters[k - 1] + parameters[k]) / 2; + if (parameters[k] - parameters[k - 1] > 1e-12 && !Contains(edge.Start + edge.Direction * middle)) + return false; } + } - if (FloatingNumber.AreApproximatelyEqual(q.Y, p1.Y) && - FloatingNumber.AreApproximatelyEqual(q.X, p1.X)) - return 0; + return true; + } - if (q.Y <= p1.Y || q.Y > p2.Y) - return 1; + /// + /// Determines whether this polygon equals another polygon within the default tolerances (same vertices in the same + /// order). + /// + /// The other polygon. + /// if the polygons are approximately equal; otherwise, . + public bool ApproximatelyEquals(Polygon other) => + ApproximatelyEquals(other, Tolerance.DefaultAbsolute, Tolerance.DefaultRelative); - var delta = (p1.X - q.X) * (p2.Y - q.Y) - (p1.Y - q.Y) * (p2.X - q.X); - if (delta > 0) - return -1; - return delta < 0 ? 1 : 0; - } + /// + /// Determines whether this polygon equals another polygon within the specified tolerances (same vertices in the + /// same order). + /// + /// The other polygon. + /// The absolute tolerance. + /// The relative tolerance. + /// if the polygons are approximately equal; otherwise, . + public bool ApproximatelyEquals(Polygon other, double absolute, double relative) + { + ArgumentNullException.ThrowIfNull(other); + if (_points.Length != other._points.Length) + return false; + for (var i = 0; i < _points.Length; i++) + if (!_points[i].ApproximatelyEquals(other._points[i], absolute, relative)) + return false; + return true; + } - /// - /// Determines whether the specified is equal to this instance. - /// - /// The to compare with this instance. - /// - /// true if the specified is equal to this instance; otherwise, false. - /// - public override bool Equals(object obj) + /// + public override string ToString() + { + var builder = new StringBuilder("Polygon ["); + for (var i = 0; i < _points.Length; i++) { - return ReferenceEquals(obj, null) - ? ReferenceEquals(this, null) - : obj is Polygon && ((Polygon) obj).Points.SequenceEqual(Points); + if (i > 0) + builder.Append(", "); + builder.Append(CultureInfo.InvariantCulture, $"({_points[i].X}, {_points[i].Y})"); } - /// - /// Returns a hash code for this instance. - /// - /// - /// A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table. - /// - public override int GetHashCode() - { - return Points.GetHashCode(); - } + return builder.Append(']').ToString(); + } - /// - /// Determines whether the two specified instances are equal to each other. - /// - /// The first . - /// The to compare with the other . - /// - /// true if the two specified are equal to each other; otherwise, false. - /// - public static bool operator ==(Polygon left, Polygon right) - { - if (ReferenceEquals(left, right)) - return true; - return left != null && left.Equals(right); - } + /// + public bool Equals(Polygon? other) => + other is not null && (ReferenceEquals(this, other) || _points.AsSpan().SequenceEqual(other._points)); - /// - /// Determines whether the two specified instances are not equal to each other. - /// - /// The first . - /// The to compare with the other . - /// - /// true if the two specified are not equal to each other; otherwise, false. - /// - public static bool operator !=(Polygon left, Polygon right) - { - if (ReferenceEquals(left, right)) + /// + public override bool Equals(object? obj) => Equals(obj as Polygon); + + /// + public override int GetHashCode() + { + var hash = new HashCode(); + foreach (var point in _points) + hash.Add(point); + return hash.ToHashCode(); + } + + /// + /// Determines whether two polygons have the same vertices in the same order. + /// + /// The first polygon. + /// The second polygon. + /// if both are or equal; otherwise, . + public static bool operator ==(Polygon? left, Polygon? right) + { + if (left is null || right is null) + return left is null && right is null; + if (left._points.Length != right._points.Length) + return false; + for (var i = 0; i < left._points.Length; i++) + if (left._points[i] != right._points[i]) return false; - return left != null && !left.Equals(right); - } + return true; + } + + /// + /// Determines whether two polygons differ. + /// + /// The first polygon. + /// The second polygon. + /// if the polygons differ; otherwise, . + public static bool operator !=(Polygon? left, Polygon? right) => !(left == right); + + /// + /// Fills with 0, 1 and the sorted positions (as fractions of the edge) where the + /// edge meets the border of this polygon. + /// + private void CollectBorderIntersections(LineSegment2D edge, List parameters) + { + parameters.Clear(); + parameters.Add(0); + parameters.Add(1); + var lengthSquared = edge.Direction.LengthSquared; + if (lengthSquared == 0) + return; - /// - /// Returns a that represents this instance. - /// - /// - /// A that represents this instance. - /// - public override string ToString() + for (int i = 0, j = _points.Length - 1; i < _points.Length; j = i++) { - return $"Polygon {{{string.Join(", ", Points)}}}"; + var intersection = edge.Intersect(new LineSegment2D(_points[j], _points[i])); + switch (intersection.Kind) + { + case IntersectionKind.Point: + parameters.Add(Parameter(intersection.Point.GetValueOrDefault())); + break; + case IntersectionKind.Overlap: + parameters.Add(Parameter(intersection.Segment.GetValueOrDefault().Start)); + parameters.Add(Parameter(intersection.Segment.GetValueOrDefault().End)); + break; + } } + + parameters.Sort(); + return; + + double Parameter(Point2D point) => Math.Clamp((point - edge.Start).Dot(edge.Direction) / lengthSquared, 0, 1); } -} \ No newline at end of file +} diff --git a/src/SharpMath/Geometry/ProjectionData.cs b/src/SharpMath/Geometry/ProjectionData.cs index 63846fc..eee7161 100644 --- a/src/SharpMath/Geometry/ProjectionData.cs +++ b/src/SharpMath/Geometry/ProjectionData.cs @@ -1,87 +1,121 @@ -// ProjectionData.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 +namespace SharpMath.Geometry; -using System; -using System.Drawing; - -namespace SharpMath.Geometry +/// +/// Describes a perspective projection onto a viewport. +/// +public readonly struct ProjectionData : IEquatable { /// - /// Provides configuration data for performing projections in a view frustum. + /// Initializes a new instance of the struct. + /// + /// The width of the viewport, e.g. in pixels. Must not be negative. + /// The height of the viewport, e.g. in pixels. Must not be negative. + /// The aspect ratio (width / height) used for the projection. Must be positive. + /// The vertical field of view in radians, in (0, π). + /// The distance to the near plane. Must be positive. + /// The distance to the far plane. Must be greater than . + /// An argument is out of range. + public ProjectionData(double width, double height, double aspectRatio, double fieldOfView, double nearPlane, + double farPlane) + { + if (!(width >= 0) || double.IsPositiveInfinity(width)) + throw new ArgumentOutOfRangeException(nameof(width), width, "The width must be a finite, non-negative number."); + if (!(height >= 0) || double.IsPositiveInfinity(height)) + throw new ArgumentOutOfRangeException(nameof(height), height, + "The height must be a finite, non-negative number."); + Validate(fieldOfView, aspectRatio, nearPlane, farPlane); + + Width = width; + Height = height; + AspectRatio = aspectRatio; + FieldOfView = fieldOfView; + NearPlane = nearPlane; + FarPlane = farPlane; + } + + /// + /// Gets the default projection data: a 100x100 viewport, an aspect ratio of 16:9, a field of view of 60° and the + /// planes 1 and 100. + /// + public static ProjectionData Default => new(100, 100, 16.0 / 9.0, Math.PI / 3, 1, 100); + + /// + /// Gets the width of the viewport. + /// + public double Width { get; } + + /// + /// Gets the height of the viewport. + /// + public double Height { get; } + + /// + /// Gets the aspect ratio (width / height) used for the projection. + /// + public double AspectRatio { get; } + + /// + /// Gets the vertical field of view in radians. /// - [Serializable] - public struct ProjectionData + public double FieldOfView { get; } + + /// + /// Gets the distance to the near plane. + /// + public double NearPlane { get; } + + /// + /// Gets the distance to the far plane. + /// + public double FarPlane { get; } + + /// + /// Gets the distance between the near and the far plane. + /// + public double Depth => FarPlane - NearPlane; + + /// + public bool Equals(ProjectionData other) => + Width.Equals(other.Width) && Height.Equals(other.Height) && AspectRatio.Equals(other.AspectRatio) && + FieldOfView.Equals(other.FieldOfView) && NearPlane.Equals(other.NearPlane) && FarPlane.Equals(other.FarPlane); + + /// + public override bool Equals(object? obj) => obj is ProjectionData other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(Width, Height, AspectRatio, FieldOfView, NearPlane, FarPlane); + + /// + /// Determines whether two instances are equal. + /// + /// The first instance. + /// The second instance. + /// if all values are equal; otherwise, . + public static bool operator ==(ProjectionData left, ProjectionData right) => + left.Width == right.Width && left.Height == right.Height && left.AspectRatio == right.AspectRatio && + left.FieldOfView == right.FieldOfView && left.NearPlane == right.NearPlane && left.FarPlane == right.FarPlane; + + /// + /// Determines whether two instances differ. + /// + /// The first instance. + /// The second instance. + /// if any value differs; otherwise, . + public static bool operator !=(ProjectionData left, ProjectionData right) => !(left == right); + + internal static void Validate(double fieldOfView, double aspectRatio, double nearPlane, double farPlane) { - /// - /// Initializes a new instance of the struct. - /// - /// The size of the canvas. - /// The aspect ratio. - /// The field of view. - /// The near plane. - /// The far plane. - /// - /// nearPlane must be a value greater than zero. - /// or - /// farPlane must be a value greater than zero. - /// or - /// nearPlane must be a value smaller than farPlane. - /// or - /// FOV must be greater than zero and must not be bigger than 360 degrees (PI). - /// - public ProjectionData(Size canvasSize, float aspectRatio, float fieldOfView, float nearPlane, float farPlane) - { - if (nearPlane <= 0) - throw new ArgumentException("nearPlane must be a value greater than zero."); - if (farPlane <= 0) - throw new ArgumentException("farPlane must be a value greater than zero."); - if (nearPlane >= farPlane) - throw new ArgumentException("nearPlane must be a value smaller than farPlane."); - if (fieldOfView <= 0 || fieldOfView > Math.PI) - throw new ArgumentException( - "FOV must be greater than zero and must not be bigger than 360 degrees (PI)."); - - CanvasSize = canvasSize; - AspectRatio = aspectRatio; - FieldOfView = fieldOfView; - NearPlane = nearPlane; - FarPlane = farPlane; - } - - /// - /// Gets or sets the size of the canvas. - /// - public Size CanvasSize { get; set; } - - /// - /// Gets or sets the aspect ratio. - /// - public float AspectRatio { get; set; } - - /// - /// Gets or sets the field of view (FOV). - /// - public float FieldOfView { get; set; } - - /// - /// Gets or sets the near plane. - /// - public float NearPlane { get; set; } - - /// - /// Gets or sets the far plane. - /// - public float FarPlane { get; set; } - - /// - /// Gets the depth. - /// - public float Depth => FarPlane - NearPlane; - - /// - /// Gets the default . - /// - public static ProjectionData Default - => new ProjectionData(new Size(100, 100), 16f / 9f, (float) Math.PI / 3f, 1f, 100f); + if (!(fieldOfView > 0 && fieldOfView < Math.PI)) + throw new ArgumentOutOfRangeException(nameof(fieldOfView), fieldOfView, + "The field of view must be greater than 0 and less than π (180°)."); + if (!(aspectRatio > 0) || double.IsPositiveInfinity(aspectRatio)) + throw new ArgumentOutOfRangeException(nameof(aspectRatio), aspectRatio, + "The aspect ratio must be a finite, positive number."); + if (!(nearPlane > 0) || double.IsPositiveInfinity(nearPlane)) + throw new ArgumentOutOfRangeException(nameof(nearPlane), nearPlane, + "The near plane must be a finite, positive distance."); + if (!(farPlane > nearPlane) || double.IsPositiveInfinity(farPlane)) + throw new ArgumentOutOfRangeException(nameof(farPlane), farPlane, + "The far plane must be finite and farther away than the near plane."); } -} \ No newline at end of file +} diff --git a/src/SharpMath/Geometry/SegmentIntersection2D.cs b/src/SharpMath/Geometry/SegmentIntersection2D.cs new file mode 100644 index 0000000..d0975e1 --- /dev/null +++ b/src/SharpMath/Geometry/SegmentIntersection2D.cs @@ -0,0 +1,12 @@ +namespace SharpMath.Geometry; + +/// +/// The result of intersecting a with another segment or a line. +/// +/// +/// How the objects intersect: , or +/// . +/// +/// The intersection point if is ; otherwise, . +/// The overlapping segment if is ; otherwise, . +public readonly record struct SegmentIntersection2D(IntersectionKind Kind, Point2D? Point, LineSegment2D? Segment); diff --git a/src/SharpMath/Geometry/SegmentMath.cs b/src/SharpMath/Geometry/SegmentMath.cs new file mode 100644 index 0000000..5e6bc14 --- /dev/null +++ b/src/SharpMath/Geometry/SegmentMath.cs @@ -0,0 +1,37 @@ +namespace SharpMath.Geometry; + +/// +/// Shared helpers for segments and polygon edges. +/// +internal static class SegmentMath +{ + /// + /// The tolerance for distances between points with coordinates up to . + /// + public static double DistanceTolerance(double scale) => + Math.Max(Tolerance.DefaultAbsolute, Tolerance.DefaultRelative * scale); + + public static double CoordinateScale(Point2D point) => Math.Max(Math.Abs(point.X), Math.Abs(point.Y)); + + /// + /// Determines whether a point lies within of the segment from + /// to , without square roots on the common path. + /// + public static bool IsOnSegment(Point2D point, Point2D a, Point2D b, double tolerance) + { + var edge = b - a; + var offset = point - a; + var lengthSquared = edge.LengthSquared; + var toleranceSquared = tolerance * tolerance; + if (lengthSquared == 0) + return offset.LengthSquared <= toleranceSquared; + + // The distance to the line is |cross| / |edge|; the projection must fall within the segment (± tolerance). + var cross = edge.Cross(offset); + if (cross * cross > toleranceSquared * lengthSquared) + return false; + var dot = edge.Dot(offset); + var slack = tolerance * Math.Sqrt(lengthSquared); + return dot >= -slack && dot <= lengthSquared + slack; + } +} diff --git a/src/SharpMath/Geometry/VariableMatrix.cs b/src/SharpMath/Geometry/VariableMatrix.cs deleted file mode 100644 index 05fd83d..0000000 --- a/src/SharpMath/Geometry/VariableMatrix.cs +++ /dev/null @@ -1,60 +0,0 @@ -// VariableMatrix.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; -using System.ComponentModel; - -namespace SharpMath.Geometry -{ - /// - /// Represents a matrix that can vary in its column and row count. - /// - internal sealed class VariableMatrix : IMatrix - { - private readonly double[,] _fields; - - /// - /// Initializes a new instance of the class. - /// - /// The row count of the . - /// The column count of the . - public VariableMatrix(uint rowCount, uint columnCount) - { - _fields = new double[rowCount, columnCount]; - RowCount = rowCount; - ColumnCount = columnCount; - } - - /// - /// Gets the column count of the . - /// - public uint ColumnCount { get; } - - /// - /// Gets or sets the field value at the specified row and column indices. - /// - /// The row index. - /// The column index. - /// The field value at the specified row and column indices. - public double this[uint row, uint column] - { - get => _fields[row, column]; - set => _fields[row, column] = value; - } - - [EditorBrowsable(EditorBrowsableState.Never)] - [Obsolete("Simple indexer is not supported in this class.", true)] - public double this[uint index] - { - get => double.NaN; - - // ReSharper disable once ValueParameterNotUsed - set { } - } - - /// - /// Gets the row count of the . - /// - public uint RowCount { get; } - } -} \ No newline at end of file diff --git a/src/SharpMath/Geometry/Vector2.cs b/src/SharpMath/Geometry/Vector2.cs index 870eea5..e5dac9f 100644 --- a/src/SharpMath/Geometry/Vector2.cs +++ b/src/SharpMath/Geometry/Vector2.cs @@ -1,543 +1,408 @@ -// Vector2.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 +using System.Globalization; + +namespace SharpMath.Geometry; + +/// +/// Represents an immutable two-dimensional vector. +/// +/// +/// Equality (, ==) is exact. Use +/// for tolerance-based comparisons. +/// +public readonly struct Vector2 : IVector, IEquatable +{ + /// + /// Initializes a new instance of the struct. + /// + /// The X component. + /// The Y component. + public Vector2(double x, double y) + { + X = x; + Y = y; + } -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using SharpMath.Geometry.Exceptions; + /// + /// Initializes a new instance of the struct with the position vector of a point. + /// + /// The point. + public Vector2(Point2D point) : this(point.X, point.Y) + { + } + + /// + /// Gets the X component. + /// + public double X { get; } + + /// + /// Gets the Y component. + /// + public double Y { get; } + + /// + /// Gets the vector (0, 0). + /// + public static Vector2 Zero => default; + + /// + /// Gets the vector (1, 1). + /// + public static Vector2 One => new(1, 1); + + /// + /// Gets the unit vector (1, 0). + /// + public static Vector2 UnitX => new(1, 0); + + /// + /// Gets the unit vector (0, 1). + /// + public static Vector2 UnitY => new(0, 1); + + /// + /// Gets the vector (0, 1). + /// + public static Vector2 Up => new(0, 1); + + /// + /// Gets the vector (0, −1). + /// + public static Vector2 Down => new(0, -1); + + /// + /// Gets the vector (−1, 0). + /// + public static Vector2 Left => new(-1, 0); + + /// + /// Gets the vector (1, 0). + /// + public static Vector2 Right => new(1, 0); + + /// + public int Dimension => 2; + + /// + public double this[int index] => index switch + { + 0 => X, + 1 => Y, + _ => throw new ArgumentOutOfRangeException(nameof(index), index, "The index must be 0 or 1.") + }; + + /// + public double Length => Math.Sqrt(LengthSquared); + + /// + public double LengthSquared => X * X + Y * Y; + + /// + public bool IsZero => Tolerance.IsZero(X) && Tolerance.IsZero(Y); + + /// + public bool IsNormalized => Tolerance.AreEqual(LengthSquared, 1); + + /// + /// Gets the vector rotated by 90° counterclockwise, i.e. (−Y, X). + /// + public Vector2 Perpendicular => new(-Y, X); + + /// + /// Returns the vector with the same direction and a length of 1. + /// + /// The normalized vector. + /// The vector is the zero vector. + public Vector2 Normalize() + { + // Scaling by the largest component first avoids underflow and overflow of the squared length. + var scale = Math.Max(Math.Abs(X), Math.Abs(Y)); + if (scale == 0) + throw new InvalidOperationException("The zero vector cannot be normalized."); + var scaled = new Vector2(X / scale, Y / scale); + var length = scaled.Length; + return new Vector2(scaled.X / length, scaled.Y / length); + } + + /// + /// Calculates the dot product with another vector. + /// + /// The other vector. + /// The dot product. + public double Dot(Vector2 other) => X * other.X + Y * other.Y; + + /// + /// Calculates the dot product of two vectors. + /// + /// The first vector. + /// The second vector. + /// The dot product. + public static double Dot(Vector2 left, Vector2 right) => left.Dot(right); + + /// + /// Calculates the two-dimensional cross product (the Z component of the three-dimensional cross product) with another + /// vector. + /// + /// The other vector. + /// X · other.Y − Y · other.X. + public double Cross(Vector2 other) => X * other.Y - Y * other.X; + + /// + /// Calculates the two-dimensional cross product of two vectors. + /// + /// The first vector. + /// The second vector. + /// left.X · right.Y − left.Y · right.X. + public static double Cross(Vector2 left, Vector2 right) => left.Cross(right); + + /// + /// Calculates the angle between this vector and another vector. + /// + /// The other vector. + /// The angle in radians, in the range [0, π]. + /// One of the vectors is the zero vector. + public double Angle(Vector2 other) + { + if (IsExactlyZero || other.IsExactlyZero) + throw VectorMath.ZeroVectorAngle(); + var (u, v) = (Normalize(), other.Normalize()); + return VectorMath.Angle(Math.Abs(u.Cross(v)), u.Dot(v)); + } + + /// + /// Calculates the angle between two vectors. + /// + /// The first vector. + /// The second vector. + /// The angle in radians, in the range [0, π]. + /// One of the vectors is the zero vector. + public static double Angle(Vector2 left, Vector2 right) => left.Angle(right); + + /// + /// Calculates the distance between the tips of this vector and another vector. + /// + /// The other vector. + /// The distance. + public double Distance(Vector2 other) => (this - other).Length; + + /// + /// Calculates the distance between the tips of two vectors. + /// + /// The first vector. + /// The second vector. + /// The distance. + public static double Distance(Vector2 left, Vector2 right) => left.Distance(right); + + /// + /// Calculates the squared distance between the tips of this vector and another vector. + /// + /// The other vector. + /// The squared distance. + public double DistanceSquared(Vector2 other) => (this - other).LengthSquared; + + /// + /// Calculates the squared distance between the tips of two vectors. + /// + /// The first vector. + /// The second vector. + /// The squared distance. + public static double DistanceSquared(Vector2 left, Vector2 right) => left.DistanceSquared(right); -namespace SharpMath.Geometry -{ /// - /// Represents a two-dimensional vector. + /// Calculates the area of the parallelogram spanned by this vector and another vector. /// - [Serializable] - public struct Vector2 : IVector, IEquatable, IEnumerable + /// The other vector. + /// The area. + public double ParallelogramArea(Vector2 other) => Math.Abs(Cross(other)); + + /// + /// Interpolates linearly between two vectors. The interpolant is clamped to [0, 1]. + /// + /// The vector returned for an interpolant of 0. + /// The vector returned for an interpolant of 1. + /// The interpolant. + /// The interpolated vector. + public static Vector2 Lerp(Vector2 source, Vector2 target, double amount) => + LerpUnclamped(source, target, Math.Clamp(amount, 0, 1)); + + /// + /// Interpolates or extrapolates linearly between two vectors. + /// + /// The vector returned for an interpolant of 0. + /// The vector returned for an interpolant of 1. + /// The interpolant; values outside of [0, 1] extrapolate. + /// The interpolated vector. + public static Vector2 LerpUnclamped(Vector2 source, Vector2 target, double amount) => + source + (target - source) * amount; + + /// + /// Moves a vector towards a target by at most the specified distance without overshooting it. + /// + /// The vector to move. + /// The target vector. + /// The maximum distance to move. + /// The moved vector, or if it is within reach. + public static Vector2 MoveTowards(Vector2 source, Vector2 target, double maxDistanceDelta) { - /// - /// Initializes a new instance of the class. - /// - /// The existing to copy. - public Vector2(Vector2 vector) - { - X = vector.X; - Y = vector.Y; - } - - /// - /// Initializes a new instance of the class. - /// - /// The value of the X-coordinate (X1 in mathematic coordinate systems). - /// The value of the Y-coordinate (X2 in mathematic coordinate systems). - public Vector2(double x, double y) - { - X = x; - Y = y; - } - - /// - /// Initializes a new instance of the class. - /// - /// The that a position should be created for. - public Vector2(Point2D point) - : this(point.PositionVector) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The tail of the . - /// The head of the . - public Vector2(Point2D bottom, Point2D tip) - : this((tip - bottom).PositionVector) - { - } - - /// - /// Gets or sets the value of the X-coordinate (X1 in mathematic coordinate systems). - /// - public double X { get; set; } - - /// - /// Gets or sets the value of the Y-coordinate (X2 in mathematic coordinate systems). - /// - public double Y { get; set; } - - /// - /// A with all values set to zero. - /// - public static Vector2 Zero => new Vector2(0, 0); - - /// - /// A with all values set to one. - /// - public static Vector2 One => new Vector2(1, 1); - - /// - /// A unit pointing up. - /// - public static Vector2 Up => new Vector2(0, 1); - - /// - /// A unit pointing down. - /// - public static Vector2 Down => new Vector2(0, -1); - - /// - /// A unit pointing to the left. - /// - public static Vector2 Left => new Vector2(-1, 0); - - /// - /// A unit pointing to the right. - /// - public static Vector2 Right => new Vector2(1, 0); - - /// - /// A unit with coordinates 1, 0. - /// - public static Vector2 UnitX => new Vector2(1, 0); - - /// - /// A unit with coordinates 0, 1. - /// - public static Vector2 UnitY => new Vector2(0, 1); - - /// - /// Gets the that is perpendicular to the . - /// - public Vector2 Perpendicular => new Vector2(-Y, X); - - /// - /// Gets or sets the value of the coordinate at the specified index. - /// - /// The index. - /// The value of the coordinate at the specified index. - public double this[uint index] - { - get - { - switch (index) - { - case 0: - return X; - case 1: - return Y; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 1."); - } - } - set - { - switch (index) - { - case 0: - X = value; - break; - case 1: - Y = value; - break; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 1."); - } - } - } - - /// - /// Gets the dimension of the . - /// - public uint Dimension => 2; - - /// - /// Gets the length of the . - /// - public double Magnitude => Math.Sqrt(SquareMagnitude); - - /// - /// Gets the squared length of the . - /// - public double SquareMagnitude - { - get - { - double result = 0; - for (uint i = 0; i < 2; ++i) - result += Math.Pow(this[i], 2); - return result; - } - } - - /// - /// Gets a value indicating whether the is normalized, or not. - /// - public bool IsNormalized => Magnitude.IsApproximatelyEqualTo(1); - - /// - /// Gets a value indicating whether the has all of its components set to zero, or not. - /// - public bool IsZero => this.All(c => FloatingNumber.AreApproximatelyEqual(c, 0)); - - /// - /// Gets the LaTeX-string representing this vector graphically. - /// - public string ToLaTeXString() - { - return @"\left( \begin{array}{c} " + this[0] + @" \\ " + this[1] + @" \end{array} \right)"; - } - - /// - /// Generates a from an object that implements the interface, if the - /// dimension is correct. - /// - /// The to generate a from. - /// The generated . - /// The dimension of the given vector is invalid. It must be 2. - public static Vector2 FromVector(IVector vector) - { - if (vector.Dimension != 2) - throw new DimensionException("The dimension of the given vector is invalid. It must be 2."); - return new Vector2(vector[0], vector[1]); - } - - /// - /// Calculates the angle between two instances. - /// - /// The first . - /// The second . - /// The angle between the instances. - public static double Angle(Vector2 firstVector, Vector2 secondVector) - { - return firstVector.Angle(secondVector); - } - - /// - /// Calculates the area of the parallelogram that this and the specified instances span. - /// - /// The other . - /// The area of the spanned parallelogram. - public double Area(Vector2 other) - { - return Math.Abs(VectorProduct(other)); - } - - /// - /// Calculates the area of the parallelogram that the two specified instances span. - /// - /// The first . - /// The second . - /// The area of the spanned parallelogram. - public static double Area(Vector2 firstVector, Vector2 secondVector) - { - return firstVector.Area(secondVector); - } - - /// - /// Determines whether two instances are orthogonal to each other, or not. - /// - /// The first . - /// The second . - /// true, if the instances are orthogonal to each other, otherwise false. - public static bool AreOrthogonal(Vector2 firstVector, Vector2 secondVector) - { - return firstVector.CheckForOrthogonality(secondVector); - } - - /// - /// Determines whether two instances are orthonormal to each other, or not. - /// - /// The first . - /// The second . - /// true if the instances are orthonormal to each other, otherwise false. - public static bool AreOrthonormal(Vector2 firstVector, Vector2 secondVector) - { - return firstVector.CheckForOrthonormality(secondVector); - } - - /// - /// Determines whether two instances are parallel to each other, or not. - /// - /// The first . - /// The second . - /// true if the instances are parallel to each other, otherwise false. - public static bool AreParallel(Vector2 firstVector, Vector2 secondVector) - { - return firstVector.CheckForParallelism(secondVector); - } - - /// - /// Calculates the distance between two points. - /// - /// The source point. - /// The target point. - /// The distance between specified points. - public static double Distance(Vector2 source, Vector2 target) - { - return source.Distance(target); - } - - /// - /// Divides a by multipling it with the reciprocal of the scalar. - /// - /// The . - /// The scalar whose reciprocal will be calculated. - /// The resulting . - public static Vector2 Divide(Vector2 vector, double scalar) - { - return vector * (1 / scalar); - } - - /// - /// Calculates the dot product of the specified instances. - /// - /// The first that should be included into the calculation. - /// The second that should be included into the calculation. - /// The calculated scalar as a . - public static double DotProduct(Vector2 firstVector, Vector2 secondVector) - { - return firstVector.DotProduct(secondVector); - } - - /// - /// Linearly interpolates between two instances. - /// - /// The source point. - /// The target point. - /// The fraction. - /// The position of the new point. - public static Vector2 Lerp(Vector2 source, Vector2 target, double fraction) - { - return source.Lerp(target, fraction); - } - - /// - /// Linearly interpolates between two instances. - /// - /// The source point. - /// The target point. - /// The fraction. - /// The position of the new point. - public static Vector2 LerpUnclamped(Vector2 source, Vector2 target, double fraction) - { - return source.LerpUnclamped(target, fraction); - } - - /// - /// Moves a source point in a straight line towards a target point by adding the given distance delta and returns its - /// new position. - /// - /// The source point. - /// The target point. - /// The distance delta that the source point is moved by in all directions. - /// The position of the new point. - public static Vector2 MoveTowards(Vector2 source, Vector2 target, double maxDistanceDelta) - { - return source.MoveTowards(target, maxDistanceDelta); - } - - /// - /// Calculates the vector product of the current and the specified instance. - /// - /// The other . - /// The vector product of the current and the specified . - public double VectorProduct(Vector2 other) - { - return X * other.Y - Y * other.X; - } - - /// - /// Calculates the vector product of the specified instances. - /// - /// The first . - /// The second . - /// The vector product of the specified instances. - public static double VectorProduct(Vector2 firstVector, Vector2 secondVector) - { - return firstVector.VectorProduct(secondVector); - } - - /// - /// Implements the operator +. - /// - /// The first . - /// The second . - /// - /// The resulting . - /// - public static Vector2 operator +(Vector2 firstVector, Vector2 secondVector) - { - return firstVector.Add(secondVector); - } - - /// - /// Implements the operator -. - /// - /// The first . - /// The second . - /// - /// The resulting . - /// - public static Vector2 operator -(Vector2 firstVector, Vector2 secondVector) - { - return firstVector.Subtract(secondVector); - } - - /// - /// Implements the operator -. - /// - /// The to negate. - /// - /// The negated . - /// - public static Vector2 operator -(Vector2 current) - { - return current.Negate(); - } - - /// - /// Implements the operator *. - /// - /// The . - /// The scalar. - /// - /// The resulting . - /// - public static Vector2 operator *(Vector2 vector, double scalar) - { - return vector.Multiply(scalar); - } - - /// - /// Implements the operator * for calculating the dot product of two instances. - /// - /// The first . - /// The second . - /// - /// The scalar that has been calculated. - /// - public static double operator *(Vector2 firstVector, Vector2 secondVector) - { - return firstVector.DotProduct(secondVector); - } - - /// - /// Transforms this with the specified . - /// - /// The transformation . - /// The transformed . - public Vector2 Transform(Matrix3x3 matrix) - { - return matrix * this; - } - - /// - /// Transforms the specified with the specified . - /// - /// The that should be transformed. - /// The transformation . - /// The transformed . - public static Vector2 Transform(Vector2 vector, Matrix3x3 matrix) - { - return vector.Transform(matrix); - } - - /// - /// Transforms the specified with the specified . - /// - /// The transformation . - /// The that should be transformed. - /// The transformed . - public static Vector2 Transform(Matrix3x3 matrix, Vector2 vector) - { - return vector.Transform(matrix); - } - - /// - /// Returns a that represents this instance. - /// - /// - /// A that represents this instance. - /// - public override string ToString() - { - return $"Vector2 {{X: {this[0]}, Y: {this[1]}}}"; - } - - /// - /// Determines whether the specified , is equal to the current instance. - /// - /// The to compare with the current instance. - /// - /// true if the specified is equal to the current instance; otherwise, false. - /// - public override bool Equals(object obj) - { - if (obj is Vector2) - return this == (Vector2) obj; - var vector = obj as IVector; - if (Dimension != vector?.Dimension) - return false; - return this == FromVector(vector); - } - - /// - /// Returns a hash code for this instance. - /// - /// - /// A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table. - /// - public override int GetHashCode() - { - unchecked - { - var hash = 17; - // ReSharper disable NonReadonlyMemberInGetHashCode - hash = hash * 23 + X.GetHashCode(); - hash = hash * 23 + Y.GetHashCode(); - return hash; - } - } - - public bool Equals(Vector2 other) - { - return this == other; - } - - public IEnumerator GetEnumerator() - { - for (uint i = 0; i < 2; i++) yield return this[i]; - } - - IEnumerator IEnumerable.GetEnumerator() - { - for (uint i = 0; i < 2; i++) yield return this[i]; - } - - /// - /// Implements the operator ==. - /// - /// The left . - /// The right . - /// - /// The result of the operator. - /// - public static bool operator ==(Vector2 left, Vector2 right) - { - return left.SequenceEqual(right); - } - - /// - /// Implements the operator !=. - /// - /// The left . - /// The right . - /// - /// The result of the operator. - /// - public static bool operator !=(Vector2 left, Vector2 right) - { - return !left.SequenceEqual(right); - } + var delta = target - source; + var distance = delta.Length; + if (distance <= maxDistanceDelta || distance == 0) + return target; + return source + delta / distance * maxDistanceDelta; } -} \ No newline at end of file + + /// + /// Determines whether this vector is parallel (or antiparallel) to another vector, i.e. whether the sine of the + /// angle between them is at most 1e-9, independent of their lengths. The zero vector is parallel to every vector. + /// + /// The other vector. + /// if the vectors are parallel; otherwise, . + public bool IsParallelTo(Vector2 other) + { + if (IsExactlyZero || other.IsExactlyZero) + return true; + var (u, v) = (Normalize(), other.Normalize()); + return VectorMath.IsNegligible(Math.Abs(u.Cross(v))); + } + + /// + /// Determines whether this vector is orthogonal to another vector, i.e. whether the cosine of the angle between + /// them is at most 1e-9, independent of their lengths. The zero vector is orthogonal to every vector. + /// + /// The other vector. + /// if the vectors are orthogonal; otherwise, . + public bool IsOrthogonalTo(Vector2 other) => + IsExactlyZero || other.IsExactlyZero || VectorMath.IsNegligible(Normalize().Dot(other.Normalize())); + + /// + /// Determines whether this vector and another vector are both normalized and orthogonal to each other. + /// + /// The other vector. + /// if the vectors are orthonormal; otherwise, . + public bool IsOrthonormalTo(Vector2 other) => IsNormalized && other.IsNormalized && IsOrthogonalTo(other); + + /// + /// Transforms a vector with a 2x2 matrix (matrix · vector). + /// + /// The vector. + /// The matrix. + /// The transformed vector. + public static Vector2 Transform(Vector2 vector, Matrix2x2 matrix) => matrix * vector; + + /// + /// Transforms a vector with a 3x3 matrix in homogeneous coordinates (matrix · (x, y, 1)), so translations + /// are applied. The result is divided by the resulting W component if it is not 1. + /// + /// The vector. + /// The matrix. + /// The transformed vector. + public static Vector2 Transform(Vector2 vector, Matrix3x3 matrix) => matrix * vector; + + /// + /// Converts the vector into the point it points to. + /// + /// The point (X, Y). + public Point2D ToPoint() => new(X, Y); + + /// + /// Determines whether this vector equals another vector within the default tolerances (component-wise). + /// + /// The other vector. + /// if the vectors are approximately equal; otherwise, . + public bool ApproximatelyEquals(Vector2 other) => + ApproximatelyEquals(other, Tolerance.DefaultAbsolute, Tolerance.DefaultRelative); + + /// + /// Determines whether this vector equals another vector within the specified tolerances (component-wise). + /// + /// The other vector. + /// The absolute tolerance. + /// The relative tolerance. + /// if the vectors are approximately equal; otherwise, . + public bool ApproximatelyEquals(Vector2 other, double absolute, double relative) => + Tolerance.AreEqual(X, other.X, absolute, relative) && Tolerance.AreEqual(Y, other.Y, absolute, relative); + + private bool IsExactlyZero => X == 0 && Y == 0; + + /// + /// Returns a LaTeX representation of the vector as a column vector. + /// + /// The LaTeX string. + public string ToLaTeXString() => VectorMath.ToLaTeX([X, Y]); + + /// + public override string ToString() => + string.Create(CultureInfo.InvariantCulture, $"Vector2 {{X: {X}, Y: {Y}}}"); + + /// + public bool Equals(Vector2 other) => X.Equals(other.X) && Y.Equals(other.Y); + + /// + public override bool Equals(object? obj) => obj is Vector2 other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(X, Y); + + /// + /// Adds two vectors. + /// + /// The first vector. + /// The second vector. + /// The sum. + public static Vector2 operator +(Vector2 left, Vector2 right) => new(left.X + right.X, left.Y + right.Y); + + /// + /// Subtracts a vector from another vector. + /// + /// The minuend. + /// The subtrahend. + /// The difference. + public static Vector2 operator -(Vector2 left, Vector2 right) => new(left.X - right.X, left.Y - right.Y); + + /// + /// Negates a vector. + /// + /// The vector. + /// The negated vector. + public static Vector2 operator -(Vector2 vector) => new(-vector.X, -vector.Y); + + /// + /// Multiplies a vector with a scalar. + /// + /// The vector. + /// The scalar. + /// The scaled vector. + public static Vector2 operator *(Vector2 vector, double scalar) => new(vector.X * scalar, vector.Y * scalar); + + /// + /// Multiplies a scalar with a vector. + /// + /// The scalar. + /// The vector. + /// The scaled vector. + public static Vector2 operator *(double scalar, Vector2 vector) => vector * scalar; + + /// + /// Divides a vector by a scalar. + /// + /// The vector. + /// The scalar. + /// The scaled vector. + public static Vector2 operator /(Vector2 vector, double scalar) => new(vector.X / scalar, vector.Y / scalar); + + /// + /// Determines whether two vectors are exactly equal (component-wise ==). + /// + /// The first vector. + /// The second vector. + /// if all components are equal; otherwise, . + public static bool operator ==(Vector2 left, Vector2 right) => left.X == right.X && left.Y == right.Y; + + /// + /// Determines whether two vectors differ in at least one component. + /// + /// The first vector. + /// The second vector. + /// if any component differs; otherwise, . + public static bool operator !=(Vector2 left, Vector2 right) => !(left == right); +} diff --git a/src/SharpMath/Geometry/Vector3.cs b/src/SharpMath/Geometry/Vector3.cs index 4fd994b..2c8deaf 100644 --- a/src/SharpMath/Geometry/Vector3.cs +++ b/src/SharpMath/Geometry/Vector3.cs @@ -1,493 +1,466 @@ -// Vector3.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 +using System.Globalization; + +namespace SharpMath.Geometry; + +/// +/// Represents an immutable three-dimensional vector. +/// +/// +/// The library uses a right-handed coordinate system with column vectors: points along −Z. +/// Equality (, ==) is exact. Use +/// for tolerance-based comparisons. +/// +public readonly struct Vector3 : IVector, IEquatable +{ + /// + /// Initializes a new instance of the struct. + /// + /// The X component. + /// The Y component. + /// The Z component. + public Vector3(double x, double y, double z) + { + X = x; + Y = y; + Z = z; + } + + /// + /// Initializes a new instance of the struct from a two-dimensional vector and a Z component. + /// + /// The X and Y components. + /// The Z component. + public Vector3(Vector2 xy, double z) : this(xy.X, xy.Y, z) + { + } + + /// + /// Initializes a new instance of the struct with the position vector of a point. + /// + /// The point. + public Vector3(Point3D point) : this(point.X, point.Y, point.Z) + { + } -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; + /// + /// Gets the X component. + /// + public double X { get; } -// ReSharper disable NonReadonlyMemberInGetHashCode + /// + /// Gets the Y component. + /// + public double Y { get; } + + /// + /// Gets the Z component. + /// + public double Z { get; } + + /// + /// Gets the vector (0, 0, 0). + /// + public static Vector3 Zero => default; + + /// + /// Gets the vector (1, 1, 1). + /// + public static Vector3 One => new(1, 1, 1); + + /// + /// Gets the unit vector (1, 0, 0). + /// + public static Vector3 UnitX => new(1, 0, 0); + + /// + /// Gets the unit vector (0, 1, 0). + /// + public static Vector3 UnitY => new(0, 1, 0); + + /// + /// Gets the unit vector (0, 0, 1). + /// + public static Vector3 UnitZ => new(0, 0, 1); + + /// + /// Gets the vector (0, 1, 0). + /// + public static Vector3 Up => new(0, 1, 0); + + /// + /// Gets the vector (0, −1, 0). + /// + public static Vector3 Down => new(0, -1, 0); + + /// + /// Gets the vector (−1, 0, 0). + /// + public static Vector3 Left => new(-1, 0, 0); + + /// + /// Gets the vector (1, 0, 0). + /// + public static Vector3 Right => new(1, 0, 0); + + /// + /// Gets the vector (0, 0, −1), the viewing direction in the right-handed coordinate system. + /// + public static Vector3 Forward => new(0, 0, -1); + + /// + /// Gets the vector (0, 0, 1). + /// + public static Vector3 Back => new(0, 0, 1); + + /// + public int Dimension => 3; + + /// + public double this[int index] => index switch + { + 0 => X, + 1 => Y, + 2 => Z, + _ => throw new ArgumentOutOfRangeException(nameof(index), index, "The index must be between 0 and 2.") + }; + + /// + public double Length => Math.Sqrt(LengthSquared); + + /// + public double LengthSquared => X * X + Y * Y + Z * Z; + + /// + public bool IsZero => Tolerance.IsZero(X) && Tolerance.IsZero(Y) && Tolerance.IsZero(Z); + + /// + public bool IsNormalized => Tolerance.AreEqual(LengthSquared, 1); + + /// + /// Creates a vector from homogeneous coordinates by dividing X, Y and Z by W. + /// + /// The homogeneous vector. + /// The Cartesian vector. + /// The W component is zero (a point at infinity). + public static Vector3 FromHomogeneous(Vector4 vector) + { + if (vector.W == 0) + throw new ArgumentException("A homogeneous vector with W = 0 has no Cartesian representation.", nameof(vector)); + return new Vector3(vector.X / vector.W, vector.Y / vector.W, vector.Z / vector.W); + } + + /// + /// Returns the vector with the same direction and a length of 1. + /// + /// The normalized vector. + /// The vector is the zero vector. + public Vector3 Normalize() + { + // Scaling by the largest component first avoids underflow and overflow of the squared length. + var scale = Math.Max(Math.Max(Math.Abs(X), Math.Abs(Y)), Math.Abs(Z)); + if (scale == 0) + throw new InvalidOperationException("The zero vector cannot be normalized."); + var scaled = new Vector3(X / scale, Y / scale, Z / scale); + var length = scaled.Length; + return new Vector3(scaled.X / length, scaled.Y / length, scaled.Z / length); + } + + /// + /// Calculates the dot product with another vector. + /// + /// The other vector. + /// The dot product. + public double Dot(Vector3 other) => X * other.X + Y * other.Y + Z * other.Z; + + /// + /// Calculates the dot product of two vectors. + /// + /// The first vector. + /// The second vector. + /// The dot product. + public static double Dot(Vector3 left, Vector3 right) => left.Dot(right); + + /// + /// Calculates the cross product with another vector. + /// + /// The other vector. + /// The cross product this × other. + public Vector3 Cross(Vector3 other) => + new(Y * other.Z - Z * other.Y, Z * other.X - X * other.Z, X * other.Y - Y * other.X); + + /// + /// Calculates the cross product of two vectors. + /// + /// The first vector. + /// The second vector. + /// The cross product left × right. + public static Vector3 Cross(Vector3 left, Vector3 right) => left.Cross(right); + + /// + /// Calculates the scalar triple product first · (second × third), the signed volume of the parallelepiped + /// spanned by the three vectors. + /// + /// The first vector. + /// The second vector. + /// The third vector. + /// The scalar triple product. + public static double ScalarTripleProduct(Vector3 first, Vector3 second, Vector3 third) => + first.Dot(second.Cross(third)); -namespace SharpMath.Geometry -{ /// - /// Represents a three-dimensional vector. + /// Calculates the angle between this vector and another vector. /// - [Serializable] - public struct Vector3 : IVector, IEquatable, IEnumerable + /// The other vector. + /// The angle in radians, in the range [0, π]. + /// One of the vectors is the zero vector. + public double Angle(Vector3 other) { - /// - /// Initializes a new instance of the class. - /// - /// The existing to copy. - public Vector3(Vector3 vector) - { - X = vector.X; - Y = vector.Y; - Z = vector.Z; - } - - /// - /// Initializes a new instance of the class. - /// - /// The value of the X-coordinate (X2 in mathematic coordinate systems). - /// The value of the Y-coordinate (X3 in mathematic coordinate systems). - /// The value of the Z-coordinate (X1 in mathematic coordinate systems). - public Vector3(double x, double y, double z) - { - X = x; - Y = y; - Z = z; - } - - /// - /// Initializes a new instance of the class. - /// - /// The that a position should be created for. - public Vector3(Point3D point) - { - X = point.X; - Y = point.Y; - Z = point.Z; - } - - /// - /// Initializes a new instance of the class. - /// - /// The tail of the . - /// The head of the . - public Vector3(Point3D bottom, Point3D tip) - : this((tip - bottom).PositionVector) - { - } - - /// - /// Gets or sets the value of the X-coordinate (X2 in mathematic coordinate systems). - /// - public double X { get; set; } - - /// - /// Gets or sets the value of the Y-coordinate (X3 in mathematic coordinate systems). - /// - public double Y { get; set; } - - /// - /// Gets or sets the value of the Z-coordinate (X1 in mathematic coordinate systems). - /// - public double Z { get; set; } - - /// - /// A with all values set to zero. - /// - public static Vector3 Zero => new Vector3(0, 0, 0); - - /// - /// A with all values set to one. - /// - public static Vector3 One => new Vector3(1, 1, 1); - - /// - /// A unit pointing up. - /// - public static Vector3 Up => new Vector3(0, 1, 0); - - /// - /// A unit pointing down. - /// - public static Vector3 Down => new Vector3(0, -1, 0); - - /// - /// A unit pointing to the left. - /// - public static Vector3 Left => new Vector3(-1, 0, 0); - - /// - /// A unit pointing to the right. - /// - public static Vector3 Right => new Vector3(1, 0, 0); - - /// - /// A unit pointing forward. - /// - public static Vector3 Forward => new Vector3(0, 0, 1); - - /// - /// A unit pointing backward. - /// - public static Vector3 Back => new Vector3(0, 0, -1); - - /// - /// A unit with coordinates 1, 0, 0. - /// - public static Vector3 UnitX => new Vector3(1, 0, 0); - - /// - /// A unit with coordinates 0, 1, 0. - /// - public static Vector3 UnitY => new Vector3(0, 1, 0); - - /// - /// A unit with coordinates 0, 0, 1. - /// - public static Vector3 UnitZ => new Vector3(0, 0, 1); - - public IEnumerator GetEnumerator() - { - for (uint i = 0; i < 3; i++) yield return this[i]; - } - - IEnumerator IEnumerable.GetEnumerator() - { - for (uint i = 0; i < 3; i++) yield return this[i]; - } - - public bool Equals(Vector3 other) - { - return this == other; - } - - /// - /// Gets or sets the value of the coordinate at the specified index. - /// - /// The index. - /// The value of the coordinate at the specified index. - public double this[uint index] - { - get - { - switch (index) - { - case 0: - return X; - case 1: - return Y; - case 2: - return Z; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 2."); - } - } - set - { - switch (index) - { - case 0: - X = value; - break; - case 1: - Y = value; - break; - case 2: - Z = value; - break; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 2."); - } - } - } - - /// - /// Gets the dimension of the . - /// - public uint Dimension => 3; - - /// - /// Gets the length of the . - /// - public double Magnitude => Math.Sqrt(SquareMagnitude); - - /// - /// Gets the squared length of the . - /// - public double SquareMagnitude - { - get - { - double result = 0; - for (uint i = 0; i < 3; ++i) - result += Math.Pow(this[i], 2); - return result; - } - } - - /// - /// Gets a value indicating whether the is normalized, or not. - /// - public bool IsNormalized => Magnitude.IsApproximatelyEqualTo(1); - - /// - /// Gets a value indicating whether the has all of its components set to zero, or not. - /// - public bool IsZero => this.All(c => FloatingNumber.AreApproximatelyEqual(c, 0)); - - /// - /// Gets the LaTeX-string representing this vector graphically. - /// - public string ToLaTeXString() - { - return @"\left( \begin{array}{c} " + this[0] + @" \\ " + this[1] + @" \\ " + this[2] + - @" \end{array} \right)"; - } - - /// - /// Generates a from an object implementing the interface, if the - /// dimension is correct. - /// - /// The to generate a from. - /// The generated . - /// The dimension of the given vector is invalid. It must be 3. - public static Vector3 FromVector(IVector vector) - { - if (vector.Dimension != 3) - throw new ArgumentException("The dimension of the given vector is invalid. It must be 3."); - return new Vector3(vector[0], vector[1], vector[2]); - } - - /// - /// Calculates the area of the parallelogram that this and the specified instances span. - /// - /// The other . - /// The area of the spanned parallelogram. - public double Area(Vector3 other) - { - return VectorProduct(this, other).Magnitude; - } - - /// - /// Calculates the area of the parallelogram that the two specified instances span. - /// - /// The first . - /// The second . - /// The area of the spanned parallelogram. - public static double Area(Vector3 firstVector, Vector3 secondVector) - { - return firstVector.Area(secondVector); - } - - /// - /// Linearly interpolates between two instances. - /// - /// The source point. - /// The target point. - /// The fraction. - /// The position of the new point. - public static Vector3 Lerp(Vector3 source, Vector3 target, double fraction) - { - return source.Lerp(target, fraction); - } - - /// - /// Linearly interpolates between two instances. - /// - /// The source point. - /// The target point. - /// The fraction. - /// The position of the new point. - public static Vector3 LerpUnclamped(Vector3 source, Vector3 target, double fraction) - { - return source.LerpUnclamped(target, fraction); - } - - /// - /// Moves a source point in a straight line towards a target point by adding the given distance delta and returns its - /// new position. - /// - /// The source point. - /// The target point. - /// The distance delta that the source point is moved by in all directions. - /// The position of the new point. - public static Vector3 MoveTowards(Vector3 source, Vector3 target, double maxDistanceDelta) - { - return source.MoveTowards(target, maxDistanceDelta); - } - - /// - /// Calculates the volume of the parallelepiped that is being created by the three specified - /// instances. - /// - /// The first . - /// The second . - /// The third . - /// Returns the calculated volume. - public static double ScalarTripleProduct(Vector3 firstVector, Vector3 secondVector, Vector3 thirdVector) - { - //var matrix = SquareMatrix.FromMatrix(firstVector.AsHorizontalMatrix().AugmentVertically(secondVector.AsHorizontalMatrix()).AugmentVertically(thirdVector.AsHorizontalMatrix())); - //return Math.Abs(matrix.Determinant); - - return Math.Abs(VectorProduct(firstVector, secondVector).DotProduct(thirdVector)); - } - - /// - /// Calculates the vector product of the current and the specified . - /// - /// The other . - /// The calculated . - public Vector3 VectorProduct(Vector3 other) - { - return new Vector3(Y * other.Z - Z * other.Y, Z * other.X - X * other.Z, X * other.Y - Y * other.X); - } - - /// - /// Calculates the that is perpendicular to the specified instances. - /// - /// The first that should be included into the calculation. - /// The second that should be included into the calculation. - /// Returns the calculated . - public static Vector3 VectorProduct(Vector3 firstVector, Vector3 secondVector) - { - return firstVector.VectorProduct(secondVector); - } - - /// - /// Implements the operator +. - /// - /// The first . - /// The second . - /// - /// The resulting . - /// - public static Vector3 operator +(Vector3 firstVector, Vector3 secondVector) - { - return firstVector.Add(secondVector); - } - - /// - /// Implements the operator -. - /// - /// The first . - /// The second . - /// - /// The resulting . - /// - public static Vector3 operator -(Vector3 firstVector, Vector3 secondVector) - { - return firstVector.Subtract(secondVector); - } - - /// - /// Implements the operator -. - /// - /// The to negate. - /// - /// The negated . - /// - public static Vector3 operator -(Vector3 current) - { - return current.Negate(); - } - - /// - /// Implements the operator *. - /// - /// The . - /// The scalar. - /// - /// The resulting . - /// - public static Vector3 operator *(Vector3 vector, double scalar) - { - return vector.Multiply(scalar); - } - - /// - /// Implements the operator * for calculating the scalar product of two instances. - /// - /// The first . - /// The second . - /// - /// The scalar that has been calculated. - /// - public static double operator *(Vector3 firstVector, Vector3 secondVector) - { - return firstVector.DotProduct(secondVector); - } - - /// - /// Transforms the specified with the specified . - /// - /// The that should be transformed. - /// The transformation . - /// The transformed . - public static Vector3 Transform(Vector3 vector, Matrix4x4 matrix) - { - var result = matrix * new Vector4(vector.X, vector.Y, vector.Z, 1); - result.X /= result.W; - result.Y /= result.W; - result.Z /= result.W; - result.Z /= result.W; - return result.Convert(); - } - - /// - /// Returns a that represents this instance. - /// - /// - /// A that represents this instance. - /// - public override string ToString() - { - return $"X: {this[0]}, Y: {this[1]}, Z: {this[2]}"; - } - - /// - /// Determines whether the specified , is equal to this instance. - /// - /// The to compare with this instance. - /// - /// true if the specified is equal to this instance; otherwise, false. - /// - public override bool Equals(object obj) - { - if (obj is Vector3) - return this == (Vector3) obj; - var vector = obj as IVector; - if (Dimension != vector?.Dimension) - return false; - return this == FromVector(vector); - } - - /// - /// Returns a hash code for this instance. - /// - /// - /// A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table. - /// - public override int GetHashCode() - { - unchecked - { - var hash = 17; - hash = hash * 23 + X.GetHashCode(); - hash = hash * 23 + Y.GetHashCode(); - hash = hash * 23 + Z.GetHashCode(); - return hash; - } - } - - /// - /// Implements the operator ==. - /// - /// The left . - /// The right . - /// - /// The result of the operator. - /// - public static bool operator ==(Vector3 left, Vector3 right) - { - return left.SequenceEqual(right); - } - - /// - /// Implements the operator !=. - /// - /// The left . - /// The right . - /// - /// The result of the operator. - /// - public static bool operator !=(Vector3 left, Vector3 right) - { - return !left.SequenceEqual(right); - } + if (IsExactlyZero || other.IsExactlyZero) + throw VectorMath.ZeroVectorAngle(); + var (u, v) = (Normalize(), other.Normalize()); + return VectorMath.Angle(u.Cross(v).Length, u.Dot(v)); } -} \ No newline at end of file + + /// + /// Calculates the angle between two vectors. + /// + /// The first vector. + /// The second vector. + /// The angle in radians, in the range [0, π]. + /// One of the vectors is the zero vector. + public static double Angle(Vector3 left, Vector3 right) => left.Angle(right); + + /// + /// Calculates the distance between the tips of this vector and another vector. + /// + /// The other vector. + /// The distance. + public double Distance(Vector3 other) => (this - other).Length; + + /// + /// Calculates the distance between the tips of two vectors. + /// + /// The first vector. + /// The second vector. + /// The distance. + public static double Distance(Vector3 left, Vector3 right) => left.Distance(right); + + /// + /// Calculates the squared distance between the tips of this vector and another vector. + /// + /// The other vector. + /// The squared distance. + public double DistanceSquared(Vector3 other) => (this - other).LengthSquared; + + /// + /// Calculates the squared distance between the tips of two vectors. + /// + /// The first vector. + /// The second vector. + /// The squared distance. + public static double DistanceSquared(Vector3 left, Vector3 right) => left.DistanceSquared(right); + + /// + /// Calculates the area of the parallelogram spanned by this vector and another vector. + /// + /// The other vector. + /// The area |this × other|. + public double ParallelogramArea(Vector3 other) => Cross(other).Length; + + /// + /// Interpolates linearly between two vectors. The interpolant is clamped to [0, 1]. + /// + /// The vector returned for an interpolant of 0. + /// The vector returned for an interpolant of 1. + /// The interpolant. + /// The interpolated vector. + public static Vector3 Lerp(Vector3 source, Vector3 target, double amount) => + LerpUnclamped(source, target, Math.Clamp(amount, 0, 1)); + + /// + /// Interpolates or extrapolates linearly between two vectors. + /// + /// The vector returned for an interpolant of 0. + /// The vector returned for an interpolant of 1. + /// The interpolant; values outside of [0, 1] extrapolate. + /// The interpolated vector. + public static Vector3 LerpUnclamped(Vector3 source, Vector3 target, double amount) => + source + (target - source) * amount; + + /// + /// Moves a vector towards a target by at most the specified distance without overshooting it. + /// + /// The vector to move. + /// The target vector. + /// The maximum distance to move. + /// The moved vector, or if it is within reach. + public static Vector3 MoveTowards(Vector3 source, Vector3 target, double maxDistanceDelta) + { + var delta = target - source; + var distance = delta.Length; + if (distance <= maxDistanceDelta || distance == 0) + return target; + return source + delta / distance * maxDistanceDelta; + } + + /// + /// Determines whether this vector is parallel (or antiparallel) to another vector, i.e. whether the sine of the + /// angle between them is at most 1e-9, independent of their lengths. The zero vector is parallel to every vector. + /// + /// The other vector. + /// if the vectors are parallel; otherwise, . + public bool IsParallelTo(Vector3 other) + { + if (IsExactlyZero || other.IsExactlyZero) + return true; + var (u, v) = (Normalize(), other.Normalize()); + return VectorMath.IsNegligible(u.Cross(v).Length); + } + + /// + /// Determines whether this vector is orthogonal to another vector, i.e. whether the cosine of the angle between + /// them is at most 1e-9, independent of their lengths. The zero vector is orthogonal to every vector. + /// + /// The other vector. + /// if the vectors are orthogonal; otherwise, . + public bool IsOrthogonalTo(Vector3 other) => + IsExactlyZero || other.IsExactlyZero || VectorMath.IsNegligible(Normalize().Dot(other.Normalize())); + + /// + /// Determines whether this vector and another vector are both normalized and orthogonal to each other. + /// + /// The other vector. + /// if the vectors are orthonormal; otherwise, . + public bool IsOrthonormalTo(Vector3 other) => IsNormalized && other.IsNormalized && IsOrthogonalTo(other); + + /// + /// Transforms a vector with a 3x3 matrix (matrix · vector). + /// + /// The vector. + /// The matrix. + /// The transformed vector. + public static Vector3 Transform(Vector3 vector, Matrix3x3 matrix) => matrix * vector; + + /// + /// Transforms a vector with a 4x4 matrix in homogeneous coordinates (matrix · (x, y, z, 1)), so + /// translations are applied. The result is divided by the resulting W component if it is not 1 (perspective divide). + /// + /// The vector. + /// The matrix. + /// The transformed vector. + public static Vector3 Transform(Vector3 vector, Matrix4x4 matrix) => matrix * vector; + + /// + /// Converts the vector into the point it points to. + /// + /// The point (X, Y, Z). + public Point3D ToPoint() => new(X, Y, Z); + + /// + /// Determines whether this vector equals another vector within the default tolerances (component-wise). + /// + /// The other vector. + /// if the vectors are approximately equal; otherwise, . + public bool ApproximatelyEquals(Vector3 other) => + ApproximatelyEquals(other, Tolerance.DefaultAbsolute, Tolerance.DefaultRelative); + + /// + /// Determines whether this vector equals another vector within the specified tolerances (component-wise). + /// + /// The other vector. + /// The absolute tolerance. + /// The relative tolerance. + /// if the vectors are approximately equal; otherwise, . + public bool ApproximatelyEquals(Vector3 other, double absolute, double relative) => + Tolerance.AreEqual(X, other.X, absolute, relative) && Tolerance.AreEqual(Y, other.Y, absolute, relative) && + Tolerance.AreEqual(Z, other.Z, absolute, relative); + + private bool IsExactlyZero => X == 0 && Y == 0 && Z == 0; + + /// + /// Returns a LaTeX representation of the vector as a column vector. + /// + /// The LaTeX string. + public string ToLaTeXString() => VectorMath.ToLaTeX([X, Y, Z]); + + /// + public override string ToString() => + string.Create(CultureInfo.InvariantCulture, $"Vector3 {{X: {X}, Y: {Y}, Z: {Z}}}"); + + /// + public bool Equals(Vector3 other) => X.Equals(other.X) && Y.Equals(other.Y) && Z.Equals(other.Z); + + /// + public override bool Equals(object? obj) => obj is Vector3 other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(X, Y, Z); + + /// + /// Adds two vectors. + /// + /// The first vector. + /// The second vector. + /// The sum. + public static Vector3 operator +(Vector3 left, Vector3 right) => + new(left.X + right.X, left.Y + right.Y, left.Z + right.Z); + + /// + /// Subtracts a vector from another vector. + /// + /// The minuend. + /// The subtrahend. + /// The difference. + public static Vector3 operator -(Vector3 left, Vector3 right) => + new(left.X - right.X, left.Y - right.Y, left.Z - right.Z); + + /// + /// Negates a vector. + /// + /// The vector. + /// The negated vector. + public static Vector3 operator -(Vector3 vector) => new(-vector.X, -vector.Y, -vector.Z); + + /// + /// Multiplies a vector with a scalar. + /// + /// The vector. + /// The scalar. + /// The scaled vector. + public static Vector3 operator *(Vector3 vector, double scalar) => + new(vector.X * scalar, vector.Y * scalar, vector.Z * scalar); + + /// + /// Multiplies a scalar with a vector. + /// + /// The scalar. + /// The vector. + /// The scaled vector. + public static Vector3 operator *(double scalar, Vector3 vector) => vector * scalar; + + /// + /// Divides a vector by a scalar. + /// + /// The vector. + /// The scalar. + /// The scaled vector. + public static Vector3 operator /(Vector3 vector, double scalar) => + new(vector.X / scalar, vector.Y / scalar, vector.Z / scalar); + + /// + /// Determines whether two vectors are exactly equal (component-wise ==). + /// + /// The first vector. + /// The second vector. + /// if all components are equal; otherwise, . + public static bool operator ==(Vector3 left, Vector3 right) => + left.X == right.X && left.Y == right.Y && left.Z == right.Z; + + /// + /// Determines whether two vectors differ in at least one component. + /// + /// The first vector. + /// The second vector. + /// if any component differs; otherwise, . + public static bool operator !=(Vector3 left, Vector3 right) => !(left == right); +} diff --git a/src/SharpMath/Geometry/Vector4.cs b/src/SharpMath/Geometry/Vector4.cs index 4e59cd6..5935dd4 100644 --- a/src/SharpMath/Geometry/Vector4.cs +++ b/src/SharpMath/Geometry/Vector4.cs @@ -1,297 +1,391 @@ -// Vector4.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 +using System.Globalization; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; +namespace SharpMath.Geometry; -// ReSharper disable NonReadonlyMemberInGetHashCode - -namespace SharpMath.Geometry +/// +/// Represents an immutable four-dimensional vector, e.g. a point or direction in homogeneous coordinates. +/// +/// +/// Equality (, ==) is exact. Use +/// for tolerance-based comparisons. +/// +public readonly struct Vector4 : IVector, IEquatable { /// - /// Represents a four-dimensional vector with a homogeneous coordinate. + /// Initializes a new instance of the struct. + /// + /// The X component. + /// The Y component. + /// The Z component. + /// The W component. + public Vector4(double x, double y, double z, double w) + { + X = x; + Y = y; + Z = z; + W = w; + } + + /// + /// Initializes a new instance of the struct from a three-dimensional vector and a W component. + /// + /// The X, Y and Z components. + /// The W component (1 for points, 0 for directions). + public Vector4(Vector3 xyz, double w) : this(xyz.X, xyz.Y, xyz.Z, w) + { + } + + /// + /// Gets the X component. + /// + public double X { get; } + + /// + /// Gets the Y component. + /// + public double Y { get; } + + /// + /// Gets the Z component. + /// + public double Z { get; } + + /// + /// Gets the W component. + /// + public double W { get; } + + /// + /// Gets the vector (0, 0, 0, 0). + /// + public static Vector4 Zero => default; + + /// + /// Gets the vector (1, 1, 1, 1). + /// + public static Vector4 One => new(1, 1, 1, 1); + + /// + /// Gets the unit vector (1, 0, 0, 0). + /// + public static Vector4 UnitX => new(1, 0, 0, 0); + + /// + /// Gets the unit vector (0, 1, 0, 0). + /// + public static Vector4 UnitY => new(0, 1, 0, 0); + + /// + /// Gets the unit vector (0, 0, 1, 0). + /// + public static Vector4 UnitZ => new(0, 0, 1, 0); + + /// + /// Gets the unit vector (0, 0, 0, 1). + /// + public static Vector4 UnitW => new(0, 0, 0, 1); + + /// + public int Dimension => 4; + + /// + public double this[int index] => index switch + { + 0 => X, + 1 => Y, + 2 => Z, + 3 => W, + _ => throw new ArgumentOutOfRangeException(nameof(index), index, "The index must be between 0 and 3.") + }; + + /// + public double Length => Math.Sqrt(LengthSquared); + + /// + public double LengthSquared => X * X + Y * Y + Z * Z + W * W; + + /// + public bool IsZero => Tolerance.IsZero(X) && Tolerance.IsZero(Y) && Tolerance.IsZero(Z) && Tolerance.IsZero(W); + + /// + public bool IsNormalized => Tolerance.AreEqual(LengthSquared, 1); + + /// + /// Returns the vector with the same direction and a length of 1. + /// + /// The normalized vector. + /// The vector is the zero vector. + public Vector4 Normalize() + { + // Scaling by the largest component first avoids underflow and overflow of the squared length. + var scale = Math.Max(Math.Max(Math.Max(Math.Abs(X), Math.Abs(Y)), Math.Abs(Z)), Math.Abs(W)); + if (scale == 0) + throw new InvalidOperationException("The zero vector cannot be normalized."); + var scaled = new Vector4(X / scale, Y / scale, Z / scale, W / scale); + var length = scaled.Length; + return new Vector4(scaled.X / length, scaled.Y / length, scaled.Z / length, scaled.W / length); + } + + /// + /// Calculates the dot product with another vector. + /// + /// The other vector. + /// The dot product. + public double Dot(Vector4 other) => X * other.X + Y * other.Y + Z * other.Z + W * other.W; + + /// + /// Calculates the dot product of two vectors. + /// + /// The first vector. + /// The second vector. + /// The dot product. + public static double Dot(Vector4 left, Vector4 right) => left.Dot(right); + + /// + /// Calculates the angle between this vector and another vector. + /// + /// The other vector. + /// The angle in radians, in the range [0, π]. + /// One of the vectors is the zero vector. + public double Angle(Vector4 other) + { + if (IsExactlyZero || other.IsExactlyZero) + throw VectorMath.ZeroVectorAngle(); + var (u, v) = (Normalize(), other.Normalize()); + return VectorMath.Angle(u.SpannedArea(v), u.Dot(v)); + } + + /// + /// Calculates the angle between two vectors. + /// + /// The first vector. + /// The second vector. + /// The angle in radians, in the range [0, π]. + /// One of the vectors is the zero vector. + public static double Angle(Vector4 left, Vector4 right) => left.Angle(right); + + /// + /// Calculates the distance between the tips of this vector and another vector. + /// + /// The other vector. + /// The distance. + public double Distance(Vector4 other) => (this - other).Length; + + /// + /// Calculates the distance between the tips of two vectors. + /// + /// The first vector. + /// The second vector. + /// The distance. + public static double Distance(Vector4 left, Vector4 right) => left.Distance(right); + + /// + /// Calculates the squared distance between the tips of this vector and another vector. + /// + /// The other vector. + /// The squared distance. + public double DistanceSquared(Vector4 other) => (this - other).LengthSquared; + + /// + /// Calculates the squared distance between the tips of two vectors. + /// + /// The first vector. + /// The second vector. + /// The squared distance. + public static double DistanceSquared(Vector4 left, Vector4 right) => left.DistanceSquared(right); + + /// + /// Interpolates linearly between two vectors. The interpolant is clamped to [0, 1]. + /// + /// The vector returned for an interpolant of 0. + /// The vector returned for an interpolant of 1. + /// The interpolant. + /// The interpolated vector. + public static Vector4 Lerp(Vector4 source, Vector4 target, double amount) => + LerpUnclamped(source, target, Math.Clamp(amount, 0, 1)); + + /// + /// Interpolates or extrapolates linearly between two vectors. + /// + /// The vector returned for an interpolant of 0. + /// The vector returned for an interpolant of 1. + /// The interpolant; values outside of [0, 1] extrapolate. + /// The interpolated vector. + public static Vector4 LerpUnclamped(Vector4 source, Vector4 target, double amount) => + source + (target - source) * amount; + + /// + /// Moves a vector towards a target by at most the specified distance without overshooting it. + /// + /// The vector to move. + /// The target vector. + /// The maximum distance to move. + /// The moved vector, or if it is within reach. + public static Vector4 MoveTowards(Vector4 source, Vector4 target, double maxDistanceDelta) + { + var delta = target - source; + var distance = delta.Length; + if (distance <= maxDistanceDelta || distance == 0) + return target; + return source + delta / distance * maxDistanceDelta; + } + + /// + /// Determines whether this vector is parallel (or antiparallel) to another vector, i.e. whether the sine of the + /// angle between them is at most 1e-9, independent of their lengths. The zero vector is parallel to every vector. /// - [Serializable] - public struct Vector4 : IVector, IEquatable, IEnumerable + /// The other vector. + /// if the vectors are parallel; otherwise, . + public bool IsParallelTo(Vector4 other) { - /// - /// Initializes a new instance of the class. - /// - /// The value of the X-coordinate (X2 in mathematic coordinate systems). - /// The value of the Y-coordinate (X3 in mathematic coordinate systems). - /// The value of the Z-coordinate (X1 in mathematic coordinate systems). - /// The value of the homogeneous coordinate. - public Vector4(double x, double y, double z, double w) - { - X = x; - Y = y; - Z = z; - W = w; - } - - /// - /// Initializes a new instance of the class. - /// - /// The existing to copy. - public Vector4(Vector4 vector) - { - X = vector.X; - Y = vector.Y; - Z = vector.Z; - W = vector.W; - } - - /// - /// Gets or sets the value of the X-coordinate (X2 in mathematic coordinate systems). - /// - public double X { get; set; } - - /// - /// Gets or sets the value of the Y-coordinate (X3 in mathematic coordinate systems). - /// - public double Y { get; set; } - - /// - /// Gets or sets the value of the Z-coordinate (X1 in mathematic coordinate systems). - /// - public double Z { get; set; } - - /// - /// Gets or sets the value of the homogenous coordinate. - /// - public double W { get; set; } - - /// - /// A with all values set to zero. - /// - public static Vector4 Zero => new Vector4(); - - /// - /// A unit with all values set to one. - /// - public static Vector4 One => new Vector4(1, 1, 1, 1); - - /// - /// A unit with coordinates 1, 0, 0, 0. - /// - public static Vector4 UnitX => new Vector4(1, 0, 0, 0); - - /// - /// A unit with coordinates 0, 1, 0, 0. - /// - public static Vector4 UnitY => new Vector4(0, 1, 0, 0); - - /// - /// A unit with coordinates 0, 0, 1, 0. - /// - public static Vector4 UnitZ => new Vector4(0, 0, 1, 0); - - /// - /// A unit with coordinates 0, 0, 0, 1. - /// - public static Vector4 UnitW => new Vector4(0, 0, 1, 0); - - public IEnumerator GetEnumerator() - { - for (uint i = 0; i < 4; i++) yield return this[i]; - } - - IEnumerator IEnumerable.GetEnumerator() - { - for (uint i = 0; i < 4; i++) yield return this[i]; - } - - public bool Equals(Vector4 other) - { - return this == other; - } - - /// - /// Gets or sets the value of the coordinate at the specified index. - /// - /// The index. - /// The value of the coordinate at the specified index. - public double this[uint index] - { - get - { - switch (index) - { - case 0: - return X; - case 1: - return Y; - case 2: - return Z; - case 3: - return W; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 3."); - } - } - set - { - switch (index) - { - case 0: - X = value; - break; - case 1: - Y = value; - break; - case 2: - Z = value; - break; - case 3: - W = value; - break; - default: - throw new IndexOutOfRangeException("The index must be between 0 and 3."); - } - } - } - - /// - /// Gets the dimension of the . - /// - public uint Dimension => 4; - - /// - /// Gets the length of the . - /// - public double Magnitude => Math.Sqrt(SquareMagnitude); - - /// - /// Gets the squared length of the . - /// - public double SquareMagnitude - { - get - { - double result = 0; - for (uint i = 0; i < 4; ++i) - result += Math.Pow(this[i], 2); - return result; - } - } - - /// - /// Gets a value indicating whether the is normalized, or not. - /// - public bool IsNormalized => Magnitude.IsApproximatelyEqualTo(1); - - /// - /// Gets a value indicating whether the has all of its components set to zero, or not. - /// - public bool IsZero => this.All(c => FloatingNumber.AreApproximatelyEqual(c, 0)); - - /// - /// Gets the LaTeX-string representing this vector graphically. - /// - public string ToLaTeXString() - { - return @"\left( \begin{array}{c} " + this[0] + @" \\ " + this[1] + @" \\ " + this[2] + @" \\ " + this[3] + - @" \end{array} \right)"; - } - - /// - /// Generates a from an object implementing the interface, if the - /// dimension is correct. - /// - /// The to generate a from. - /// The generated . - /// The dimension of the given vector is invalid. It must be 4. - public static Vector4 FromVector(IVector vector) - { - if (vector.Dimension != 4) - throw new ArgumentException("The dimension of the given vector is invalid. It must be 4."); - return new Vector4(vector[0], vector[1], vector[2], vector[3]); - } - - /// - /// Transforms the specified with the specified . - /// - /// The that should be transformed. - /// The transformation . - /// The transformed . - public static Vector4 Transform(Vector4 vector, Matrix4x4 matrix) - { - return matrix * vector; - } - - /// - /// Returns a that represents this instance. - /// - /// - /// A that represents this instance. - /// - public override string ToString() - { - return $"X: {this[0]}, Y: {this[1]}, Z: {this[2]}, W: {this[3]}"; - } - - /// - /// Determines whether the specified , is equal to this instance. - /// - /// The to compare with this instance. - /// - /// true if the specified is equal to this instance; otherwise, false. - /// - public override bool Equals(object obj) - { - if (obj is Vector4) - return this == (Vector4) obj; - var vector = obj as IVector; - if (Dimension != vector?.Dimension) - return false; - return this == FromVector(vector); - } - - /// - /// Returns a hash code for this instance. - /// - /// - /// A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table. - /// - public override int GetHashCode() - { - unchecked - { - var hash = 17; - hash = hash * 23 + X.GetHashCode(); - hash = hash * 23 + Y.GetHashCode(); - hash = hash * 23 + Z.GetHashCode(); - hash = hash * 23 + W.GetHashCode(); - return hash; - } - } - - /// - /// Implements the operator ==. - /// - /// The left . - /// The right . - /// - /// The result of the operator. - /// - public static bool operator ==(Vector4 left, Vector4 right) - { - return left.SequenceEqual(right); - } - - /// - /// Implements the operator !=. - /// - /// The left . - /// The right . - /// - /// The result of the operator. - /// - public static bool operator !=(Vector4 left, Vector4 right) - { - return !left.SequenceEqual(right); - } + if (IsExactlyZero || other.IsExactlyZero) + return true; + var (u, v) = (Normalize(), other.Normalize()); + return VectorMath.IsNegligible(u.SpannedArea(v)); } -} \ No newline at end of file + + + /// + /// Determines whether this vector is orthogonal to another vector, i.e. whether the cosine of the angle between + /// them is at most 1e-9, independent of their lengths. The zero vector is orthogonal to every vector. + /// + /// The other vector. + /// if the vectors are orthogonal; otherwise, . + public bool IsOrthogonalTo(Vector4 other) => + IsExactlyZero || other.IsExactlyZero || VectorMath.IsNegligible(Normalize().Dot(other.Normalize())); + + /// + /// Determines whether this vector and another vector are both normalized and orthogonal to each other. + /// + /// The other vector. + /// if the vectors are orthonormal; otherwise, . + public bool IsOrthonormalTo(Vector4 other) => IsNormalized && other.IsNormalized && IsOrthogonalTo(other); + + /// + /// Transforms a vector with a 4x4 matrix (matrix · vector). + /// + /// The vector. + /// The matrix. + /// The transformed vector. + public static Vector4 Transform(Vector4 vector, Matrix4x4 matrix) => matrix * vector; + + /// + /// Determines whether this vector equals another vector within the default tolerances (component-wise). + /// + /// The other vector. + /// if the vectors are approximately equal; otherwise, . + public bool ApproximatelyEquals(Vector4 other) => + ApproximatelyEquals(other, Tolerance.DefaultAbsolute, Tolerance.DefaultRelative); + + /// + /// Determines whether this vector equals another vector within the specified tolerances (component-wise). + /// + /// The other vector. + /// The absolute tolerance. + /// The relative tolerance. + /// if the vectors are approximately equal; otherwise, . + public bool ApproximatelyEquals(Vector4 other, double absolute, double relative) => + Tolerance.AreEqual(X, other.X, absolute, relative) && Tolerance.AreEqual(Y, other.Y, absolute, relative) && + Tolerance.AreEqual(Z, other.Z, absolute, relative) && Tolerance.AreEqual(W, other.W, absolute, relative); + + private bool IsExactlyZero => X == 0 && Y == 0 && Z == 0 && W == 0; + + /// + /// The area of the parallelogram spanned with another vector, from the 2x2 minors (Lagrange identity), which + /// avoids the cancellation of |a|²|b|² − (a·b)². + /// + private double SpannedArea(Vector4 other) => + Math.Sqrt(Square(X * other.Y - Y * other.X) + Square(X * other.Z - Z * other.X) + + Square(X * other.W - W * other.X) + Square(Y * other.Z - Z * other.Y) + + Square(Y * other.W - W * other.Y) + Square(Z * other.W - W * other.Z)); + + private static double Square(double value) => value * value; + + /// + /// Returns a LaTeX representation of the vector as a column vector. + /// + /// The LaTeX string. + public string ToLaTeXString() => VectorMath.ToLaTeX([X, Y, Z, W]); + + /// + public override string ToString() => + string.Create(CultureInfo.InvariantCulture, $"Vector4 {{X: {X}, Y: {Y}, Z: {Z}, W: {W}}}"); + + /// + public bool Equals(Vector4 other) => + X.Equals(other.X) && Y.Equals(other.Y) && Z.Equals(other.Z) && W.Equals(other.W); + + /// + public override bool Equals(object? obj) => obj is Vector4 other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(X, Y, Z, W); + + /// + /// Adds two vectors. + /// + /// The first vector. + /// The second vector. + /// The sum. + public static Vector4 operator +(Vector4 left, Vector4 right) => + new(left.X + right.X, left.Y + right.Y, left.Z + right.Z, left.W + right.W); + + /// + /// Subtracts a vector from another vector. + /// + /// The minuend. + /// The subtrahend. + /// The difference. + public static Vector4 operator -(Vector4 left, Vector4 right) => + new(left.X - right.X, left.Y - right.Y, left.Z - right.Z, left.W - right.W); + + /// + /// Negates a vector. + /// + /// The vector. + /// The negated vector. + public static Vector4 operator -(Vector4 vector) => new(-vector.X, -vector.Y, -vector.Z, -vector.W); + + /// + /// Multiplies a vector with a scalar. + /// + /// The vector. + /// The scalar. + /// The scaled vector. + public static Vector4 operator *(Vector4 vector, double scalar) => + new(vector.X * scalar, vector.Y * scalar, vector.Z * scalar, vector.W * scalar); + + /// + /// Multiplies a scalar with a vector. + /// + /// The scalar. + /// The vector. + /// The scaled vector. + public static Vector4 operator *(double scalar, Vector4 vector) => vector * scalar; + + /// + /// Divides a vector by a scalar. + /// + /// The vector. + /// The scalar. + /// The scaled vector. + public static Vector4 operator /(Vector4 vector, double scalar) => + new(vector.X / scalar, vector.Y / scalar, vector.Z / scalar, vector.W / scalar); + + /// + /// Determines whether two vectors are exactly equal (component-wise ==). + /// + /// The first vector. + /// The second vector. + /// if all components are equal; otherwise, . + public static bool operator ==(Vector4 left, Vector4 right) => + left.X == right.X && left.Y == right.Y && left.Z == right.Z && left.W == right.W; + + /// + /// Determines whether two vectors differ in at least one component. + /// + /// The first vector. + /// The second vector. + /// if any component differs; otherwise, . + public static bool operator !=(Vector4 left, Vector4 right) => !(left == right); +} diff --git a/src/SharpMath/Geometry/VectorMath.cs b/src/SharpMath/Geometry/VectorMath.cs new file mode 100644 index 0000000..9b6aff5 --- /dev/null +++ b/src/SharpMath/Geometry/VectorMath.cs @@ -0,0 +1,38 @@ +using System.Globalization; +using System.Text; + +namespace SharpMath.Geometry; + +/// +/// Shared helpers for the vector types. +/// +internal static class VectorMath +{ + /// + /// Computes the angle between two unit vectors from the length of their cross product (or of the parallelogram + /// they span) and their dot product. Unlike acos, this is accurate for (nearly) parallel vectors. + /// + public static double Angle(double sine, double cosine) => Math.Atan2(sine, cosine); + + /// + /// Determines whether a sine or cosine of two unit vectors is negligible (|value| ≤ 1e-9), independent of the + /// lengths of the original vectors. + /// + public static bool IsNegligible(double unitValue) => Math.Abs(unitValue) <= Tolerance.DefaultRelative; + + public static InvalidOperationException ZeroVectorAngle() => + new("The angle to or from the zero vector is undefined."); + + public static string ToLaTeX(ReadOnlySpan components) + { + var builder = new StringBuilder(@"\left( \begin{array}{c} "); + for (var i = 0; i < components.Length; i++) + { + if (i > 0) + builder.Append(@" \\ "); + builder.Append(components[i].ToString(CultureInfo.InvariantCulture)); + } + + return builder.Append(@" \end{array} \right)").ToString(); + } +} diff --git a/src/SharpMath/Geometry/VectorUtils.cs b/src/SharpMath/Geometry/VectorUtils.cs deleted file mode 100644 index 9420493..0000000 --- a/src/SharpMath/Geometry/VectorUtils.cs +++ /dev/null @@ -1,289 +0,0 @@ -// VectorUtils.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; -using System.Diagnostics; - -namespace SharpMath.Geometry -{ - /// - /// Defines static methods and extensions for working with instances. - /// - public static class VectorUtils - { - /// - /// Adds two instances. - /// - /// The first . - /// The second . - /// The resulting . - public static T Add(this T firstVector, T secondVector) where T : IVector, new() - { - var resultVector = new T(); - for (uint i = 0; i < resultVector.Dimension; ++i) - resultVector[i] = firstVector[i] + secondVector[i]; - return resultVector; - } - - /// - /// Calculates the angle between two instances. - /// - /// The first . - /// The second . - /// The angle between the current and the specified instance. - /// Cannot calculate the angle between two vectors, if one is zero. - public static double Angle(this T first, T second) where T : IVector - { - if (first.IsZero || second.IsZero) - throw new InvalidOperationException("Cannot calculate the angle between two vectors, if one is zero."); - return Math.Acos(first.DotProduct(second) / (first.Magnitude * second.Magnitude)); - } - - /// - /// Represents an as a horizontal whose column count is equal to its - /// dimension. - /// - /// The represented as horizontal . - public static TOut AsHorizontalMatrix(this IVector vector) where TOut : IMatrix, new() - { - var matrix = new TOut(); - if (matrix.RowCount != 1 && matrix.ColumnCount != vector.Dimension) - throw new ArgumentException( - $"Type parameter TOut is not an adequate IMatrix-type as its constraints do not fit those of the resulting matrix. The constraints must be 1x{vector.Dimension}."); - - for (uint i = 0; i < vector.Dimension; ++i) - matrix[0, i] = vector[i]; - return matrix; - } - - /// - /// Represents an as a vertical whose row count is equal to its - /// dimension. - /// - /// The represented as vertical . - public static TOut AsVerticalMatrix(this IVector vector) where TOut : IMatrix, new() - { - var matrix = new TOut(); - if (matrix.RowCount != vector.Dimension && matrix.ColumnCount != 1) - throw new ArgumentException( - $"Type parameter TOut is not an adequate IMatrix-type as its constraints do not fit those of the resulting matrix. The constraints must be {vector.Dimension}x1."); - - for (uint i = 0; i < vector.Dimension; ++i) - matrix[i, 0] = vector[i]; - return matrix; - } - - /// - /// Determines whether two instances are orthogonal to each other, or not. - /// - /// The first . - /// The second . - /// true, if the instances are orthogonal to each other, otherwise false. - public static bool CheckForOrthogonality(this T first, T second) where T : IVector - { - return !first.IsZero && !second.IsZero && - FloatingNumber.AreApproximatelyEqual(DotProduct(first, second), 0); - } - - /// - /// Determines whether two instances are orthonormal to each other, or not. - /// - /// The first . - /// The second . - /// true, if the instances are orthonormal to each other, otherwise false. - public static bool CheckForOrthonormality(this T first, T second) where T : IVector - { - return first.CheckForOrthogonality(second) && first.IsNormalized && second.IsNormalized; - } - - /// - /// Determines whether two instances are parallel to each other, or not. - /// - /// The first . - /// The second . - /// true if the instances are parallel to each other, otherwise false. - public static bool CheckForParallelism(this T first, T second) where T : IVector - { - if (first.IsZero || second.IsZero) - return false; - - double firstResult = 0; - for (uint i = 0; i < first.Dimension; ++i) - if (i == 0) - { - firstResult = second[i] / first[i]; - } - else - { - if (!FloatingNumber.AreApproximatelyEqual(second[i] / first[i], firstResult)) - return false; - } - - return true; - } - - /// - /// Creates a new object that is a copy of the current instance. - /// - /// - /// A new object that is a copy of this instance. - /// - public static T Clone(this T vector) where T : IVector, new() - { - var cloneVector = new T(); - for (uint i = 0; i < vector.Dimension; ++i) - cloneVector[i] = vector[i]; - return cloneVector; - } - - /// - /// Converts an into an of another dimension. - /// - /// The type that the should be converted to. - /// The converted into the given type. - public static TOut Convert(this IVector vector) where TOut : IVector, new() - { - var resultVector = new TOut(); - if (resultVector.Dimension == vector.Dimension) - Debug.Print( - $"Vector conversion method (Vector{vector.Dimension}.To()) is currently used to convert a vector into one of the same dimension. Please check if this has been your intention."); - for (uint i = 0; i < Math.Min(vector.Dimension, resultVector.Dimension); ++i) - resultVector[i] = vector[i]; - return resultVector; - } - - /// - /// Calculates the distance between two instances that are the position s - /// of two points. - /// - /// The source . - /// The target . - /// The distance between the two instances. - public static double Distance(this T source, T target) where T : IVector, new() - { - return Subtract(source, target).Magnitude; - } - - /// - /// Divides a by multipling it with the reciprocal of the scalar. - /// - /// The . - /// The scalar whose reciprocal will be calculated. - /// The resulting . - public static T Divide(this T vector, double scalar) where T : IVector, new() - { - var resultVector = new T(); - for (uint i = 0; i < resultVector.Dimension; ++i) - resultVector[i] = vector[i] * (1 / scalar); - return resultVector; - } - - /// - /// Calculates the dot product of two instances. - /// - /// The first . - /// The second . - /// The calculated scalar as a . - public static double DotProduct(this T first, T second) where T : IVector - { - double result = 0; - for (uint i = 0; i < first.Dimension; ++i) - result += first[i] * second[i]; - return result; - } - - /// - /// Linearly interpolates between two instances. - /// - /// The source point. - /// The target point. - /// The fraction. - /// The position of the new point. - public static T Lerp(this T current, T target, double interpolant) where T : IVector, new() - { - if (interpolant > 1) - interpolant = 1; - else if (interpolant < 0) - interpolant = 0; - return LerpUnclamped(current, target, interpolant); - } - - /// - /// Linearly interpolates between two instances. - /// - /// The source point. - /// The target point. - /// The interpolant factor. - /// The position of the new point. - public static T LerpUnclamped(this T current, T target, double interpolant) where T : IVector, new() - { - // source + (target - source) * fraction - return Add(current, Multiply(Subtract(target, current), interpolant)); - } - - /// - /// Moves the source point in a straight line towards a target point by adding the given distance delta and returns - /// its new position. - /// - /// The source point. - /// The target point. - /// The distance delta that this source point is moved by in all directions. - /// The position of the new point. - public static T MoveTowards(this T source, T target, double maxDistanceDelta) where T : IVector, new() - { - return source.LerpUnclamped(target, maxDistanceDelta / source.Distance(target)); - } - - /// - /// Multiplies a with a specified scalar. - /// - /// The . - /// The scalar. - /// The resulting . - public static T Multiply(this T vector, double scalar) where T : IVector, new() - { - var resultVector = new T(); - for (uint i = 0; i < resultVector.Dimension; ++i) - resultVector[i] = vector[i] * scalar; - return resultVector; - } - - /// - /// Negates the specified . - /// - /// The negated . - public static T Negate(this T vector) where T : IVector, new() - { - var resultVector = new T(); - for (uint i = 0; i < vector.Dimension; ++i) - resultVector[i] = -vector[i]; - return resultVector; - } - - /// - /// Calculates the normalized of the . - /// - /// The normalized . - public static T Normalize(this T vector) where T : IVector, new() - { - var resultVector = new T(); - for (uint i = 0; i < vector.Dimension; ++i) - resultVector[i] = vector[i] / vector.Magnitude; - return resultVector; - } - - /// - /// Subtracts two instances. - /// - /// The first . - /// The second . - /// The resulting . - public static T Subtract(this T firstVector, T secondVector) where T : IVector, new() - { - var resultVector = new T(); - for (uint i = 0; i < resultVector.Dimension; ++i) - resultVector[i] = firstVector[i] - secondVector[i]; - return resultVector; - } - } -} \ No newline at end of file diff --git a/src/SharpMath/MathHelper.cs b/src/SharpMath/MathHelper.cs deleted file mode 100644 index 5d598b6..0000000 --- a/src/SharpMath/MathHelper.cs +++ /dev/null @@ -1,70 +0,0 @@ -// MathHelper.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; - -// ReSharper disable InconsistentNaming - -namespace SharpMath -{ - /// - /// Provides constants and methods for logarithmic, trigonometric and other useful mathematical functions. - /// - public static class MathHelper - { - /// - /// Represents the base of the natural logarithm. - /// - public const float E = (float) Math.E; - - /// - /// Represents the base-10 logarithm of e, the base of natural logarithms. - /// - public const float Log10E = 0.4342945f; - - /// - /// Represents the base-2 logarithm of e, the base of natural logarithms. - /// - public const float Log2E = 1.442695f; - - /// - /// Represents the ratio of the circumference of a circle to its diameter, specified by the constant, π. - /// - public const float Pi = (float) Math.PI; - - /// - /// Represents over 2 (90 degrees). - /// - public const float PiOver2 = (float) (Math.PI / 2.0); - - /// - /// Represents over 4 (45 degrees). - /// - public const float PiOver4 = (float) (Math.PI / 4.0); - - /// - /// Represents 2 (360 degrees). - /// - public const float TwoPi = (float) (Math.PI * 2.0); - - /// - /// Converts degrees to radians. - /// - /// The angle in degrees. - /// The angle in radians. - public static double DegreesToRadians(double degrees) - { - return degrees * (Math.PI / 180); - } - - /// - /// Converts radians to degrees. - /// - /// The angle in radians. - /// The angle in degrees. - public static double RadiansToDegrees(double radians) - { - return radians * (180 / Math.PI); - } - } -} \ No newline at end of file diff --git a/src/SharpMath/SharpMath.csproj b/src/SharpMath/SharpMath.csproj index 4faf450..8bba334 100644 --- a/src/SharpMath/SharpMath.csproj +++ b/src/SharpMath/SharpMath.csproj @@ -2,12 +2,6 @@ A .NET math library: vectors, matrices, lines, polygons, linear equation systems and an expression parser. - - disable - false - none - false - $(NoWarn);CS1591 diff --git a/src/SharpMath/Tolerance.cs b/src/SharpMath/Tolerance.cs new file mode 100644 index 0000000..e73fbc5 --- /dev/null +++ b/src/SharpMath/Tolerance.cs @@ -0,0 +1,95 @@ +namespace SharpMath; + +/// +/// Provides tolerance-based comparisons of floating-point numbers. +/// +/// +/// Two numbers a and b are considered equal if +/// |a − b| ≤ max(absolute, relative · max(|a|, |b|)). The absolute part handles values close to zero, the +/// relative part scales with the magnitude of the compared values. is never equal to +/// anything. +/// +public static class Tolerance +{ + /// + /// The default absolute tolerance (1e-12). + /// + public const double DefaultAbsolute = 1e-12; + + /// + /// The default relative tolerance (1e-9). + /// + public const double DefaultRelative = 1e-9; + + /// + /// Determines whether two numbers are equal within the default tolerances. + /// + /// The first number. + /// The second number. + /// if the numbers are approximately equal; otherwise, . + public static bool AreEqual(double a, double b) => AreEqual(a, b, DefaultAbsolute, DefaultRelative); + + /// + /// Determines whether two numbers are equal within the specified tolerances. + /// + /// The first number. + /// The second number. + /// The absolute tolerance. Must not be negative. + /// The relative tolerance. Must not be negative. + /// if the numbers are approximately equal; otherwise, . + /// A tolerance is negative or NaN. + public static bool AreEqual(double a, double b, double absolute, double relative) + { + ValidateTolerances(absolute, relative); + + // Also covers infinities of the same sign. + if (a == b) + return true; + + var difference = Math.Abs(a - b); + if (!double.IsFinite(difference)) + return false; + + return difference <= Math.Max(absolute, relative * Math.Max(Math.Abs(a), Math.Abs(b))); + } + + /// + /// Determines whether a number is zero within the default absolute tolerance. + /// + /// The number. + /// if |value| ≤ ; otherwise, . + public static bool IsZero(double value) => Math.Abs(value) <= DefaultAbsolute; + + /// + /// Determines whether a number is zero within the specified absolute tolerance. + /// + /// The number. + /// The absolute tolerance. Must not be negative. + /// if |value| ≤ absolute; otherwise, . + /// is negative or NaN. + public static bool IsZero(double value, double absolute) + { + ValidateTolerances(absolute, 0); + return Math.Abs(value) <= absolute; + } + + /// + /// Determines whether a number is negligible compared to a reference magnitude, using the default tolerances. + /// + /// The number, e.g. a residual or a cross product. + /// The magnitude of the quantities was computed from. + /// + /// if |value| ≤ max(, + /// · |scale|); otherwise, . + /// + public static bool IsNegligible(double value, double scale) => + Math.Abs(value) <= Math.Max(DefaultAbsolute, DefaultRelative * Math.Abs(scale)); + + private static void ValidateTolerances(double absolute, double relative) + { + if (!(absolute >= 0)) + throw new ArgumentOutOfRangeException(nameof(absolute), absolute, "The tolerance must not be negative."); + if (!(relative >= 0)) + throw new ArgumentOutOfRangeException(nameof(relative), relative, "The tolerance must not be negative."); + } +} diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index bc58e1b..00e1d0f 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -4,13 +4,16 @@ Exe true + + true - $(NoWarn);CA1707;CA1305;CA1307;CA1310;CA1852;CA1861 + $(NoWarn);CA1707;CA1305;CA1307;CA1310;CA1852;CA1861;CS1591 + diff --git a/tests/SharpMath.Tests/Geometry/BoundingBox2DTests.cs b/tests/SharpMath.Tests/Geometry/BoundingBox2DTests.cs new file mode 100644 index 0000000..2271c1e --- /dev/null +++ b/tests/SharpMath.Tests/Geometry/BoundingBox2DTests.cs @@ -0,0 +1,33 @@ +using SharpMath.Geometry; + +namespace SharpMath.Tests.Geometry; + +public class BoundingBox2DTests +{ + [Fact] + public void PropertiesAndOperations() + { + var box = new BoundingBox2D(1, 2, 5, 4); + Assert.Equal(4, box.Width); + Assert.Equal(2, box.Height); + Assert.Equal(new Point2D(3, 3), box.Center); + Assert.True(box.Contains(new Point2D(1, 2))); + Assert.False(box.Contains(new Point2D(0, 3))); + Assert.Equal(new BoundingBox2D(0, 0, 5, 4), box.Union(new BoundingBox2D(0, 0, 1, 1))); + Assert.Equal(box, BoundingBox2D.FromPoints([new Point2D(5, 2), new Point2D(1, 4)])); + Assert.Throws(() => BoundingBox2D.FromPoints([])); + Assert.Throws(() => new BoundingBox2D(2, 0, 1, 0)); + Assert.Throws(() => new BoundingBox2D(0, 2, 1, 1)); + } + + [Fact] + public void EqualityAndStrings() + { + var box = new BoundingBox2D(1, 2, 5, 4); + Assert.True(box == new BoundingBox2D(1, 2, 5, 4)); + Assert.True(box != new BoundingBox2D(1, 2, 5, 5)); + Assert.True(box.Equals((object) new BoundingBox2D(1, 2, 5, 4))); + Assert.Equal(box.GetHashCode(), new BoundingBox2D(1, 2, 5, 4).GetHashCode()); + Assert.Equal("BoundingBox2D [Min: (1, 2), Max: (5, 4)]", box.ToString()); + } +} diff --git a/tests/SharpMath.Tests/Geometry/Line2DTests.cs b/tests/SharpMath.Tests/Geometry/Line2DTests.cs new file mode 100644 index 0000000..baae7bf --- /dev/null +++ b/tests/SharpMath.Tests/Geometry/Line2DTests.cs @@ -0,0 +1,124 @@ +using SharpMath.Geometry; + +namespace SharpMath.Tests.Geometry; + +public class Line2DTests +{ + [Fact] + public void FromPoints_ProvidesSlopeAndIntercept() + { + var line = Line2D.FromPoints(new Point2D(0, 2), new Point2D(3, 1)); + Assert.Equal(-1d / 3d, line.Slope, 12); + Assert.Equal(2, line.YIntercept, 12); + + var second = Line2D.FromPoints(new Point2D(4, 5), new Point2D(1, 3)); + Assert.Equal(2d / 3d, second.Slope, 12); + Assert.Equal(7d / 3d, second.YIntercept, 12); + } + + [Fact] + public void FromPoints_RejectsIdenticalPoints() => + Assert.Throws(() => Line2D.FromPoints(new Point2D(1, 1), new Point2D(1, 1))); + + [Fact] + public void VerticalLines_AreSupported() + { + // Regression: vertical lines used to have an infinite slope and contain none of their points. + var line = Line2D.FromPoints(new Point2D(1, 0), new Point2D(1, 5)); + Assert.True(line.IsVertical); + Assert.False(line.IsHorizontal); + Assert.True(double.IsNaN(line.Slope)); + Assert.True(double.IsNaN(line.YIntercept)); + Assert.True(double.IsNaN(line.SolveForY(1))); + Assert.Equal(1, line.SolveForX(42), 12); + Assert.True(line.Contains(new Point2D(1, 0))); + Assert.True(line.Contains(new Point2D(1, 5))); + Assert.False(line.Contains(new Point2D(2, 5))); + } + + [Fact] + public void GeneralForm_IsNormalized() + { + // Issue #9: 4/3·x − y + 1/3 = 0 + var line = Line2D.FromGeneralForm(1.3333333333333333, -1, 0.3333333333333335); + Assert.Equal(1, line.A * line.A + line.B * line.B, 12); + Assert.True(line.A > 0); + Assert.Equal((-0.3333333333333335 - 1.3333333333333333 * 2) / -1, line.SolveForY(2), 12); + Assert.True(line.ApproximatelyEquals(Line2D.FromGeneralForm(-2.6666666666666666, 2, -0.666666666666667))); + Assert.True(Line2D.FromGeneralForm(0, -2, 4).ApproximatelyEquals(Line2D.FromGeneralForm(0, 1, -2))); + Assert.True(Line2D.FromGeneralForm(0, 1, -2).IsHorizontal); + Assert.True(double.IsNaN(Line2D.FromGeneralForm(0, 1, -2).SolveForX(1))); + Assert.Throws(() => Line2D.FromGeneralForm(0, 0, 1)); + Assert.Throws(() => Line2D.FromGeneralForm(double.NaN, 1, 1)); + } + + [Fact] + public void FactoriesProduceTheSameLine() + { + var fromSlope = Line2D.FromSlopeIntercept(2, 3); + var fromPoints = Line2D.FromPoints(new Point2D(0, 3), new Point2D(1, 5)); + var fromDirection = Line2D.FromPointAndDirection(new Point2D(-1, 1), new Vector2(-2, -4)); + Assert.True(fromSlope.ApproximatelyEquals(fromPoints)); + Assert.True(fromSlope.ApproximatelyEquals(fromDirection)); + Assert.Throws(() => Line2D.FromPointAndDirection(Point2D.Origin, Vector2.Zero)); + } + + [Fact] + public void Contains_DistanceAndProjection() + { + var line = Line2D.FromSlopeIntercept(1, 0); + Assert.True(line.Contains(new Point2D(1, 1))); + Assert.False(line.Contains(new Point2D(2, 1))); + Assert.True(Line2D.FromSlopeIntercept(2, 3).Contains(new Point2D(4, 11))); + Assert.Equal(Math.Sqrt(2), line.DistanceTo(new Point2D(2, 0)), 12); + Assert.Equal(-Math.Sqrt(2), line.SignedDistanceTo(new Point2D(0, 2)), 12); + Assert.True(line.ClosestPoint(new Point2D(2, 0)).ApproximatelyEquals(new Point2D(1, 1))); + Assert.Equal(new Point2D(1, 4), new Point2D(1, Line2D.FromSlopeIntercept(3, 1).SolveForY(1))); + Assert.True(line.Contains(line.PointAt(3.5))); + Assert.True(line.Direction.IsNormalized); + Assert.True(line.Normal.IsOrthogonalTo(line.Direction)); + } + + [Fact] + public void Parallelism() + { + Assert.True(Line2D.FromSlopeIntercept(2, 0).IsParallelTo(Line2D.FromSlopeIntercept(2, 3))); + Assert.False(Line2D.FromSlopeIntercept(2, 3).IsParallelTo(Line2D.FromSlopeIntercept(3, 1))); + } + + [Fact] + public void Intersect_ReportsPointNoneAndCoincident() + { + var intersection = Line2D.FromSlopeIntercept(0.5, -1).Intersect(Line2D.FromSlopeIntercept(-2, -6)); + Assert.Equal(IntersectionKind.Point, intersection.Kind); + Assert.True(intersection.Point!.Value.ApproximatelyEquals(new Point2D(-2, -2))); + + var second = Line2D.FromSlopeIntercept(2, 0).Intersect(Line2D.FromSlopeIntercept(3, 1)); + Assert.True(second.Point!.Value.ApproximatelyEquals(new Point2D(-1, -2))); + + var parallel = Line2D.FromSlopeIntercept(2, 0).Intersect(Line2D.FromSlopeIntercept(2, 3)); + Assert.Equal(new LineIntersection2D(IntersectionKind.None, null), parallel); + + // Regression: coincident lines used to report "no intersection" with a NaN point. + var line = Line2D.FromSlopeIntercept(2, 1); + Assert.Equal(IntersectionKind.Coincident, line.Intersect(Line2D.FromPoints(new Point2D(0, 1), new Point2D(1, 3))).Kind); + + var vertical = Line2D.FromGeneralForm(1, 0, -2).Intersect(Line2D.FromSlopeIntercept(1, 0)); + Assert.True(vertical.Point!.Value.ApproximatelyEquals(new Point2D(2, 2))); + } + + [Fact] + public void EqualityAndStrings() + { + var line = Line2D.FromGeneralForm(3, 4, 5); + Assert.Equal(0.6, line.A, 12); + Assert.Equal(0.8, line.B, 12); + Assert.Equal(1, line.C, 12); + Assert.True(line == Line2D.FromGeneralForm(3, 4, 5)); + Assert.True(line != Line2D.FromGeneralForm(3, 4, 6)); + Assert.True(line.Equals((object) Line2D.FromGeneralForm(6, 8, 10))); + Assert.False(line.Equals((object) 3)); + Assert.Equal(line.GetHashCode(), Line2D.FromGeneralForm(3, 4, 5).GetHashCode()); + Assert.Equal("Line2D [0.6·x + 0.8·y + 1 = 0]", line.ToString()); + } +} diff --git a/tests/SharpMath.Tests/Geometry/Line3DTests.cs b/tests/SharpMath.Tests/Geometry/Line3DTests.cs new file mode 100644 index 0000000..146f487 --- /dev/null +++ b/tests/SharpMath.Tests/Geometry/Line3DTests.cs @@ -0,0 +1,84 @@ +using SharpMath.Geometry; + +namespace SharpMath.Tests.Geometry; + +public class Line3DTests +{ + [Fact] + public void Factories_StoreCanonicalForm() + { + var line = Line3D.FromPoints(Point3D.Origin, new Point3D(1, 1, 1)); + Assert.Equal(Point3D.Origin, line.Point); + Assert.True(line.Direction.ApproximatelyEquals(Vector3.One.Normalize())); + + var reversed = Line3D.FromPoints(new Point3D(2, 2, 2), new Point3D(-1, -1, -1)); + Assert.True(line.ApproximatelyEquals(reversed)); + + var offset = Line3D.FromPointAndDirection(new Point3D(5, 1, 0), new Vector3(-3, 0, 0)); + Assert.Equal(new Point3D(0, 1, 0), offset.Point); + Assert.Equal(Vector3.UnitX, offset.Direction); + + Assert.Throws(() => Line3D.FromPoints(Point3D.Origin, Point3D.Origin)); + Assert.Throws(() => Line3D.FromPointAndDirection(Point3D.Origin, Vector3.Zero)); + } + + [Fact] + public void Contains_WorksForAxisAlignedDirections() + { + var line = Line3D.FromPointAndDirection(new Point3D(2, 3, 1), new Vector3(2, 1, 2)); + Assert.True(line.Contains(new Point3D(6, 5, 5))); + Assert.False(line.Contains(new Point3D(2, 3.5, 2))); + + // Regression: zero direction components used to break the check. + Assert.True(Line3D.FromPointAndDirection(Point3D.Origin, Vector3.UnitX).Contains(new Point3D(5, 0, 0))); + Assert.True(Line3D.FromPointAndDirection(Point3D.Origin, new Vector3(0, 1, 1)).Contains(new Point3D(0, 2, 2))); + } + + [Fact] + public void DistancesAndClosestPoints() + { + var xAxis = Line3D.FromPointAndDirection(Point3D.Origin, Vector3.UnitX); + Assert.Equal(5, xAxis.DistanceTo(new Point3D(7, 3, 4)), 12); + Assert.Equal(new Point3D(7, 0, 0), xAxis.ClosestPoint(new Point3D(7, 3, 4))); + Assert.Equal(new Point3D(2, 0, 0), xAxis.PointAt(2)); + + var skew = Line3D.FromPointAndDirection(new Point3D(0, 0, 3), Vector3.UnitY); + Assert.Equal(3, xAxis.DistanceTo(skew), 12); + var (onX, onSkew) = xAxis.ClosestPoints(skew); + Assert.True(onX.ApproximatelyEquals(Point3D.Origin)); + Assert.True(onSkew.ApproximatelyEquals(new Point3D(0, 0, 3))); + + var parallel = Line3D.FromPointAndDirection(new Point3D(0, 2, 0), Vector3.UnitX); + Assert.Equal(2, xAxis.DistanceTo(parallel), 12); + Assert.True(xAxis.IsParallelTo(parallel)); + Assert.False(xAxis.IsParallelTo(skew)); + } + + [Fact] + public void Intersect_ReportsAllKinds() + { + var xAxis = Line3D.FromPointAndDirection(Point3D.Origin, Vector3.UnitX); + var crossing = Line3D.FromPoints(new Point3D(1, -1, 0), new Point3D(1, 1, 0)); + var intersection = xAxis.Intersect(crossing); + Assert.Equal(IntersectionKind.Point, intersection.Kind); + Assert.True(intersection.Point!.Value.ApproximatelyEquals(new Point3D(1, 0, 0))); + + Assert.Equal(IntersectionKind.Skew, + xAxis.Intersect(Line3D.FromPointAndDirection(new Point3D(0, 0, 3), Vector3.UnitY)).Kind); + Assert.Equal(IntersectionKind.None, + xAxis.Intersect(Line3D.FromPointAndDirection(new Point3D(0, 2, 0), Vector3.UnitX)).Kind); + Assert.Equal(IntersectionKind.Coincident, + xAxis.Intersect(Line3D.FromPoints(new Point3D(3, 0, 0), new Point3D(-1, 0, 0))).Kind); + } + + [Fact] + public void EqualityAndStrings() + { + var line = Line3D.FromPointAndDirection(new Point3D(4, 1, 0), Vector3.UnitX); + Assert.True(line == Line3D.FromPointAndDirection(new Point3D(-2, 1, 0), -Vector3.UnitX)); + Assert.True(line != Line3D.FromPointAndDirection(new Point3D(0, 2, 0), Vector3.UnitX)); + Assert.True(line.Equals((object) Line3D.FromPointAndDirection(new Point3D(9, 1, 0), Vector3.UnitX))); + Assert.Equal(line.GetHashCode(), Line3D.FromPointAndDirection(new Point3D(1, 1, 0), Vector3.UnitX).GetHashCode()); + Assert.Equal("Line3D [(0, 1, 0) + t·(1, 0, 0)]", line.ToString()); + } +} diff --git a/tests/SharpMath.Tests/Geometry/LineSegment2DTests.cs b/tests/SharpMath.Tests/Geometry/LineSegment2DTests.cs new file mode 100644 index 0000000..fdc42ad --- /dev/null +++ b/tests/SharpMath.Tests/Geometry/LineSegment2DTests.cs @@ -0,0 +1,100 @@ +using SharpMath.Geometry; + +namespace SharpMath.Tests.Geometry; + +public class LineSegment2DTests +{ + private static readonly LineSegment2D Diagonal = new(new Point2D(0, 0), new Point2D(4, 4)); + + [Fact] + public void BasicProperties() + { + Assert.Equal(new Point2D(0, 0), Diagonal.Start); + Assert.Equal(new Point2D(4, 4), Diagonal.End); + Assert.Equal(new Vector2(4, 4), Diagonal.Direction); + Assert.Equal(Math.Sqrt(32), Diagonal.Length, 12); + Assert.Equal(new Point2D(2, 2), Diagonal.Midpoint); + Assert.True(Diagonal.ToLine().ApproximatelyEquals(Line2D.FromSlopeIntercept(1, 0))); + Assert.Throws(() => new LineSegment2D(Point2D.Origin, Point2D.Origin).ToLine()); + } + + [Fact] + public void ContainsDistanceAndClosestPoint() + { + Assert.True(Diagonal.Contains(new Point2D(1, 1))); + Assert.True(Diagonal.Contains(new Point2D(4, 4))); + Assert.False(Diagonal.Contains(new Point2D(5, 5))); + Assert.False(Diagonal.Contains(new Point2D(1, 2))); + Assert.Equal(Math.Sqrt(2), Diagonal.DistanceTo(new Point2D(5, 5)), 12); + Assert.Equal(new Point2D(0, 0), Diagonal.ClosestPoint(new Point2D(-3, -1))); + Assert.True(Diagonal.ClosestPoint(new Point2D(0, 2)).ApproximatelyEquals(new Point2D(1, 1))); + var point = new LineSegment2D(new Point2D(1, 1), new Point2D(1, 1)); + Assert.Equal(new Point2D(1, 1), point.ClosestPoint(new Point2D(5, 5))); + } + + [Fact] + public void Intersect_Segments() + { + var crossing = Diagonal.Intersect(new LineSegment2D(new Point2D(0, 4), new Point2D(4, 0))); + Assert.Equal(IntersectionKind.Point, crossing.Kind); + Assert.True(crossing.Point!.Value.ApproximatelyEquals(new Point2D(2, 2))); + Assert.Null(crossing.Segment); + + var touching = Diagonal.Intersect(new LineSegment2D(new Point2D(4, 4), new Point2D(6, 0))); + Assert.True(touching.Point!.Value.ApproximatelyEquals(new Point2D(4, 4))); + + Assert.Equal(IntersectionKind.None, + Diagonal.Intersect(new LineSegment2D(new Point2D(0, 4), new Point2D(1, 3))).Kind); + Assert.Equal(IntersectionKind.None, + Diagonal.Intersect(new LineSegment2D(new Point2D(0, 1), new Point2D(4, 5))).Kind); + Assert.Equal(IntersectionKind.None, + Diagonal.Intersect(new LineSegment2D(new Point2D(5, 5), new Point2D(6, 6))).Kind); + } + + [Fact] + public void Intersect_CollinearSegments() + { + var overlap = Diagonal.Intersect(new LineSegment2D(new Point2D(6, 6), new Point2D(2, 2))); + Assert.Equal(IntersectionKind.Overlap, overlap.Kind); + Assert.True(overlap.Segment!.Value.ApproximatelyEquals(new LineSegment2D(new Point2D(2, 2), new Point2D(4, 4)))); + + var endToEnd = Diagonal.Intersect(new LineSegment2D(new Point2D(4, 4), new Point2D(6, 6))); + Assert.Equal(IntersectionKind.Point, endToEnd.Kind); + Assert.True(endToEnd.Point!.Value.ApproximatelyEquals(new Point2D(4, 4))); + } + + [Fact] + public void Intersect_DegenerateSegments() + { + var point = new LineSegment2D(new Point2D(1, 1), new Point2D(1, 1)); + Assert.Equal(IntersectionKind.Point, point.Intersect(Diagonal).Kind); + Assert.Equal(IntersectionKind.Point, Diagonal.Intersect(point).Kind); + Assert.Equal(IntersectionKind.None, new LineSegment2D(new Point2D(1, 2), new Point2D(1, 2)).Intersect(Diagonal).Kind); + Assert.Equal(IntersectionKind.None, Diagonal.Intersect(new LineSegment2D(new Point2D(1, 2), new Point2D(1, 2))).Kind); + } + + [Fact] + public void Intersect_Lines() + { + var crossing = Diagonal.Intersect(Line2D.FromGeneralForm(1, 0, -3)); + Assert.True(crossing.Point!.Value.ApproximatelyEquals(new Point2D(3, 3))); + Assert.Equal(IntersectionKind.None, Diagonal.Intersect(Line2D.FromGeneralForm(1, 0, -5)).Kind); + Assert.Equal(IntersectionKind.None, Diagonal.Intersect(Line2D.FromSlopeIntercept(1, 1)).Kind); + var coincident = Diagonal.Intersect(Line2D.FromSlopeIntercept(1, 0)); + Assert.Equal(IntersectionKind.Overlap, coincident.Kind); + Assert.Equal(Diagonal, coincident.Segment); + var point = new LineSegment2D(new Point2D(1, 1), new Point2D(1, 1)); + Assert.Equal(IntersectionKind.Point, point.Intersect(Line2D.FromSlopeIntercept(1, 0)).Kind); + Assert.Equal(IntersectionKind.None, point.Intersect(Line2D.FromSlopeIntercept(1, 1)).Kind); + } + + [Fact] + public void EqualityAndStrings() + { + Assert.True(Diagonal == new LineSegment2D(new Point2D(0, 0), new Point2D(4, 4))); + Assert.True(Diagonal != new LineSegment2D(new Point2D(4, 4), new Point2D(0, 0))); + Assert.True(Diagonal.Equals((object) new LineSegment2D(new Point2D(0, 0), new Point2D(4, 4)))); + Assert.Equal(Diagonal.GetHashCode(), new LineSegment2D(new Point2D(0, 0), new Point2D(4, 4)).GetHashCode()); + Assert.Equal("LineSegment2D [(0, 0) → (4, 4)]", Diagonal.ToString()); + } +} diff --git a/tests/SharpMath.Tests/Geometry/Matrix2x2Tests.cs b/tests/SharpMath.Tests/Geometry/Matrix2x2Tests.cs new file mode 100644 index 0000000..a0408fb --- /dev/null +++ b/tests/SharpMath.Tests/Geometry/Matrix2x2Tests.cs @@ -0,0 +1,118 @@ +using SharpMath.Geometry; + +namespace SharpMath.Tests.Geometry; + +public class Matrix2x2Tests +{ + private static readonly Matrix2x2 Sample = new(8, 3, 4, 2); + + [Fact] + public void ConstructorsAndIndexers_AreRowMajor() + { + Assert.Equal(Sample, new Matrix2x2(new Vector2(8, 3), new Vector2(4, 2))); + Assert.Equal(3, Sample[0, 1]); + Assert.Equal(4, Sample[1, 0]); + Assert.Equal(3, Sample[1]); + Assert.Equal(2, Sample[3]); + Assert.Equal(8, Sample.M11); + Assert.Equal(2, Sample.RowCount); + Assert.Equal(2, Sample.ColumnCount); + Assert.Equal(2, Sample.Dimension); + + // Regression: [0, 2] used to silently return M21. + Assert.Throws(() => Sample[0, 2]); + Assert.Throws(() => Sample[2, 0]); + Assert.Throws(() => Sample[4]); + } + + [Fact] + public void DeterminantTraceAndInverse() + { + Assert.Equal(4, Sample.Determinant); + Assert.Equal(10, Sample.Trace); + + // Regression: Inverse used to return the identity matrix. + var inverse = Sample.Inverse(); + Assert.True(inverse.ApproximatelyEquals(new Matrix2x2(0.5, -0.75, -1, 2))); + Assert.True((Sample * inverse).IsIdentity); + Assert.Equal(new Matrix2x2(2, -3, -4, 8), Sample.Adjugate()); + Assert.Equal(new Matrix2x2(2, -4, -3, 8), Sample.Cofactors()); + } + + [Fact] + public void Inverse_OfSingularMatrixThrows() + { + var singular = new Matrix2x2(1, 2, 2, 4); + Assert.True(singular.IsSingular); + Assert.False(singular.TryInvert(out var inverse)); + Assert.Equal(Matrix2x2.Zero, inverse); + Assert.Throws(() => singular.Inverse()); + } + + [Fact] + public void IsSingular_IsScaleAware() + { + Assert.False((Matrix2x2.Identity * 1e-5).IsSingular); + Assert.True(Matrix2x2.Zero.IsSingular); + } + + [Fact] + public void Predicates() + { + // Regression: IsIdentity used to check M22 == 0. + Assert.True(Matrix2x2.Identity.IsIdentity); + Assert.False(new Matrix2x2(1, 0, 0, 0).IsIdentity); + Assert.False(new Matrix2x2(1, 1, 0, 1).IsIdentity); + + Assert.True(new Matrix2x2(1, 0, 0, 0).IsDiagonal); + Assert.True(Matrix2x2.Zero.IsDiagonal); + Assert.False(Sample.IsDiagonal); + Assert.True(new Matrix2x2(1, 2, 0, 3).IsTriangular); + Assert.False(Sample.IsTriangular); + Assert.True(new Matrix2x2(1, 2, 2, 3).IsSymmetric); + Assert.False(Sample.IsSymmetric); + Assert.True(new Matrix2x2(0, 2, -2, 0).IsSkewSymmetric); + Assert.False(new Matrix2x2(1, 2, -2, 0).IsSkewSymmetric); + Assert.True(Matrix2x2.Rotation(0.7).IsOrthogonal); + Assert.False(Sample.IsOrthogonal); + } + + [Fact] + public void Factories() + { + Assert.True((Matrix2x2.Rotation(Math.PI / 2) * Vector2.UnitX).ApproximatelyEquals(Vector2.UnitY)); + Assert.Equal(new Vector2(2, 3), Matrix2x2.Scaling(2, 3) * Vector2.One); + Assert.Equal(new Matrix2x2(4, 0, 0, 4), Matrix2x2.Scaling(4)); + Assert.Equal(Sample, Matrix2x2.FromMatrix(MatrixMxN.FromMatrix(Sample))); + Assert.Throws(() => Matrix2x2.FromMatrix(Matrix3x3.Identity)); + Assert.Throws(() => Matrix2x2.FromMatrix(null!)); + } + + [Fact] + public void Operators() + { + var other = new Matrix2x2(1, 2, 3, 4); + Assert.Equal(new Matrix2x2(9, 5, 7, 6), Sample + other); + Assert.Equal(new Matrix2x2(7, 1, 1, -2), Sample - other); + Assert.Equal(new Matrix2x2(-8, -3, -4, -2), -Sample); + Assert.Equal(new Matrix2x2(16, 6, 8, 4), Sample * 2); + Assert.Equal(new Matrix2x2(16, 6, 8, 4), 2 * Sample); + Assert.Equal(new Matrix2x2(4, 1.5, 2, 1), Sample / 2); + Assert.Equal(new Matrix2x2(17, 28, 10, 16), Sample * other); + Assert.Equal(new Vector2(11, 6), Sample * Vector2.One); + Assert.Equal(new Matrix2x2(8, 4, 3, 2), Sample.Transpose()); + } + + [Fact] + public void EqualityAndStrings() + { + Assert.True(Sample == new Matrix2x2(8, 3, 4, 2)); + Assert.True(Sample != Matrix2x2.Identity); + Assert.True(Sample.Equals((object) new Matrix2x2(8, 3, 4, 2))); + Assert.False(Sample.Equals((object) 1)); + Assert.Equal(Sample.GetHashCode(), new Matrix2x2(8, 3, 4, 2).GetHashCode()); + Assert.True(Sample.ApproximatelyEquals(new Matrix2x2(8, 3, 4, 2 + 1e-12))); + Assert.False(Sample.ApproximatelyEquals(new Matrix2x2(8, 3, 4, 2.1), 0.01, 0)); + Assert.Equal("Matrix2x2 [[8, 3], [4, 2]]", Sample.ToString()); + } +} diff --git a/tests/SharpMath.Tests/Geometry/Matrix3x3Tests.cs b/tests/SharpMath.Tests/Geometry/Matrix3x3Tests.cs new file mode 100644 index 0000000..e0b3ebb --- /dev/null +++ b/tests/SharpMath.Tests/Geometry/Matrix3x3Tests.cs @@ -0,0 +1,117 @@ +using SharpMath.Geometry; + +namespace SharpMath.Tests.Geometry; + +public class Matrix3x3Tests +{ + private static readonly Matrix3x3 A = new(2, 5, 2, 3, -3, 1, 1, 4, -4); + private static readonly Matrix3x3 B = new(-4, 2.5, 3, 5, 6, 4, 9, 10, -9); + + [Fact] + public void Determinant_UsesClosedForm() + { + Assert.Equal(111, A.Determinant); + Assert.Equal(566.5, B.Determinant); + Assert.Equal(-5, A.Trace); + } + + [Fact] + public void Multiplication_IsRowByColumn() + { + Assert.Equal(new Matrix3x3(35, 55, 8, -18, -0.5, -12, -20, -13.5, 55), A * B); + Assert.Equal(new Vector3(9, 1, 1), A * Vector3.One); + } + + [Fact] + public void RowConstructor_PlacesRowsAsRows() + { + // Regression: the vectors used to be placed as columns. + var matrix = new Matrix3x3(new Vector3(1, 2, 3), new Vector3(4, 5, 6), new Vector3(7, 8, 9)); + Assert.Equal(new Matrix3x3(1, 2, 3, 4, 5, 6, 7, 8, 9), matrix); + Assert.Equal(2, matrix[0, 1]); + Assert.Equal(4, matrix[1, 0]); + Assert.Equal(9, matrix[8]); + Assert.Throws(() => matrix[0, 3]); + Assert.Throws(() => matrix[9]); + } + + [Fact] + public void Inverse_AdjugateAndCofactors() + { + // Regression: Inverse used to return the identity matrix. + Assert.True((A * A.Inverse()).IsIdentity); + Assert.True((A.Inverse() * A).IsIdentity); + Assert.True((A * A.Adjugate()).ApproximatelyEquals(Matrix3x3.Identity * A.Determinant)); + Assert.Equal(A.Adjugate().Transpose(), A.Cofactors()); + + var singular = new Matrix3x3(1, 2, 3, 2, 4, 6, 1, 1, 1); + Assert.True(singular.IsSingular); + Assert.False(singular.TryInvert(out _)); + Assert.Throws(() => singular.Inverse()); + + // Regression: a permutation matrix used to be reported as not invertible. + var permutation = new Matrix3x3(0, 1, 0, 1, 0, 0, 0, 0, 1); + Assert.Equal(permutation, permutation.Inverse()); + + // Regression: small but regular matrices used to count as singular. + Assert.False((Matrix3x3.Identity * 0.001).IsSingular); + } + + [Fact] + public void Predicates() + { + Assert.True(Matrix3x3.Identity.IsIdentity); + // Regression: off-diagonal entries used to be ignored. + Assert.False(new Matrix3x3(1, 5, 0, 0, 1, 0, 0, 0, 1).IsIdentity); + Assert.True(new Matrix3x3(1, 0, 0, 0, 1, 0, 0, 0, 0).IsDiagonal); + Assert.True(new Matrix3x3(1, 3, 4, 0, 2, 7, 0, 0, 3).IsTriangular); + Assert.True(new Matrix3x3(1, 0, 0, 3, 2, 0, 4, 7, 3).IsTriangular); + Assert.False(A.IsTriangular); + Assert.True(new Matrix3x3(1, 2, 3, 2, 5, 6, 3, 6, 9).IsSymmetric); + Assert.False(A.IsSymmetric); + Assert.True(new Matrix3x3(0, 1, -2, -1, 0, 3, 2, -3, 0).IsSkewSymmetric); + Assert.True(Matrix3x3.Rotation(1.2).IsOrthogonal); + Assert.False(A.IsOrthogonal); + } + + [Fact] + public void TwoDimensionalTransformations() + { + Assert.Equal(new Vector2(11, 11), Matrix3x3.Translation(10, 10) * Vector2.One); + Assert.Equal(new Vector2(2, 3), Matrix3x3.Scaling(2, 3) * Vector2.One); + Assert.Equal(new Vector2(2, 2), Matrix3x3.Scaling(2) * Vector2.One); + + // Regression: the old test checked the input vector instead of the rotated one. + var rotated = Matrix3x3.Rotation(Math.PI) * new Vector2(0, 1); + Assert.True(rotated.ApproximatelyEquals(new Vector2(0, -1))); + + // Composition from right to left: scale, then rotate, then translate. + var transform = Matrix3x3.Translation(5, 0) * Matrix3x3.Rotation(Math.PI / 2) * Matrix3x3.Scaling(2); + Assert.True((transform * Vector2.UnitX).ApproximatelyEquals(new Vector2(5, 2))); + + var projective = new Matrix3x3(1, 0, 0, 0, 1, 0, 0, 0, 2); + Assert.Equal(new Vector2(0.5, 0.5), projective * Vector2.One); + } + + [Fact] + public void ElementWiseOperatorsEqualityAndStrings() + { + Assert.Equal(A * 2, A + A); + Assert.Equal(Matrix3x3.Zero, A - A); + Assert.Equal(A * -1, -A); + Assert.Equal(A * 2, 2 * A); + Assert.Equal(A, A * 2 / 2); + Assert.Equal(A, Matrix3x3.FromMatrix(MatrixMxN.FromMatrix(A))); + Assert.Throws(() => Matrix3x3.FromMatrix(Matrix2x2.Identity)); + Assert.True(A == new Matrix3x3(2, 5, 2, 3, -3, 1, 1, 4, -4)); + Assert.True(A != B); + Assert.True(A.Equals((object) new Matrix3x3(2, 5, 2, 3, -3, 1, 1, 4, -4))); + Assert.Equal(A.GetHashCode(), new Matrix3x3(2, 5, 2, 3, -3, 1, 1, 4, -4).GetHashCode()); + Assert.True(A.ApproximatelyEquals(A + Matrix3x3.Identity * 1e-12)); + Assert.True(A.ApproximatelyEquals(A + Matrix3x3.Identity * 0.01, 0.1, 0)); + Assert.Equal(3, A.RowCount); + Assert.Equal(3, A.ColumnCount); + Assert.Equal(3, A.Dimension); + Assert.Equal("Matrix3x3 [[2, 5, 2], [3, -3, 1], [1, 4, -4]]", A.ToString()); + } +} diff --git a/tests/SharpMath.Tests/Geometry/Matrix4x4Tests.cs b/tests/SharpMath.Tests/Geometry/Matrix4x4Tests.cs new file mode 100644 index 0000000..49f1803 --- /dev/null +++ b/tests/SharpMath.Tests/Geometry/Matrix4x4Tests.cs @@ -0,0 +1,165 @@ +using SharpMath.Geometry; + +namespace SharpMath.Tests.Geometry; + +public class Matrix4x4Tests +{ + private static readonly Matrix4x4 A = new(1, 0, 0, 1, 1, 2, 3, 2, 2, 3, 4, 0, 1, 2, -1, -2); + + [Fact] + public void Determinant_UsesClosedForm() + { + Assert.Equal(-24, A.Determinant, 10); + Assert.Equal(1, Matrix4x4.Identity.Determinant); + Assert.Equal(5, A.Trace); + } + + [Fact] + public void Inverse_AdjugateAndCofactors() + { + // Regression: Inverse used to return the identity matrix. + Assert.True((A * A.Inverse()).IsIdentity); + Assert.True((A.Adjugate() * A).ApproximatelyEquals(Matrix4x4.Identity * A.Determinant)); + Assert.Equal(A.Adjugate().Transpose(), A.Cofactors()); + + var singular = new Matrix4x4(1, 2, 3, 4, 2, 4, 6, 8, 0, 1, 0, 1, 1, 0, 1, 0); + Assert.True(singular.IsSingular); + Assert.False(singular.TryInvert(out _)); + Assert.Throws(() => singular.Inverse()); + + var transform = Matrix4x4.Translation(1, 2, 3) * Matrix4x4.Rotation(0.3, 0.5, 0.7) * Matrix4x4.Scaling(2); + Assert.True((transform * transform.Inverse()).IsIdentity); + } + + [Fact] + public void Predicates() + { + Assert.True(Matrix4x4.Identity.IsIdentity); + // Regression: M44 and off-diagonal entries used to be ignored. + Assert.False(new Matrix4x4(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 7).IsIdentity); + Assert.False(Matrix4x4.Translation(1, 0, 0).IsIdentity); + + // Regression: a zero on the diagonal used to make a matrix "not diagonal". + Assert.True(new Matrix4x4(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0).IsDiagonal); + Assert.True(Matrix4x4.Identity.IsDiagonal); + Assert.False(new Matrix4x4(1, 0, 0, 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1).IsDiagonal); + + Assert.True(new Matrix4x4(1, 3, 4, 7, 0, 2, 2, 3, 0, 0, 3, 9, 0, 0, 0, 8).IsTriangular); + Assert.False(new Matrix4x4(5, 0, 0, 2, 0, 7, 0, 0, 9, 0, 4, 0, 0, 0, 0, 3).IsTriangular); + Assert.True(Matrix4x4.Identity.IsTriangular); + + // Regression: rotations used to be reported as not orthogonal because of exact comparisons. + Assert.True(Matrix4x4.Rotation(0.3, 0.5, 0.7).IsOrthogonal); + Assert.False(A.IsOrthogonal); + Assert.True((A + A.Transpose()).IsSymmetric); + Assert.True((A - A.Transpose()).IsSkewSymmetric); + Assert.False(A.IsSkewSymmetric); + } + + [Fact] + public void AffineTransformations_UseColumnVectors() + { + Assert.Equal(new Vector3(2, 3, 4), Matrix4x4.Translation(1, 2, 3) * Vector3.One); + Assert.Equal(new Vector3(2, 3, 4), Matrix4x4.Translation(new Vector3(1, 2, 3)) * Vector3.One); + Assert.Equal(new Vector3(2, 3, 4), Matrix4x4.Scaling(2, 3, 4) * Vector3.One); + Assert.Equal(new Vector3(5, 5, 5), Matrix4x4.Scaling(5) * Vector3.One); + Assert.True((Matrix4x4.RotationX(Math.PI / 2) * Vector3.UnitY).ApproximatelyEquals(Vector3.UnitZ)); + Assert.True((Matrix4x4.RotationY(Math.PI / 2) * Vector3.UnitZ).ApproximatelyEquals(Vector3.UnitX)); + Assert.True((Matrix4x4.RotationZ(Math.PI / 2) * Vector3.UnitX).ApproximatelyEquals(Vector3.UnitY)); + Assert.Equal(Matrix4x4.RotationX(0.1) * Matrix4x4.RotationY(0.2) * Matrix4x4.RotationZ(0.3), + Matrix4x4.Rotation(0.1, 0.2, 0.3)); + + var composed = Matrix4x4.Translation(0, 0, -5) * Matrix4x4.RotationY(Math.PI / 2); + Assert.True((composed * Vector3.UnitX).ApproximatelyEquals(new Vector3(0, 0, -6))); + } + + [Fact] + public void Perspective_IsRightHandedWithDepthZeroToOne() + { + var projection = Matrix4x4.PerspectiveFieldOfView(Math.PI / 2, 1, 1, 10); + + // Regression: M44 used to stay 1 and the layout was row-vector style. + Assert.Equal(0, projection.M44); + Assert.Equal(-1, projection.M43); + + var near = projection * new Vector3(0, 0, -1); + var far = projection * new Vector3(0, 0, -10); + Assert.Equal(0, near.Z, 12); + Assert.Equal(1, far.Z, 12); + Assert.True((projection * new Vector3(1, 1, -1)).ApproximatelyEquals(new Vector3(1, 1, 0))); + + var fromData = Matrix4x4.PerspectiveFieldOfView(new ProjectionData(100, 100, 1, Math.PI / 2, 1, 10)); + Assert.Equal(projection, fromData); + } + + [Theory] + [InlineData(0, 1, 1, 10)] + [InlineData(Math.PI, 1, 1, 10)] + [InlineData(1, 0, 1, 10)] + [InlineData(1, 1, 0, 10)] + [InlineData(1, 1, 10, 10)] + public void Perspective_ValidatesArguments(double fov, double aspect, double near, double far) => + Assert.Throws(() => Matrix4x4.PerspectiveFieldOfView(fov, aspect, near, far)); + + [Fact] + public void LookAt_KeepsTranslationAndIsOrthonormal() + { + // Regression: the translation of the camera used to be lost. + var view = Matrix4x4.LookAt(new Vector3(0, 0, 5), Vector3.Zero, Vector3.Up); + Assert.True((view * Vector3.Zero).ApproximatelyEquals(new Vector3(0, 0, -5))); + + // Regression: the basis used to be scaled by the length of the up vector. + var scaledUp = Matrix4x4.LookAt(new Vector3(1, 2, 3), new Vector3(4, 0, -1), new Vector3(0, 2, 0)); + var rotationPart = new Matrix3x3(scaledUp.M11, scaledUp.M12, scaledUp.M13, scaledUp.M21, scaledUp.M22, + scaledUp.M23, scaledUp.M31, scaledUp.M32, scaledUp.M33); + Assert.True(rotationPart.IsOrthogonal); + Assert.True((scaledUp * new Vector3(1, 2, 3)).ApproximatelyEquals(Vector3.Zero)); + + Assert.Throws(() => Matrix4x4.LookAt(Vector3.One, Vector3.One, Vector3.Up)); + Assert.Throws(() => Matrix4x4.LookAt(Vector3.Zero, Vector3.Up, Vector3.Up)); + Assert.Throws(() => Matrix4x4.LookAt(Vector3.Zero, Vector3.Forward, Vector3.Zero)); + } + + [Fact] + public void World_PlacesAndOrientsObjects() + { + // Regression: the position used to be lost and the basis was not normalized. + var world = Matrix4x4.World(new Vector3(1, 2, 3), new Vector3(0, 0, -6), new Vector3(0, 12, 0)); + Assert.True((world * Vector3.Zero).ApproximatelyEquals(new Vector3(1, 2, 3))); + Assert.True((world * Vector3.Forward).ApproximatelyEquals(new Vector3(1, 2, 2))); + Assert.True(world.ApproximatelyEquals(Matrix4x4.Translation(1, 2, 3))); + + var rotated = Matrix4x4.World(Vector3.Zero, Vector3.Right, Vector3.Up); + Assert.True((rotated * Vector3.Forward).ApproximatelyEquals(Vector3.Right)); + Assert.Throws(() => Matrix4x4.World(Vector3.Zero, Vector3.Zero, Vector3.Up)); + Assert.Throws(() => Matrix4x4.World(Vector3.Zero, Vector3.Up, Vector3.Up)); + } + + [Fact] + public void ElementWiseOperatorsIndexersEqualityAndStrings() + { + Assert.Equal(A * 2, A + A); + Assert.Equal(Matrix4x4.Zero, A - A); + Assert.Equal(A * -1, -A); + Assert.Equal(2 * A, A * 2); + Assert.Equal(A, A * 4 / 4); + Assert.Equal(new Vector4(2, 8, 9, 0), A * Vector4.One); + Assert.Equal(A, Matrix4x4.FromMatrix(MatrixMxN.FromMatrix(A))); + Assert.Throws(() => Matrix4x4.FromMatrix(Matrix3x3.Identity)); + Assert.Equal(-1, A[3, 2]); + Assert.Equal(-2, A[15]); + Assert.Throws(() => A[4, 0]); + Assert.Throws(() => A[16]); + Assert.Equal(4, A.RowCount); + Assert.Equal(4, A.ColumnCount); + Assert.Equal(4, A.Dimension); + Assert.True(A == new Matrix4x4(1, 0, 0, 1, 1, 2, 3, 2, 2, 3, 4, 0, 1, 2, -1, -2)); + Assert.True(A != Matrix4x4.Identity); + Assert.True(A.Equals((object) new Matrix4x4(1, 0, 0, 1, 1, 2, 3, 2, 2, 3, 4, 0, 1, 2, -1, -2))); + Assert.Equal(A.GetHashCode(), new Matrix4x4(1, 0, 0, 1, 1, 2, 3, 2, 2, 3, 4, 0, 1, 2, -1, -2).GetHashCode()); + Assert.True(A.ApproximatelyEquals(A + Matrix4x4.Identity * 1e-12)); + Assert.Equal(new Matrix4x4(new Vector4(1, 0, 0, 1), new Vector4(1, 2, 3, 2), new Vector4(2, 3, 4, 0), + new Vector4(1, 2, -1, -2)), A); + Assert.StartsWith("Matrix4x4 [[1, 0, 0, 1], [1, 2, 3, 2]", A.ToString()); + } +} diff --git a/tests/SharpMath.Tests/Geometry/MatrixMxNTests.cs b/tests/SharpMath.Tests/Geometry/MatrixMxNTests.cs new file mode 100644 index 0000000..c7f54a0 --- /dev/null +++ b/tests/SharpMath.Tests/Geometry/MatrixMxNTests.cs @@ -0,0 +1,126 @@ +using SharpMath.Geometry; + +namespace SharpMath.Tests.Geometry; + +public class MatrixMxNTests +{ + private static readonly MatrixMxN Rectangular = new(new double[,] { { 1, 2, 3 }, { 4, 5, 6 } }); + + [Fact] + public void Constructors_CopyTheirInput() + { + var values = new double[,] { { 1, 2 }, { 3, 4 } }; + var matrix = new MatrixMxN(values); + values[0, 0] = 99; + Assert.Equal(1, matrix[0, 0]); + Assert.Equal(new MatrixMxN(2, 2, [1, 2, 3, 4]), matrix); + Assert.Equal(2, Rectangular.RowCount); + Assert.Equal(3, Rectangular.ColumnCount); + Assert.False(Rectangular.IsSquare); + Assert.Equal(6, Rectangular[1, 2]); + Assert.Throws(() => Rectangular[2, 0]); + Assert.Throws(() => Rectangular[0, 3]); + Assert.Throws(() => new MatrixMxN(new double[0, 2])); + Assert.Throws(() => new MatrixMxN(2, 2, [1, 2, 3])); + Assert.Throws(() => new MatrixMxN(0, 2, [])); + Assert.Throws(() => MatrixMxN.Zero(1, 0)); + Assert.Throws(() => new MatrixMxN(null!)); + } + + [Fact] + public void Factories_AndConversions() + { + Assert.Equal(new MatrixMxN(2, 2, [1, 0, 0, 1]), MatrixMxN.Identity(2)); + Assert.Equal(new MatrixMxN(1, 2, [0, 0]), MatrixMxN.Zero(1, 2)); + Assert.Equal(MatrixMxN.Identity(4), MatrixMxN.FromMatrix(Matrix4x4.Identity)); + Assert.Equal(new double[,] { { 1, 2, 3 }, { 4, 5, 6 } }, Rectangular.ToArray()); + Assert.Equal(new MatrixMxN(new double[,] { { 1, 4 }, { 2, 5 }, { 3, 6 } }), Rectangular.Transpose()); + } + + [Fact] + public void Arithmetic() + { + Assert.Equal(Rectangular * 2, Rectangular + Rectangular); + Assert.Equal(MatrixMxN.Zero(2, 3), Rectangular - Rectangular); + Assert.Equal(Rectangular * -1, -Rectangular); + Assert.Equal(Rectangular * 2, 2 * Rectangular); + Assert.Equal(Rectangular, Rectangular * 2 / 2); + Assert.Equal(new MatrixMxN(new double[,] { { 14, 32 }, { 32, 77 } }), Rectangular * Rectangular.Transpose()); + Assert.Throws(() => Rectangular * Rectangular); + Assert.Throws(() => Rectangular + MatrixMxN.Identity(2)); + } + + [Fact] + public void DeterminantInverseAndSolve() + { + var matrix = MatrixMxN.FromMatrix(new Matrix4x4(1, 0, 0, 1, 1, 2, 3, 2, 2, 3, 4, 0, 1, 2, -1, -2)); + Assert.Equal(-24, matrix.Determinant(), 10); + Assert.True((matrix * matrix.Inverse()).ApproximatelyEquals(MatrixMxN.Identity(4))); + + var system = new MatrixMxN(new double[,] { { 1, -1, 2 }, { 2, 3, 2 }, { 3, 2, 1 } }); + var solution = system.Solve([6, 11, 8]); + Assert.Equal(1, solution[0], 12); + Assert.Equal(1, solution[1], 12); + Assert.Equal(3, solution[2], 12); + + var multiple = system.Solve(new MatrixMxN(new double[,] { { 6, 12 }, { 11, 22 }, { 8, 16 } })); + Assert.True(multiple.ApproximatelyEquals(new MatrixMxN(new double[,] { { 1, 2 }, { 1, 2 }, { 3, 6 } }))); + + Assert.Throws(() => system.Solve([1, 2])); + Assert.Throws(() => system.Solve(MatrixMxN.Zero(2, 1))); + Assert.Throws(() => Rectangular.Determinant()); + Assert.Throws(() => Rectangular.Inverse()); + } + + [Fact] + public void SingularMatrices() + { + var singular = new MatrixMxN(new double[,] { { 1, 2 }, { 2, 4 } }); + Assert.Equal(0, singular.Determinant()); + Assert.False(singular.TryInvert(out var inverse)); + Assert.Null(inverse); + Assert.Throws(() => singular.Inverse()); + Assert.Throws(() => singular.Solve([1, 2])); + Assert.Throws(() => singular.Solve(MatrixMxN.Identity(2))); + Assert.Equal(0, MatrixMxN.Zero(2, 2).Determinant()); + } + + [Fact] + public void Solve_HandlesSmallButRegularScales() + { + // Regression: an absolute pivot threshold rejected well-conditioned systems at small scales. + var tiny = new MatrixMxN(1, 1, [1e-10]); + Assert.Equal(1, tiny.Solve([1e-10])[0], 12); + + // Partial pivoting keeps precision with a tiny leading entry. + var pivoting = new MatrixMxN(2, 2, [2e-9, 1, 1, 1]); + var solution = pivoting.Solve([1, 2]); + Assert.Equal(1 / (1 - 2e-9), solution[0], 12); + } + + [Fact] + public void Rank() + { + Assert.Equal(2, Rectangular.Rank()); + Assert.Equal(1, new MatrixMxN(new double[,] { { 1, 2 }, { 2, 4 } }).Rank()); + Assert.Equal(0, MatrixMxN.Zero(3, 3).Rank()); + Assert.Equal(3, MatrixMxN.Identity(3).Rank()); + } + + [Fact] + public void EqualityAndStrings() + { + var copy = new MatrixMxN(Rectangular.ToArray()); + Assert.True(Rectangular == copy); + Assert.False(Rectangular != copy); + Assert.True(Rectangular.Equals((object) copy)); + Assert.False(Rectangular.Equals(null)); + Assert.False(Rectangular == null); + Assert.True((MatrixMxN?) null == null); + Assert.False(Rectangular.Equals(Rectangular.Transpose())); + Assert.Equal(Rectangular.GetHashCode(), copy.GetHashCode()); + Assert.True(Rectangular.ApproximatelyEquals(Rectangular + MatrixMxN.Zero(2, 3))); + Assert.False(Rectangular.ApproximatelyEquals(Rectangular.Transpose())); + Assert.Equal("MatrixMxN [[1, 2, 3], [4, 5, 6]]", Rectangular.ToString()); + } +} diff --git a/tests/SharpMath.Tests/Geometry/PointTests.cs b/tests/SharpMath.Tests/Geometry/PointTests.cs new file mode 100644 index 0000000..216a6ca --- /dev/null +++ b/tests/SharpMath.Tests/Geometry/PointTests.cs @@ -0,0 +1,69 @@ +using SharpMath.Geometry; + +namespace SharpMath.Tests.Geometry; + +public class PointTests +{ + [Fact] + public void Point2D_FollowsAffineRules() + { + var p = new Point2D(1, 2); + var q = new Point2D(4, 6); + Assert.Equal(new Vector2(3, 4), q - p); + Assert.Equal(q, p + new Vector2(3, 4)); + Assert.Equal(p, q - new Vector2(3, 4)); + Assert.Equal(5, p.Distance(q)); + Assert.Equal(5, Point2D.Distance(p, q)); + Assert.Equal(25, p.DistanceSquared(q)); + Assert.Equal(25, Point2D.DistanceSquared(p, q)); + Assert.Equal(new Vector2(1, 2), p.ToVector()); + Assert.Equal(Point2D.Origin, new Point2D(0, 0)); + Assert.Equal(1, p[0]); + Assert.Equal(2, p[1]); + Assert.Throws(() => p[2]); + } + + [Fact] + public void Point2D_EqualityAndStrings() + { + var p = new Point2D(1, 2); + Assert.True(p == new Point2D(1, 2)); + Assert.True(p != new Point2D(1, 3)); + + // Like System.Numerics: == follows IEEE (NaN differs from itself), Equals treats NaN as equal. + var nan = new Point2D(double.NaN, 0); + var otherNan = new Point2D(double.NaN, 0); + Assert.False(nan == otherNan); + Assert.True(nan.Equals(otherNan)); + Assert.True(p.Equals((object) new Point2D(1, 2))); + Assert.False(p.Equals((object) new Vector2(1, 2))); + Assert.Equal(p.GetHashCode(), new Point2D(1, 2).GetHashCode()); + Assert.True(new Point2D(0.1 + 0.2, 0).ApproximatelyEquals(new Point2D(0.3, 0))); + Assert.True(p.ApproximatelyEquals(new Point2D(1.05, 2), 0.1, 0)); + Assert.Equal("Point2D [X: 1, Y: 2]", p.ToString()); + } + + [Fact] + public void Point3D_FollowsAffineRules() + { + var p = new Point3D(1, 2, 3); + var q = new Point3D(2, 4, 5); + Assert.Equal(new Vector3(1, 2, 2), q - p); + Assert.Equal(q, p + new Vector3(1, 2, 2)); + Assert.Equal(p, q - new Vector3(1, 2, 2)); + Assert.Equal(3, p.Distance(q)); + Assert.Equal(3, Point3D.Distance(p, q)); + Assert.Equal(9, p.DistanceSquared(q)); + Assert.Equal(9, Point3D.DistanceSquared(p, q)); + Assert.Equal(new Vector3(1, 2, 3), p.ToVector()); + Assert.Equal(Point3D.Origin, new Point3D(0, 0, 0)); + Assert.Equal(3, p[2]); + Assert.Throws(() => p[3]); + Assert.True(p == new Point3D(1, 2, 3)); + Assert.True(p != new Point3D(1, 2, 4)); + Assert.True(p.Equals((object) new Point3D(1, 2, 3))); + Assert.Equal(p.GetHashCode(), new Point3D(1, 2, 3).GetHashCode()); + Assert.True(p.ApproximatelyEquals(new Point3D(1, 2, 3 + 1e-12))); + Assert.Equal("Point3D [X: 1, Y: 2, Z: 3]", p.ToString()); + } +} diff --git a/tests/SharpMath.Tests/Geometry/PolygonTests.cs b/tests/SharpMath.Tests/Geometry/PolygonTests.cs new file mode 100644 index 0000000..c5a5d4a --- /dev/null +++ b/tests/SharpMath.Tests/Geometry/PolygonTests.cs @@ -0,0 +1,113 @@ +using SharpMath.Geometry; + +namespace SharpMath.Tests.Geometry; + +public class PolygonTests +{ + private static readonly Polygon Square = new(new Point2D(0, 0), new Point2D(2, 0), new Point2D(2, 2), new Point2D(0, 2)); + + private static readonly Polygon Pentagon = new(new Point2D(-2, -2), new Point2D(2, -2), new Point2D(4, 1), + new Point2D(0, 2), new Point2D(-4, 1)); + + [Fact] + public void Constructor_ValidatesAndCopies() + { + Assert.Throws(() => new Polygon(new Point2D(0, 1), new Point2D(3, 4))); + Assert.Throws(() => new Polygon((IEnumerable) null!)); + var points = new List { new(0, 0), new(1, 0), new(0, 1) }; + var polygon = new Polygon(points); + points.Add(new Point2D(5, 5)); + Assert.Equal(3, polygon.Points.Count); + } + + [Fact] + public void Area_UsesShoelaceFormula() + { + Assert.Equal(4, Square.Area); + Assert.Equal(2, new Polygon(new Point2D(0, 0), new Point2D(2, 0), new Point2D(1, 2)).Area); + Assert.Equal(22, Pentagon.Area); + + // Regression: polygons that do not contain the origin used to get wrong areas. + Assert.Equal(1, new Polygon(new Point2D(1, 1), new Point2D(2, 1), new Point2D(2, 2), new Point2D(1, 2)).Area); + Assert.Equal(0.5, new Polygon(new Point2D(5, 5), new Point2D(6, 5), new Point2D(5, 6)).Area); + var concave = new Polygon(new Point2D(0, 0), new Point2D(4, 0), new Point2D(4, 4), new Point2D(2, 1), + new Point2D(0, 4)); + Assert.Equal(10, concave.Area); + + Assert.Equal(4, Square.SignedArea); + Assert.Equal(-4, new Polygon(Square.Points.Reverse()).SignedArea); + } + + [Fact] + public void Perimeter() + { + Assert.Equal(8, Square.Perimeter); + Assert.Equal(4 + 2 * Math.Sqrt(13) + 2 * Math.Sqrt(17), Pentagon.Perimeter, 12); + } + + [Fact] + public void Centroid_IsCenterOfMass() + { + // Regression: the centroid of this square used to be (16, 16). + Assert.True(Square.Centroid.ApproximatelyEquals(new Point2D(1, 1))); + Assert.True(new Polygon(new Point2D(0, 0), new Point2D(3, 0), new Point2D(0, 3)).Centroid + .ApproximatelyEquals(new Point2D(1, 1))); + Assert.Equal(new Point2D(1, 1), new Polygon(new Point2D(0, 0), new Point2D(1, 1), new Point2D(2, 2)).Centroid); + } + + [Fact] + public void BoundingBox_DoesNotIncludeOrigin() + { + // Regression: the box used to always include the origin. + var polygon = new Polygon(new Point2D(1, 1), new Point2D(3, 1), new Point2D(2, 4)); + Assert.Equal(new BoundingBox2D(1, 1, 3, 4), polygon.BoundingBox); + } + + [Fact] + public void Contains_Points() + { + Assert.True(Square.Contains(new Point2D(1, 1))); + Assert.True(Square.Contains(new Point2D(2, 1))); + Assert.True(Square.Contains(new Point2D(0, 0))); + Assert.False(Square.Contains(new Point2D(3, 1))); + Assert.False(Square.Contains(new Point2D(-0.1, 1))); + var concave = new Polygon(new Point2D(0, 0), new Point2D(4, 0), new Point2D(4, 4), new Point2D(2, 1), + new Point2D(0, 4)); + Assert.False(concave.Contains(new Point2D(2, 3))); + Assert.True(concave.Contains(new Point2D(1, 1))); + } + + [Fact] + public void Contains_Polygons() + { + var inner = new Polygon(new Point2D(0.5, 0.5), new Point2D(1.5, 0.5), new Point2D(1, 1.5)); + Assert.True(Square.Contains(inner)); + Assert.True(Square.Contains(Square)); + Assert.False(inner.Contains(Square)); + + var concave = new Polygon(new Point2D(0, 0), new Point2D(4, 0), new Point2D(4, 4), new Point2D(2, 1), + new Point2D(0, 4)); + var bridge = new Polygon(new Point2D(0.5, 3), new Point2D(3.5, 3), new Point2D(3.5, 3.2), new Point2D(0.5, 3.2)); + Assert.False(concave.Contains(bridge)); + Assert.Throws(() => Square.Contains((Polygon) null!)); + } + + [Fact] + public void Equality_DoesNotRecurse() + { + // Regression: comparing two distinct polygons with == used to overflow the stack. + var other = new Polygon(new Point2D(0, 0), new Point2D(1, 0), new Point2D(0, 1)); + Assert.False(Square == other); + Assert.True(Square != other); + Assert.True(Square == new Polygon(Square.Points)); + Assert.False(Square == null); + Assert.False(null == Square); + Assert.True((Polygon?) null == null); + Assert.True(Square.Equals((object) new Polygon(Square.Points))); + Assert.False(Square.Equals((object?) null)); + + // Regression: equal polygons used to have different hash codes. + Assert.Equal(Square.GetHashCode(), new Polygon(Square.Points).GetHashCode()); + Assert.Equal("Polygon [(0, 0), (2, 0), (2, 2), (0, 2)]", Square.ToString()); + } +} diff --git a/tests/SharpMath.Tests/Geometry/ProjectionDataTests.cs b/tests/SharpMath.Tests/Geometry/ProjectionDataTests.cs new file mode 100644 index 0000000..26c2271 --- /dev/null +++ b/tests/SharpMath.Tests/Geometry/ProjectionDataTests.cs @@ -0,0 +1,41 @@ +using SharpMath.Geometry; + +namespace SharpMath.Tests.Geometry; + +public class ProjectionDataTests +{ + [Fact] + public void Default_HasDocumentedValues() + { + var data = ProjectionData.Default; + Assert.Equal(100, data.Width); + Assert.Equal(100, data.Height); + Assert.Equal(16.0 / 9.0, data.AspectRatio); + Assert.Equal(Math.PI / 3, data.FieldOfView); + Assert.Equal(1, data.NearPlane); + Assert.Equal(100, data.FarPlane); + Assert.Equal(99, data.Depth); + } + + [Theory] + [InlineData(-1, 1, 1, 1, 1, 2)] + [InlineData(1, double.NaN, 1, 1, 1, 2)] + [InlineData(1, 1, 0, 1, 1, 2)] + [InlineData(1, 1, double.PositiveInfinity, 1, 1, 2)] + [InlineData(1, 1, 1, 0, 1, 2)] + [InlineData(1, 1, 1, 4, 1, 2)] + [InlineData(1, 1, 1, 1, -1, 2)] + [InlineData(1, 1, 1, 1, 2, 1)] + public void Constructor_ValidatesArguments(double width, double height, double aspect, double fov, double near, + double far) => + Assert.Throws(() => new ProjectionData(width, height, aspect, fov, near, far)); + + [Fact] + public void Equality() + { + Assert.True(ProjectionData.Default == ProjectionData.Default); + Assert.True(ProjectionData.Default != new ProjectionData(1, 1, 1, 1, 1, 2)); + Assert.True(ProjectionData.Default.Equals((object) ProjectionData.Default)); + Assert.Equal(ProjectionData.Default.GetHashCode(), ProjectionData.Default.GetHashCode()); + } +} diff --git a/tests/SharpMath.Tests/Geometry/ReviewRegressionTests.cs b/tests/SharpMath.Tests/Geometry/ReviewRegressionTests.cs new file mode 100644 index 0000000..c65333d --- /dev/null +++ b/tests/SharpMath.Tests/Geometry/ReviewRegressionTests.cs @@ -0,0 +1,172 @@ +using SharpMath.Geometry; + +namespace SharpMath.Tests.Geometry; + +/// +/// Regression tests for numerical problems found in the review of 2.0: scale dependence, cancellation and +/// degenerate inputs. +/// +public class ReviewRegressionTests +{ + [Fact] + public void Transformations_WithLargeTranslationsOrSmallScales_AreInvertible() + { + Matrix4x4[] transformations = + [ + Matrix4x4.Translation(178, 0, 0), + Matrix4x4.Translation(1e6, -2e6, 3e6), + Matrix4x4.LookAt(new Vector3(1e4, 2e3, -5e3), Vector3.Zero, Vector3.Up), + Matrix4x4.Scaling(0.001), + Matrix4x4.Translation(500, 0, 0) * Matrix4x4.Rotation(0.3, 0.2, 0.1) * Matrix4x4.Scaling(1e-3) + ]; + foreach (var transformation in transformations) + { + Assert.False(transformation.IsSingular); + Assert.True(transformation.TryInvert(out var inverse)); + Assert.True((transformation * inverse).ApproximatelyEquals(Matrix4x4.Identity, 1e-9, 1e-9)); + } + + Assert.False(Matrix3x3.Translation(1000, 0).IsSingular); + Assert.Equal(new Vector2(0, 5), Matrix3x3.Translation(1000, 0).Inverse() * new Vector2(1000, 5)); + var shear = new Matrix2x2(1, 1e5, 0, 1); + Assert.False(shear.IsSingular); + Assert.Equal(new Matrix2x2(1, -1e5, 0, 1), shear.Inverse()); + } + + [Fact] + public void SingularMatrices_AreStillDetected() + { + Assert.True(new Matrix4x4(1, 2, 3, 4, 2, 4, 6, 8, 1, 1, 1, 1, 0, 1, 0, 1).IsSingular); + Assert.True(new Matrix3x3(1, 2, 3, 4, 5, 6, 7, 8, 9).IsSingular); + Assert.True(new Matrix4x4(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0).IsSingular); + Assert.True(new Matrix2x2(double.NaN, 0, 0, 1).IsSingular); + } + + [Fact] + public void StructuralPredicates_DoNotDependOnTheScale() + { + Assert.False(new Matrix2x2(1e-15, 1e-15, 1e-15, 1e-15).IsDiagonal); + Assert.True(new Matrix2x2(1e-15, 0, 0, 2e-15).IsDiagonal); + Assert.False(new Matrix2x2(1e-15, 2e-15, 0, 1e-15).IsSymmetric); + Assert.True(new Matrix3x3(1e12, 1, 0, 0, 1e12, 0, 0, 0, 1e12).IsDiagonal); + } + + [Fact] + public void MatrixMxN_Determinant_IsTheProductOfPivots() + { + var illConditioned = new MatrixMxN(2, 2, [1, 0, 0, 1e-17]); + Assert.Equal(1e-17, illConditioned.Determinant()); + Assert.False(illConditioned.TryInvert(out _)); + Assert.Equal(0, new MatrixMxN(2, 2, [1, 2, 2, 4]).Determinant()); + Assert.Equal(-2, new MatrixMxN(2, 2, [0, 1, 2, 0]).Determinant()); + } + + [Fact] + public void VectorPredicates_DoNotDependOnTheScale() + { + var tinyX = new Vector2(1e-6, 0); + var tinyY = new Vector2(0, 1e-6); + Assert.False(tinyX.IsParallelTo(tinyY)); + Assert.True(tinyX.IsOrthogonalTo(tinyY)); + Assert.False(tinyX.IsOrthogonalTo(tinyX)); + Assert.True(tinyX.IsParallelTo(tinyX * -3)); + Assert.False(new Vector3(1e-7, 0, 0).IsParallelTo(new Vector3(1e-7, 1e-9, 0))); + Assert.False(new Vector4(1e-7, 0, 0, 0).IsParallelTo(new Vector4(0, 1e-7, 0, 0))); + Assert.True(new Vector3(1e200, 1e200, 0).IsParallelTo(new Vector3(2e200, 2e200, 0))); + } + + [Fact] + public void Angle_HandlesTinyAndHugeVectors() + { + Assert.Equal(0, new Vector2(1e-200, 0).Angle(new Vector2(3e-200, 0))); + Assert.Equal(0, new Vector3(1e100, 1e100, 0).Angle(new Vector3(2e100, 2e100, 0)), 12); + Assert.Equal(Math.PI / 2, new Vector4(1e300, 0, 0, 0).Angle(new Vector4(0, 0, 0, 1e300)), 12); + Assert.True(Tolerance.AreEqual(1e-12, new Vector3(1, 0, 0).Angle(new Vector3(1, 1e-12, 0)), 0, 1e-6)); + Assert.Throws(() => new Vector4(0, 0, 0, 0).Angle(Vector4.UnitX)); + } + + [Fact] + public void NearlyParallel3DLines_HaveFiniteClosestPoints() + { + // Regression: 1 − (d·e)² cancelled to 0 for angles around 1e-8 rad, giving NaN. + var first = Line3D.FromPointAndDirection(Point3D.Origin, Vector3.UnitX); + var second = Line3D.FromPointAndDirection(Point3D.Origin, new Vector3(1, 5e-9, 0)); + var (onFirst, onSecond) = first.ClosestPoints(second); + Assert.True(double.IsFinite(onFirst.X) && double.IsFinite(onSecond.Y)); + Assert.Equal(0, first.DistanceTo(second), 9); + Assert.Equal(IntersectionKind.Point, first.Intersect(second).Kind); + + var offset = Line3D.FromPointAndDirection(new Point3D(0, 0, 1), new Vector3(1, 1e-8, 0)); + Assert.Equal(1, first.DistanceTo(offset), 6); + Assert.Equal(IntersectionKind.Skew, first.Intersect(offset).Kind); + } + + [Fact] + public void TinySegments_BehaveLikeLargeOnes() + { + // Regression: disjoint segments of length 1e-6 were reported to intersect. + var horizontal = new LineSegment2D(new Point2D(0, 0), new Point2D(1e-6, 0)); + var vertical = new LineSegment2D(new Point2D(5e-7, 1e-7), new Point2D(5e-7, 2e-7)); + Assert.Equal(IntersectionKind.None, horizontal.Intersect(vertical).Kind); + var crossing = new LineSegment2D(new Point2D(5e-7, -1e-7), new Point2D(5e-7, 1e-7)); + Assert.True(horizontal.Intersect(crossing).Point!.Value.ApproximatelyEquals(new Point2D(5e-7, 0), 1e-15, 1e-9)); + + // Regression: segments shorter than about 1e-12 threw from ToLine and Intersect(Line2D). + var minute = new LineSegment2D(new Point2D(0, 0), new Point2D(1e-13, 1e-13)); + Assert.True(minute.ToLine().IsParallelTo(Line2D.FromSlopeIntercept(1, 0))); + Assert.Equal(IntersectionKind.Overlap, minute.Intersect(Line2D.FromSlopeIntercept(1, 0)).Kind); + } + + [Fact] + public void PolygonContainment_ChecksEveryPieceOfAnEdge() + { + // A U shape; the edge from (1, 1.5) to (4, 6) leaves through the notch corner (2, 3) and ends at the corner (4, 6). + var u = new Polygon(new Point2D(0, 0), new Point2D(6, 0), new Point2D(6, 6), new Point2D(4, 6), new Point2D(4, 3), + new Point2D(2, 3), new Point2D(2, 6), new Point2D(0, 6)); + Assert.False(u.Contains(new Polygon(new Point2D(1, 1.5), new Point2D(4, 6), new Point2D(5, 1)))); + + // Touching the border from inside, along the bottom of the notch, is allowed. + Assert.True(u.Contains(new Polygon(new Point2D(1, 1), new Point2D(5, 1), new Point2D(5, 3), new Point2D(1, 3)))); + Assert.True(u.Contains(u)); + } + + [Fact] + public void ApproximateEqualityOverloads() + { + Assert.True(Line2D.FromSlopeIntercept(1, 0).ApproximatelyEquals(Line2D.FromSlopeIntercept(1.001, 0), 0.01, 0)); + Assert.True(Line3D.FromPointAndDirection(Point3D.Origin, Vector3.UnitX) + .ApproximatelyEquals(Line3D.FromPointAndDirection(new Point3D(0, 0.001, 0), Vector3.UnitX), 0.01, 0)); + var segment = new LineSegment2D(Point2D.Origin, new Point2D(1, 1)); + Assert.True(segment.ApproximatelyEquals(new LineSegment2D(Point2D.Origin, new Point2D(1.001, 1)), 0.01, 0)); + Assert.False(segment.ApproximatelyEquals(new LineSegment2D(Point2D.Origin, new Point2D(1.001, 1)))); + var box = new BoundingBox2D(0, 0, 1, 1); + Assert.True(box.ApproximatelyEquals(new BoundingBox2D(0, 0, 1 + 1e-12, 1))); + Assert.True(box.ApproximatelyEquals(new BoundingBox2D(0, 0, 1.05, 1), 0.1, 0)); + var triangle = new Polygon(Point2D.Origin, new Point2D(1, 0), new Point2D(0, 1)); + Assert.True(triangle.ApproximatelyEquals(new Polygon(Point2D.Origin, new Point2D(1 + 1e-12, 0), new Point2D(0, 1)))); + Assert.False(triangle.ApproximatelyEquals(new Polygon(Point2D.Origin, new Point2D(1, 0), new Point2D(0, 1), new Point2D(-1, 0)))); + Assert.True(triangle.ApproximatelyEquals(new Polygon(Point2D.Origin, new Point2D(1.05, 0), new Point2D(0, 1)), 0.1, 0)); + Assert.Throws(() => triangle.ApproximatelyEquals(null!)); + } + + [Fact] + public void EqualityOperators_FollowIeee_WhileEqualsTreatsNaNAsEqual() + { + var nanLine = Line2D.FromGeneralForm(1, 0, 0) with { }; + Assert.True(nanLine == Line2D.FromGeneralForm(1, 0, 0)); + var nanBox = new BoundingBox2D(0, 0, 1, 1); + Assert.True(nanBox == new BoundingBox2D(0, 0, 1, 1)); + var nanMatrix = new MatrixMxN(1, 1, [double.NaN]); + var otherNanMatrix = new MatrixMxN(1, 1, [double.NaN]); + Assert.False(nanMatrix == otherNanMatrix); + Assert.True(nanMatrix.Equals(otherNanMatrix)); + var nanPolygon = new Polygon(new Point2D(double.NaN, 0), new Point2D(1, 0), new Point2D(0, 1)); + var otherNanPolygon = new Polygon(new Point2D(double.NaN, 0), new Point2D(1, 0), new Point2D(0, 1)); + Assert.False(nanPolygon == otherNanPolygon); + Assert.True(nanPolygon.Equals(otherNanPolygon)); + Assert.False(nanPolygon == new Polygon(new Point2D(double.NaN, 0), new Point2D(1, 0), new Point2D(0, 1), new Point2D(1, 1))); + var segment = new LineSegment2D(new Point2D(double.NaN, 0), Point2D.Origin); + Assert.False(segment == new LineSegment2D(new Point2D(double.NaN, 0), Point2D.Origin)); + Assert.True(ProjectionData.Default == ProjectionData.Default); + } +} diff --git a/tests/SharpMath.Tests/Geometry/Vector2Tests.cs b/tests/SharpMath.Tests/Geometry/Vector2Tests.cs new file mode 100644 index 0000000..d48fe2a --- /dev/null +++ b/tests/SharpMath.Tests/Geometry/Vector2Tests.cs @@ -0,0 +1,197 @@ +using SharpMath.Geometry; + +namespace SharpMath.Tests.Geometry; + +public class Vector2Tests +{ + [Fact] + public void Constructors_SetComponents() + { + var vector = new Vector2(3, 4); + Assert.Equal(3, vector.X); + Assert.Equal(4, vector.Y); + Assert.Equal(new Vector2(1, 2), new Vector2(new Point2D(1, 2))); + Assert.Equal(2, vector.Dimension); + } + + [Fact] + public void StaticVectors_HaveExpectedValues() + { + Assert.Equal(new Vector2(0, 0), Vector2.Zero); + Assert.Equal(new Vector2(1, 1), Vector2.One); + Assert.Equal(new Vector2(1, 0), Vector2.UnitX); + Assert.Equal(new Vector2(0, 1), Vector2.UnitY); + Assert.Equal(new Vector2(0, 1), Vector2.Up); + Assert.Equal(new Vector2(0, -1), Vector2.Down); + Assert.Equal(new Vector2(-1, 0), Vector2.Left); + Assert.Equal(new Vector2(1, 0), Vector2.Right); + } + + [Fact] + public void Indexer_ReturnsComponentsAndValidatesIndex() + { + var vector = new Vector2(10, 5); + Assert.Equal(10, vector[0]); + Assert.Equal(5, vector[1]); + Assert.Throws(() => vector[2]); + Assert.Throws(() => vector[-1]); + } + + [Fact] + public void Length_IsEuclidean() + { + var vector = new Vector2(3, 4); + Assert.Equal(5, vector.Length); + Assert.Equal(25, vector.LengthSquared); + } + + [Fact] + public void IsZero_AndIsNormalized_UseTolerance() + { + Assert.True(new Vector2(1e-13, -1e-13).IsZero); + Assert.False(new Vector2(1e-3, 0).IsZero); + Assert.True(new Vector2(0.6, 0.8).IsNormalized); + Assert.False(new Vector2(1, 1).IsNormalized); + } + + [Fact] + public void Normalize_ReturnsUnitVector() + { + Assert.True(new Vector2(3, 4).Normalize().ApproximatelyEquals(new Vector2(0.6, 0.8))); + Assert.True(new Vector2(1e-200, 0).Normalize().ApproximatelyEquals(Vector2.UnitX)); + Assert.True(new Vector2(1e200, 1e200).Normalize().IsNormalized); + } + + [Fact] + public void Normalize_ThrowsForZeroVector() => + Assert.Throws(() => Vector2.Zero.Normalize()); + + [Fact] + public void DotAndCross_AreComputed() + { + Assert.Equal(0, Vector2.UnitX.Dot(Vector2.UnitY)); + Assert.Equal(20, Vector2.Dot(new Vector2(2, 4), new Vector2(4, 3))); + Assert.Equal(1, Vector2.UnitX.Cross(Vector2.UnitY)); + Assert.Equal(-10, Vector2.Cross(new Vector2(2, 4), new Vector2(3, 1))); + } + + [Fact] + public void Angle_IsComputedAndClamped() + { + Assert.Equal(Math.PI / 2, Vector2.UnitX.Angle(Vector2.UnitY), 12); + Assert.Equal(Math.PI, Vector2.Angle(Vector2.UnitX, -Vector2.UnitX), 12); + + // Regression: parallel vectors used to produce NaN because the cosine slightly exceeded 1. + var vector = new Vector2(0.1, 0.7); + Assert.Equal(0, vector.Angle(vector * 3), 6); + } + + [Fact] + public void Angle_ThrowsForZeroVector() => + Assert.Throws(() => Vector2.Zero.Angle(Vector2.UnitX)); + + [Fact] + public void Distance_IsComputed() + { + Assert.Equal(5, new Vector2(1, 1).Distance(new Vector2(4, 5))); + Assert.Equal(5, Vector2.Distance(new Vector2(1, 1), new Vector2(4, 5))); + Assert.Equal(25, new Vector2(1, 1).DistanceSquared(new Vector2(4, 5))); + Assert.Equal(25, Vector2.DistanceSquared(new Vector2(1, 1), new Vector2(4, 5))); + } + + [Fact] + public void ParallelogramArea_IsAbsoluteCross() => + Assert.Equal(10, new Vector2(2, 4).ParallelogramArea(new Vector2(3, 1))); + + [Fact] + public void Perpendicular_RotatesCounterclockwise() => + Assert.Equal(new Vector2(-2, 1), new Vector2(1, 2).Perpendicular); + + [Fact] + public void Lerp_ClampsAndLerpUnclamped_Extrapolates() + { + var a = new Vector2(0, 0); + var b = new Vector2(10, 20); + Assert.Equal(new Vector2(5, 10), Vector2.Lerp(a, b, 0.5)); + Assert.Equal(b, Vector2.Lerp(a, b, 2)); + Assert.Equal(a, Vector2.Lerp(a, b, -1)); + Assert.Equal(new Vector2(20, 40), Vector2.LerpUnclamped(a, b, 2)); + } + + [Fact] + public void MoveTowards_ClampsAtTargetAndHandlesEqualPoints() + { + var source = new Vector2(0, 0); + var target = new Vector2(3, 4); + Assert.Equal(new Vector2(0.6, 0.8), Vector2.MoveTowards(source, target, 1)); + Assert.Equal(target, Vector2.MoveTowards(source, target, 10)); + + // Regression: equal points produced NaN. + Assert.Equal(target, Vector2.MoveTowards(target, target, 1)); + } + + [Fact] + public void ParallelismAndOrthogonality_DoNotDivideByComponents() + { + // Regression: zero components produced NaN and "not parallel". + Assert.True(new Vector2(1, 0).IsParallelTo(new Vector2(2, 0))); + Assert.True(new Vector2(1, 0).IsParallelTo(new Vector2(-4, 0))); + Assert.False(new Vector2(1, 0).IsParallelTo(new Vector2(1, 1))); + Assert.True(Vector2.Zero.IsParallelTo(Vector2.UnitX)); + + Assert.True(Vector2.UnitX.IsOrthogonalTo(Vector2.UnitY)); + Assert.False(Vector2.UnitX.IsOrthogonalTo(Vector2.One)); + Assert.True(Vector2.Zero.IsOrthogonalTo(Vector2.UnitX)); + + Assert.True(Vector2.UnitX.IsOrthonormalTo(Vector2.UnitY)); + Assert.False(Vector2.UnitX.IsOrthonormalTo(new Vector2(0, 2))); + } + + [Fact] + public void Transform_UsesColumnVectors() + { + Assert.True(Vector2.Transform(Vector2.UnitX, Matrix2x2.Rotation(Math.PI / 2)).ApproximatelyEquals(Vector2.UnitY)); + + // Regression (issue #6): translations are applied in homogeneous coordinates. + Assert.Equal(new Vector2(11, 11), Vector2.Transform(Vector2.One, Matrix3x3.Translation(10, 10))); + } + + [Fact] + public void Operators_ComputeComponentWise() + { + var a = new Vector2(1, 2); + var b = new Vector2(3, 5); + Assert.Equal(new Vector2(4, 7), a + b); + Assert.Equal(new Vector2(-2, -3), a - b); + Assert.Equal(new Vector2(-1, -2), -a); + Assert.Equal(new Vector2(2, 4), a * 2); + Assert.Equal(new Vector2(2, 4), 2 * a); + Assert.Equal(new Vector2(0.5, 1), a / 2); + } + + [Fact] + public void Equality_IsExactAndApproximatelyEquals_IsTolerant() + { + var a = new Vector2(0.1 + 0.2, 0); + var b = new Vector2(0.3, 0); + Assert.False(a == b); + Assert.True(a != b); + Assert.False(a.Equals(b)); + Assert.True(a.ApproximatelyEquals(b)); + Assert.False(a.ApproximatelyEquals(new Vector2(0.31, 0))); + Assert.True(a.ApproximatelyEquals(new Vector2(0.31, 0), 0.1, 0)); + + Assert.True(new Vector2(1, 2).Equals((object) new Vector2(1, 2))); + Assert.False(new Vector2(1, 2).Equals((object) new Vector3(1, 2, 0))); + Assert.Equal(new Vector2(1, 2).GetHashCode(), new Vector2(1, 2).GetHashCode()); + Assert.Equal(new Vector2(0.0, 0).GetHashCode(), new Vector2(-0.0, 0).GetHashCode()); + } + + [Fact] + public void Conversions_AndStrings() + { + Assert.Equal(new Point2D(1, 2), new Vector2(1, 2).ToPoint()); + Assert.Equal("Vector2 {X: 1.5, Y: -2}", new Vector2(1.5, -2).ToString()); + Assert.Equal(@"\left( \begin{array}{c} 1 \\ 6 \end{array} \right)", new Vector2(1, 6).ToLaTeXString()); + } +} diff --git a/tests/SharpMath.Tests/Geometry/Vector3Tests.cs b/tests/SharpMath.Tests/Geometry/Vector3Tests.cs new file mode 100644 index 0000000..e0688e6 --- /dev/null +++ b/tests/SharpMath.Tests/Geometry/Vector3Tests.cs @@ -0,0 +1,136 @@ +using SharpMath.Geometry; + +namespace SharpMath.Tests.Geometry; + +public class Vector3Tests +{ + [Fact] + public void Constructors_SetComponents() + { + Assert.Equal(new Vector3(1, 2, 3), new Vector3(new Vector2(1, 2), 3)); + Assert.Equal(new Vector3(1, 2, 3), new Vector3(new Point3D(1, 2, 3))); + Assert.Equal(3, Vector3.One.Dimension); + } + + [Fact] + public void StaticVectors_FollowRightHandedConvention() + { + Assert.Equal(new Vector3(0, 0, -1), Vector3.Forward); + Assert.Equal(new Vector3(0, 0, 1), Vector3.Back); + Assert.Equal(new Vector3(0, 1, 0), Vector3.Up); + Assert.Equal(new Vector3(0, -1, 0), Vector3.Down); + Assert.Equal(new Vector3(-1, 0, 0), Vector3.Left); + Assert.Equal(new Vector3(1, 0, 0), Vector3.Right); + Assert.Equal(Vector3.UnitZ, Vector3.UnitX.Cross(Vector3.UnitY)); + Assert.Equal(Vector3.Zero, default); + Assert.Equal(new Vector3(1, 1, 1), Vector3.One); + Assert.Equal(new Vector3(0, 1, 0), Vector3.UnitY); + } + + [Fact] + public void Indexer_ReturnsComponentsAndValidatesIndex() + { + var vector = new Vector3(10, 5, 3); + Assert.Equal(10, vector[0]); + Assert.Equal(5, vector[1]); + Assert.Equal(3, vector[2]); + Assert.Throws(() => vector[3]); + } + + [Fact] + public void LengthAndDistance() + { + Assert.Equal(13, new Vector3(13, 0, 0).Length); + Assert.Equal(3, new Vector3(1, 2, 2).Length); + Assert.Equal(Math.Sqrt(30), new Vector3(10, 5, 3).Distance(new Vector3(5, 6, 1))); + Assert.Equal(Math.Sqrt(30), Vector3.Distance(new Vector3(10, 5, 3), new Vector3(5, 6, 1))); + Assert.Equal(30, Vector3.DistanceSquared(new Vector3(10, 5, 3), new Vector3(5, 6, 1))); + Assert.Equal(30, new Vector3(10, 5, 3).DistanceSquared(new Vector3(5, 6, 1))); + } + + [Fact] + public void Products() + { + Assert.Equal(8, new Vector3(2, 3, 1).Dot(new Vector3(-1, 2, 4))); + Assert.Equal(0, Vector3.Dot(Vector3.Forward, Vector3.Up)); + Assert.Equal(new Vector3(-15, 1, 10), new Vector3(1, -5, 2).Cross(new Vector3(2, 0, 3))); + Assert.Equal(new Vector3(-15, 1, 10), Vector3.Cross(new Vector3(1, -5, 2), new Vector3(2, 0, 3))); + // Behaviour change: the old implementation returned the absolute value; a·(b×c) = det([a; b; c]) is signed. + Assert.Equal(-31, Vector3.ScalarTripleProduct(new Vector3(2, 0, 5), new Vector3(-1, 5, -2), new Vector3(2, 1, 2))); + Assert.Equal(Math.Sqrt(525), new Vector3(3, 4, 4).ParallelogramArea(new Vector3(1, -2, 3)), 12); + } + + [Fact] + public void AngleNormalizeAndPredicates() + { + Assert.Equal(Math.PI / 2, Vector3.Angle(Vector3.UnitX, Vector3.UnitZ), 12); + Assert.True(new Vector3(1, 2, 2).Normalize().ApproximatelyEquals(new Vector3(1 / 3.0, 2 / 3.0, 2 / 3.0))); + Assert.Throws(() => Vector3.Zero.Normalize()); + Assert.Throws(() => Vector3.Zero.Angle(Vector3.UnitX)); + + Assert.True(new Vector3(2, 3, 3).IsParallelTo(new Vector3(4, 6, 6))); + // Regression: zero components produced NaN and "not parallel". + Assert.True(new Vector3(0, 1, 3).IsParallelTo(new Vector3(0, 2, 6))); + Assert.True(new Vector3(1, 0, 0).IsParallelTo(new Vector3(-4, 0, 0))); + Assert.False(new Vector3(0, 1, 3).IsParallelTo(new Vector3(0, 3, 2))); + + Assert.True(Vector3.Forward.IsOrthogonalTo(Vector3.Up)); + Assert.False(Vector3.Forward.IsOrthogonalTo(Vector3.Back)); + Assert.True(Vector3.Zero.IsOrthogonalTo(Vector3.UnitX)); + Assert.True(Vector3.Back.IsOrthonormalTo(Vector3.Down)); + Assert.False(Vector3.Forward.IsOrthonormalTo(new Vector3(2, 3, 2))); + Assert.True(new Vector3(0, 0.6, 0.8).IsNormalized); + Assert.True(new Vector3(0, 0, 1e-13).IsZero); + } + + [Fact] + public void InterpolationAndMovement() + { + Assert.Equal(new Vector3(4, 8, 9), Vector3.Lerp(new Vector3(2, 6, 8), new Vector3(6, 10, 10), 0.5)); + Assert.Equal(new Vector3(10.5, 4, 8), Vector3.Lerp(new Vector3(13, 2, 9), new Vector3(3, 10, 5), 0.25)); + Assert.Equal(new Vector3(2, 0, 0), Vector3.LerpUnclamped(Vector3.Zero, Vector3.UnitX, 2)); + Assert.Equal(new Vector3(1, 0, 0), Vector3.MoveTowards(Vector3.Zero, new Vector3(5, 0, 0), 1)); + Assert.Equal(Vector3.One, Vector3.MoveTowards(Vector3.One, Vector3.One, 1)); + Assert.Equal(new Vector3(5, 0, 0), Vector3.MoveTowards(Vector3.Zero, new Vector3(5, 0, 0), 9)); + } + + [Fact] + public void Transform_AppliesMatricesAndSinglePerspectiveDivide() + { + Assert.Equal(new Vector3(2, 4, 6), Vector3.Transform(new Vector3(1, 2, 3), Matrix3x3.Identity * 2)); + Assert.Equal(new Vector3(2, 3, 4), Vector3.Transform(new Vector3(1, 2, 3), Matrix4x4.Translation(1, 1, 1))); + + // Regression: Z used to be divided by W twice. + var halve = new Matrix4x4(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 2); + Assert.Equal(new Vector3(1, 1, 1), Vector3.Transform(new Vector3(2, 2, 2), halve)); + } + + [Fact] + public void FromHomogeneous_DividesByW() + { + Assert.Equal(new Vector3(1, 2, 3), Vector3.FromHomogeneous(new Vector4(2, 4, 6, 2))); + Assert.Throws(() => Vector3.FromHomogeneous(new Vector4(1, 2, 3, 0))); + } + + [Fact] + public void OperatorsEqualityAndStrings() + { + var a = new Vector3(1, 2, 3); + Assert.Equal(new Vector3(2, 4, 6), a + a); + Assert.Equal(Vector3.Zero, a - a); + Assert.Equal(new Vector3(-1, -2, -3), -a); + Assert.Equal(new Vector3(2, 4, 6), 2 * a); + Assert.Equal(new Vector3(2, 4, 6), a * 2); + Assert.Equal(new Vector3(0.5, 1, 1.5), a / 2); + Assert.True(a == new Vector3(1, 2, 3)); + Assert.True(a != new Vector3(2, 2, 3)); + Assert.True(a.Equals((object) new Vector3(1, 2, 3))); + Assert.False(a.Equals((object) "a")); + Assert.Equal(a.GetHashCode(), new Vector3(1, 2, 3).GetHashCode()); + Assert.True(a.ApproximatelyEquals(new Vector3(1, 2, 3 + 1e-12))); + Assert.False(a.ApproximatelyEquals(new Vector3(1, 2, 3.1), 0.01, 0)); + Assert.Equal(new Point3D(1, 2, 3), a.ToPoint()); + Assert.Equal("Vector3 {X: 1, Y: 2, Z: 3}", a.ToString()); + Assert.Equal(@"\left( \begin{array}{c} 1 \\ 6 \\ 8 \end{array} \right)", new Vector3(1, 6, 8).ToLaTeXString()); + } +} diff --git a/tests/SharpMath.Tests/Geometry/Vector4Tests.cs b/tests/SharpMath.Tests/Geometry/Vector4Tests.cs new file mode 100644 index 0000000..3f3dd8d --- /dev/null +++ b/tests/SharpMath.Tests/Geometry/Vector4Tests.cs @@ -0,0 +1,88 @@ +using SharpMath.Geometry; + +namespace SharpMath.Tests.Geometry; + +public class Vector4Tests +{ + [Fact] + public void Constructors_StaticVectorsAndIndexer() + { + var vector = new Vector4(new Vector3(1, 2, 3), 4); + Assert.Equal(new Vector4(1, 2, 3, 4), vector); + Assert.Equal(4, vector.Dimension); + Assert.Equal(4, vector[3]); + Assert.Equal(3, vector[2]); + Assert.Throws(() => vector[4]); + + // Regression: UnitW used to be (0, 0, 1, 0). + Assert.Equal(new Vector4(0, 0, 0, 1), Vector4.UnitW); + Assert.Equal(new Vector4(1, 0, 0, 0), Vector4.UnitX); + Assert.Equal(new Vector4(0, 1, 0, 0), Vector4.UnitY); + Assert.Equal(new Vector4(0, 0, 1, 0), Vector4.UnitZ); + Assert.Equal(new Vector4(1, 1, 1, 1), Vector4.One); + Assert.Equal(new Vector4(0, 0, 0, 0), Vector4.Zero); + } + + [Fact] + public void ArithmeticOperators_Exist() + { + var a = new Vector4(1, 2, 3, 4); + Assert.Equal(new Vector4(2, 4, 6, 8), a + a); + Assert.Equal(Vector4.Zero, a - a); + Assert.Equal(new Vector4(-1, -2, -3, -4), -a); + Assert.Equal(new Vector4(2, 4, 6, 8), a * 2); + Assert.Equal(new Vector4(2, 4, 6, 8), 2 * a); + Assert.Equal(new Vector4(0.5, 1, 1.5, 2), a / 2); + } + + [Fact] + public void MetricOperations() + { + var a = new Vector4(1, 1, 1, 1); + Assert.Equal(2, a.Length); + Assert.Equal(4, a.LengthSquared); + Assert.Equal(4, a.Dot(Vector4.One)); + Assert.Equal(4, Vector4.Dot(a, Vector4.One)); + Assert.True(a.Normalize().ApproximatelyEquals(new Vector4(0.5, 0.5, 0.5, 0.5))); + Assert.Throws(() => Vector4.Zero.Normalize()); + Assert.Equal(Math.PI / 2, Vector4.Angle(Vector4.UnitX, Vector4.UnitW), 12); + Assert.Equal(Math.PI / 2, Vector4.UnitX.Angle(Vector4.UnitW), 12); + Assert.Equal(2, Vector4.Distance(Vector4.Zero, a)); + Assert.Equal(2, a.Distance(Vector4.Zero)); + Assert.Equal(4, Vector4.DistanceSquared(Vector4.Zero, a)); + Assert.Equal(4, a.DistanceSquared(Vector4.Zero)); + Assert.True(new Vector4(0, 0, 0, 1).IsNormalized); + Assert.True(new Vector4(0, 0, 0, 1e-13).IsZero); + } + + [Fact] + public void Predicates() + { + Assert.True(new Vector4(1, 0, 2, 0).IsParallelTo(new Vector4(-2, 0, -4, 0))); + Assert.False(new Vector4(1, 0, 2, 0).IsParallelTo(new Vector4(1, 1, 2, 0))); + Assert.True(Vector4.UnitX.IsOrthogonalTo(Vector4.UnitW)); + Assert.True(Vector4.UnitX.IsOrthonormalTo(Vector4.UnitW)); + Assert.False(Vector4.UnitX.IsOrthonormalTo(Vector4.UnitX)); + } + + [Fact] + public void InterpolationTransformEqualityStrings() + { + Assert.Equal(new Vector4(1, 1, 1, 1), Vector4.Lerp(Vector4.Zero, new Vector4(2, 2, 2, 2), 0.5)); + Assert.Equal(new Vector4(2, 2, 2, 2), Vector4.Lerp(Vector4.Zero, new Vector4(2, 2, 2, 2), 3)); + Assert.Equal(new Vector4(4, 4, 4, 4), Vector4.LerpUnclamped(Vector4.Zero, new Vector4(2, 2, 2, 2), 2)); + Assert.Equal(new Vector4(1, 0, 0, 0), Vector4.MoveTowards(Vector4.Zero, new Vector4(3, 0, 0, 0), 1)); + Assert.Equal(new Vector4(3, 0, 0, 0), Vector4.MoveTowards(Vector4.Zero, new Vector4(3, 0, 0, 0), 5)); + Assert.Equal(new Vector4(2, 3, 4, 1), Vector4.Transform(new Vector4(1, 2, 3, 1), Matrix4x4.Translation(1, 1, 1))); + Assert.Equal(new Vector4(1, 2, 3, 0), Vector4.Transform(new Vector4(1, 2, 3, 0), Matrix4x4.Translation(1, 1, 1))); + + var a = new Vector4(1, 2, 3, 4); + Assert.True(a == new Vector4(1, 2, 3, 4)); + Assert.True(a != new Vector4(1, 2, 3, 5)); + Assert.True(a.Equals((object) new Vector4(1, 2, 3, 4))); + Assert.Equal(a.GetHashCode(), new Vector4(1, 2, 3, 4).GetHashCode()); + Assert.True(a.ApproximatelyEquals(new Vector4(1, 2, 3, 4 + 1e-12))); + Assert.Equal("Vector4 {X: 1, Y: 2, Z: 3, W: 4}", a.ToString()); + Assert.Equal(@"\left( \begin{array}{c} 1 \\ 2 \\ 3 \\ 4 \end{array} \right)", a.ToLaTeXString()); + } +} diff --git a/tests/SharpMath.Tests/Line2DTest.cs b/tests/SharpMath.Tests/Line2DTest.cs deleted file mode 100644 index cb421cc..0000000 --- a/tests/SharpMath.Tests/Line2DTest.cs +++ /dev/null @@ -1,80 +0,0 @@ -using SharpMath.Geometry; - -namespace SharpMath.Tests -{ - public class Line2DTest - { - [Fact] - public void CanCreateLineFromPoints() - { - var line = Line2D.FromPoints(new Point2D(0, 2), new Point2D(3, 1)); - Assert.Equal(-1d / 3d, line.Slope); - Assert.Equal(2, line.Offset); - - var secondLine = Line2D.FromPoints(new Point2D(4, 5), new Point2D(1, 3)); - Assert.Equal(2d / 3d, secondLine.Slope); - Assert.Equal(7d / 3d, secondLine.Offset); - } - - [Fact] - public void CanDetermineIfLinesAreParallel() - { - var line = new Line2D(2, 0); - var secondLine = new Line2D(2, 3); - var thirdLine = new Line2D(3, 1); - Assert.True(line.IsParallelTo(secondLine)); - Assert.False(secondLine.IsParallelTo(thirdLine)); - } - - [Fact] - public void CanDetermineIfLinesIntersect() - { - var line = new Line2D(2, 0); - var secondLine = new Line2D(3, 1); - var thirdLine = new Line2D(2, 3); - Assert.True(line.IntersectsWith(secondLine)); - Assert.False(line.IntersectsWith(thirdLine)); - } - - [Fact] - public void CanDetermineIfPointIsOnLine() - { - var line = new Line2D(1, 0); - Assert.True(line.IsPointOnLine(new Point2D(1, 1))); - Assert.False(line.IsPointOnLine(new Point2D(2, 1))); - - var secondLine = new Line2D(2, 3); - Assert.True(secondLine.IsPointOnLine(new Point2D(4, 11))); - Assert.False(secondLine.IsPointOnLine(new Point2D(3, 2))); - } - - [Fact] - public void CanFindIntersectionPoint() - { - // 0.5x - 1 - var firstLine = new Line2D(0.5, -1); - // -2x - 6 - var secondLine = new Line2D(-2, -6); - - var intersectionPoint = firstLine.GetIntersectionPoint(secondLine); - Assert.Equal(-2, intersectionPoint.X); - Assert.Equal(-2, intersectionPoint.Y); - } - - [Fact] - public void CanGetIntersectionPoint() - { - var line = new Line2D(2, 0); - var secondLine = new Line2D(3, 1); - Assert.Equal(new Point2D(-1, -2), line.GetIntersectionPoint(secondLine)); - } - - [Fact] - public void CanGetPoint() - { - var line = new Line2D(3, 1); - Assert.Equal(new Point2D(1, 4), line.GetPoint(1)); - Assert.Equal(new Point2D(10, 31), line.GetPoint(10)); - } - } -} \ No newline at end of file diff --git a/tests/SharpMath.Tests/Line3DTest.cs b/tests/SharpMath.Tests/Line3DTest.cs deleted file mode 100644 index 832ebd5..0000000 --- a/tests/SharpMath.Tests/Line3DTest.cs +++ /dev/null @@ -1,34 +0,0 @@ -using SharpMath.Geometry; - -namespace SharpMath.Tests -{ - public class Line3DTest - { - [Fact] - public void CanCreateLineFromPoints() - { - var line = Line3D.FromPoints(new Point3D(Vector3.Zero), new Point3D(Vector3.One)); - Assert.Equal(new Point3D(Vector3.Zero), line.Point); - Assert.Equal(Vector3.One, line.Direction); - - var secondLine = Line3D.FromPoints(new Point3D(2, 3, 1), new Point3D(3, 2, 4)); - Assert.Equal(new Point3D(2, 3, 1), secondLine.Point); - Assert.Equal(new Vector3(1, -1, 3), secondLine.Direction); - } - - [Fact] - public void CanDetermineIfPointIsOnLine() - { - var line = new Line3D(new Point3D(2, 3, 1), new Vector3(2, 1, 2)); - Assert.True(line.IsPointOnLine(new Point3D(6, 5, 5))); - Assert.False(line.IsPointOnLine(new Point3D(2, 3.5, 2))); - } - - [Fact] - public void CanGetPoint() - { - var line = new Line3D(new Point3D(2, 3, 1), new Vector3(2, 1, 2)); - Assert.Equal(new Point3D(6, 5, 5), line.GetPoint(2)); - } - } -} \ No newline at end of file diff --git a/tests/SharpMath.Tests/LinearEquationTest.cs b/tests/SharpMath.Tests/LinearEquationTest.cs deleted file mode 100644 index 218f4a1..0000000 --- a/tests/SharpMath.Tests/LinearEquationTest.cs +++ /dev/null @@ -1,50 +0,0 @@ -using SharpMath.Equations; -using SharpMath.Equations.Exceptions; - -namespace SharpMath.Tests -{ - public class LinearEquationTest - { - [Fact] - public void CanNotSolveLinearEquationSystemClearly() - { - Assert.Throws(() => - { - // 0x = 0 - infinite solutions - var equation = new LinearEquation(new[] {0.0}, 0); - var results = new LinearEquationSystem(equation).Solve(); // This will throw an exception - Assert.Equal(0, results[0]); - }); - } - - [Fact] - public void CanSolveLinearEquationSystem() - { - // x - y + 2z = 6 - var firstEquation = new LinearEquation(new[] {1.0, -1.0, 2.0}, 6); - // 2x + 3y + 2z = 11 - var secondEquation = new LinearEquation(new[] {2.0, 3.0, 2.0}, 11); - // 3x + 2y + z = 8 - var thirdEquation = new LinearEquation(new[] {3.0, 2.0, 1.0}, 8); - - var linearEquationSystem = new LinearEquationSystem(firstEquation, secondEquation, thirdEquation); - var results = linearEquationSystem.Solve(); - Assert.Equal(1, results[0]); - Assert.Equal(1, results[1]); - Assert.Equal(3, results[2]); - } - - [Fact] - public void CanSolveSimpleLinearEquationSystem() - { - // 2x = 1 - var equation = new LinearEquation - { - Coefficients = new[] {2.0}, - Result = 1 - }; - var linearEquationSystem = new LinearEquationSystem(equation); - Assert.Equal(0.5, linearEquationSystem.Solve()[0]); - } - } -} \ No newline at end of file diff --git a/tests/SharpMath.Tests/MatrixTest.cs b/tests/SharpMath.Tests/MatrixTest.cs deleted file mode 100644 index 4208472..0000000 --- a/tests/SharpMath.Tests/MatrixTest.cs +++ /dev/null @@ -1,264 +0,0 @@ -using System.Diagnostics; -using SharpMath.Geometry; - -namespace SharpMath.Tests -{ - public class MatrixTest - { - [Fact] - public void CanCalculateDeterminant() - { - var firstMatrix = new Matrix1x1 {[0, 0] = 2}; - Assert.Equal(2, firstMatrix.Determinant); - - var secondMatrix = new Matrix2x2 - { - [0, 0] = 8, - [0, 1] = 3, - [1, 0] = 4, - [1, 1] = 2 - }; - Assert.Equal(4, secondMatrix.Determinant); - - var thirdMatrix = new Matrix3x3 - { - [0, 0] = 2, - [0, 1] = 5, - [0, 2] = 2, - [1, 0] = 3, - [1, 1] = -3, - [1, 2] = 1, - [2, 0] = 1, - [2, 1] = 4, - [2, 2] = -4 - }; - - Assert.Equal(111, thirdMatrix.Determinant); - - var fourthMatrix = new Matrix3x3 - { - [0, 0] = -4, - [0, 1] = 2.5, - [0, 2] = 3, - [1, 0] = 5, - [1, 1] = 6, - [1, 2] = 4, - [2, 0] = 9, - [2, 1] = 10, - [2, 2] = -9 - }; - - Assert.Equal(566.5, fourthMatrix.Determinant); - - var fifthMatrix = new Matrix4x4 - { - [0, 0] = 1, - [0, 1] = 0, - [0, 2] = 0, - [0, 3] = 1, - [1, 0] = 1, - [1, 1] = 2, - [1, 2] = 3, - [1, 3] = 2, - [2, 0] = 2, - [2, 1] = 3, - [2, 2] = 4, - [2, 3] = 0, - [3, 0] = 1, - [3, 1] = 2, - [3, 2] = -1, - [3, 3] = -2 - }; - - Assert.Equal(-24, fifthMatrix.Determinant); - } - - [Fact] - public void CanDetermineIfMatrixIsDiagonal() - { - var matrix = new Matrix3x3 - { - M11 = 1, - M22 = 1, - M33 = 1 - }; - Assert.True(matrix.IsDiagonal); - - var secondMatrix = new Matrix4x4 - { - M11 = 1, - M22 = 1, - M33 = 1, - M44 = 1 - }; - Assert.True(secondMatrix.IsDiagonal); - - var thirdMatrix = new Matrix4x4 - { - M11 = 1, - M22 = 1, - M33 = 1, - M44 = 1, - M14 = 1 - }; - Assert.False(thirdMatrix.IsDiagonal); - - var fourthMatrix = new Matrix4x4 - { - M11 = 1, - M22 = 1, - M33 = 1 - }; - Assert.False(fourthMatrix.IsDiagonal); - } - - [Fact] - public void CanDetermineIfMatrixIsTriangle() - { - var matrix = new Matrix3x3 - { - M11 = 1, - M22 = 2, - M33 = 3, - M12 = 3, - M13 = 4, - M23 = 7 - }; - Assert.True(matrix.IsTriangle); - - var secondMatrix = new Matrix4x4 - { - M11 = 1, - M22 = 2, - M33 = 3, - M44 = 8, - M12 = 3, - M13 = 4, - M14 = 7, - M23 = 2, - M24 = 3, - M34 = 9 - }; - Assert.True(secondMatrix.IsTriangle); - - var thirdMatrix = new Matrix4x4 - { - M11 = 5, - M22 = 7, - M33 = 4, - M44 = 3, - M14 = 2, - M31 = 9 - }; - Assert.False(thirdMatrix.IsTriangle); - - var fourthMatrix = new Matrix4x4 - { - M11 = 1, - M22 = 1, - M33 = 1, - M44 = 1 - }; - Assert.True(fourthMatrix.IsTriangle); - } - - [Fact] - public void CanGetStringRepresentation() - { - var matrix = Matrix4x4.View(Vector3.Zero, Vector3.Forward, Vector3.Up); - Debug.Print(matrix.ToString()); - } - - [Fact] - public void CanMultiplyMatrices() - { - var firstMatrix = new Matrix1x3(); - var secondMatrix = new Matrix3x1(); - - firstMatrix[0, 0] = 1; - firstMatrix[0, 1] = 2; - firstMatrix[0, 2] = 3; - - secondMatrix[0, 0] = 2; - secondMatrix[1, 0] = 1; - secondMatrix[2, 0] = 2; - - var matrixProduct = MatrixUtils.Multiply(firstMatrix, secondMatrix); - Assert.Equal(matrixProduct[0, 0], 10); - - // -------------------------------- - - var thirdMatrix = new Matrix3x3 - { - [0, 0] = 2, - [0, 1] = 5, - [0, 2] = 2, - [1, 0] = 3, - [1, 1] = -3, - [1, 2] = 1, - [2, 0] = 1, - [2, 1] = 4, - [2, 2] = -4 - }; - - var fourthMatrix = new Matrix3x3 - { - [0, 0] = -4, - [0, 1] = 2.5, - [0, 2] = 3, - [1, 0] = 5, - [1, 1] = 6, - [1, 2] = 4, - [2, 0] = 9, - [2, 1] = 10, - [2, 2] = -9 - }; - - /* - 35.000 55.000 8.000 - -18.000 -0.500 -12.000 - -20.000 -13.500 55.000 - */ - var resultMatrix = new Matrix3x3 - { - [0, 0] = 35, - [0, 1] = 55, - [0, 2] = 8, - [1, 0] = -18, - [1, 1] = -0.5, - [1, 2] = -12, - [2, 0] = -20, - [2, 1] = -13.5, - [2, 2] = 55 - }; - - Assert.Equal(resultMatrix, MatrixUtils.Multiply(thirdMatrix, fourthMatrix)); - } - - [Fact] - public void CanMultiplyMatrix3X3WithVector2() - { - var translation = Matrix3x3.Translation(10, 10); - var transformed = translation * Vector2.One; - var result = new Vector2(11, 11); - Assert.Equal(result, transformed); - } - - // TODO: Implement - //[Fact] - //public void CanGetCore() - //{ - // var matrix = new Matrix3x3 - // { - // M11 = 3, - // M12 = -1, - // M13 = 4, - // M21 = 0, - // M22 = 1, - // M23 = 5, - // }; - - // Assert.Equal(new Vector3(-3, -5, 1), matrix.GetCore()); - //} - } -} \ No newline at end of file diff --git a/tests/SharpMath.Tests/ParserTest.cs b/tests/SharpMath.Tests/ParserTest.cs deleted file mode 100644 index 5ddb21e..0000000 --- a/tests/SharpMath.Tests/ParserTest.cs +++ /dev/null @@ -1,17 +0,0 @@ -using SharpMath.Expressions; - -namespace SharpMath.Tests -{ - public class ParserTest - { - [Fact] - public void CanParseTerm() - { - var term = "(-2+3)*(cos(0)*(2/3))"; - Assert.Equal(2d / 3d, new Parser(term).Evaluate()); - - var secondTerm = "3*4^2"; - Assert.Equal(48, new Parser(secondTerm).Evaluate()); - } - } -} \ No newline at end of file diff --git a/tests/SharpMath.Tests/PolygonTest.cs b/tests/SharpMath.Tests/PolygonTest.cs deleted file mode 100644 index 6814faa..0000000 --- a/tests/SharpMath.Tests/PolygonTest.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using SharpMath.Geometry; - -namespace SharpMath.Tests -{ - public class PolygonTest - { - [Fact] - public void CanCalculateArea() - { - var square = - new Polygon(new Point2D(0, 0), new Point2D(2, 0), new Point2D(2, 2), new Point2D(0, 2)); - Assert.True(FloatingNumber.AreApproximatelyEqual(4, square.Area)); - - var triangle = - new Polygon(new Point2D(0, 0), new Point2D(2, 0), new Point2D(1, 2)); - Assert.Equal(2, triangle.Area); - - var polygon = new Polygon(new Point2D(-2, -2), new Point2D(2, -2), new Point2D(4, 1), new Point2D(0, 2), - new Point2D(-4, 1)); - Assert.Equal(22, polygon.Area); - } - - [Fact] - public void CanCalculatePerimeter() - { - var square = - new Polygon(new Point2D(0, 0), new Point2D(2, 0), new Point2D(2, 2), new Point2D(0, 2)); - Assert.Equal(8, square.Perimeter); - - var polygon = new Polygon(new Point2D(-2, -2), new Point2D(2, -2), new Point2D(4, 1), new Point2D(0, 2), - new Point2D(-4, 1)); - Assert.Equal(4 + 2 * Math.Sqrt(13) + 2 * Math.Sqrt(17), polygon.Perimeter); - } - - [Fact] - public void CanValidatePointAmount() - { - Assert.Throws(() => - { - new Polygon(new Point2D(0, 1), new Point2D(3, 4)); - }); - } - } -} \ No newline at end of file diff --git a/tests/SharpMath.Tests/SharpMath.Tests.csproj b/tests/SharpMath.Tests/SharpMath.Tests.csproj index 1ba8e71..8d33d49 100644 --- a/tests/SharpMath.Tests/SharpMath.Tests.csproj +++ b/tests/SharpMath.Tests/SharpMath.Tests.csproj @@ -1,12 +1,5 @@ - - - disable - false - none - - diff --git a/tests/SharpMath.Tests/ToleranceTests.cs b/tests/SharpMath.Tests/ToleranceTests.cs new file mode 100644 index 0000000..0d3dcb7 --- /dev/null +++ b/tests/SharpMath.Tests/ToleranceTests.cs @@ -0,0 +1,51 @@ +namespace SharpMath.Tests; + +public class ToleranceTests +{ + [Theory] + [InlineData(1.0, 1.0)] + [InlineData(0.0, 1e-13)] + [InlineData(1e10 * (0.1 + 0.2), 3e9)] + [InlineData(double.PositiveInfinity, double.PositiveInfinity)] + public void AreEqual_AcceptsValuesWithinTolerance(double a, double b) => Assert.True(Tolerance.AreEqual(a, b)); + + [Theory] + [InlineData(1e-10, 1e-12)] + [InlineData(1.0, 1.0001)] + [InlineData(double.NaN, double.NaN)] + [InlineData(double.PositiveInfinity, double.NegativeInfinity)] + [InlineData(double.PositiveInfinity, 1.0)] + public void AreEqual_RejectsValuesOutsideTolerance(double a, double b) => Assert.False(Tolerance.AreEqual(a, b)); + + [Fact] + public void AreEqual_UsesCustomTolerances() + { + Assert.True(Tolerance.AreEqual(1, 1.05, 0.1, 0)); + Assert.True(Tolerance.AreEqual(100, 101, 0, 0.01)); + Assert.False(Tolerance.AreEqual(100, 102, 0, 0.01)); + } + + [Fact] + public void AreEqual_RejectsNegativeTolerances() + { + Assert.Throws(() => Tolerance.AreEqual(1, 1, -1, 0)); + Assert.Throws(() => Tolerance.AreEqual(1, 1, 0, double.NaN)); + } + + [Fact] + public void IsZero_UsesAbsoluteTolerance() + { + Assert.True(Tolerance.IsZero(1e-13)); + Assert.False(Tolerance.IsZero(1e-11)); + Assert.True(Tolerance.IsZero(0.05, 0.1)); + Assert.Throws(() => Tolerance.IsZero(0, -0.1)); + } + + [Fact] + public void IsNegligible_ScalesWithReference() + { + Assert.True(Tolerance.IsNegligible(1e-4, 1e6)); + Assert.False(Tolerance.IsNegligible(1e-4, 1)); + Assert.True(Tolerance.IsNegligible(1e-13, 0)); + } +} diff --git a/tests/SharpMath.Tests/VectorTest.cs b/tests/SharpMath.Tests/VectorTest.cs deleted file mode 100644 index db1115e..0000000 --- a/tests/SharpMath.Tests/VectorTest.cs +++ /dev/null @@ -1,307 +0,0 @@ -using System; -using System.Diagnostics; -using SharpMath.Geometry; - -// ReSharper disable UnusedVariable - -namespace SharpMath.Tests -{ - public class VectorTest - { - [Fact] - public void CanCalculateAngle() - { - var vector1 = Vector2.UnitX; - var vector2 = Vector2.UnitY; - - Assert.Equal(Math.PI / 2, vector1.Angle(vector2)); - Assert.True(vector1.CheckForOrthogonality(vector2)); - } - - [Fact] - public void CanCalculateArea() - { - var firstVector = new Vector3(3, 4, 4); - var secondVector = new Vector3(1, -2, 3); - var area = Vector3.Area(firstVector, secondVector); - Assert.Equal(Math.Sqrt(525), area); - - var thirdVector = new Vector2(2, 4); - var fourthVector = new Vector2(3, 1); - var secondArea = Vector2.Area(thirdVector, fourthVector); - Assert.True(FloatingNumber.AreApproximatelyEqual(10, secondArea)); - } - - [Fact] - public void CanCalculateCrossProduct() - { - var vector = new Vector3(1, -5, 2); - var secondVector = new Vector3(2, 0, 3); - var resultVector = vector.VectorProduct(secondVector); - - Assert.Equal(-15, resultVector.X); - Assert.Equal(1, resultVector.Y); - Assert.Equal(10, resultVector.Z); - } - - [Fact] - public void CanCalculateDistance() - { - var vector = new Vector3(10, 5, 3); - var secondVector = new Vector3(5, 6, 1); // 5, -1, 2 // Magnitude: sqrt(30) - Assert.Equal(Math.Sqrt(30), vector.Distance(secondVector)); - } - - [Fact] - public void CanCalculateLength() - { - var vector = new Vector3(13, 0, 0); - Assert.Equal(13, vector.Magnitude); - - var secondVector = new Vector3(1, 2, 2); - Assert.Equal(3, secondVector.Magnitude); - } - - [Fact] - public void CanCalculateScalarProduct() - { - Assert.Equal(0, Vector2.UnitX.DotProduct(Vector2.UnitY)); - Assert.Equal(20, new Vector2(2, 4).DotProduct(new Vector2(4, 3))); - Assert.Equal(0, Vector3.Forward.DotProduct(Vector3.Up)); - Assert.Equal(8, new Vector3(2, 3, 1).DotProduct(new Vector3(-1, 2, 4))); - } - - [Fact] - public void CanCompareVectors() - { - // Let's see, if the dimension check is working - var vector = new Vector3(); - var secondVector = new Vector2(); - Assert.False(vector.Equals(secondVector)); - - // Let's see, if the coordinate comparison is working (in this case simply two zero vectors) - var thirdVector = new Vector4(); - var fourthVector = new Vector4(); - Assert.True(thirdVector.Equals(fourthVector)); - Assert.True(thirdVector == fourthVector); - Assert.False(thirdVector != fourthVector); - - // Let's see, if the coordinate comparison is working when we have the same dimension but different coordinate values - var fifthVector = new Vector3 - { - [0] = 1, - [1] = 2, - [2] = 1 - }; - - Assert.Equal(new Vector3(1, 2, 1), fifthVector); - - var sixthVector = new Vector3 - { - [0] = 2, - [1] = 2, - [2] = 1 - }; - - Assert.False(fifthVector.Equals(sixthVector)); - Assert.False(fifthVector == sixthVector); - Assert.True(fifthVector != sixthVector); - } - - [Fact] - public void CanConvertVectorIntoMatrices() - { - var firstMatrix = new Matrix3x1 - { - [0, 0] = 1, - [1, 0] = 0, - [2, 0] = 0 - }; - var firstVectorMatrix = Vector3.Right.AsVerticalMatrix(); - Assert.True(firstMatrix.Equals(firstVectorMatrix)); // Equals semantics as in MSTest - - var secondMatrix = new Matrix1x3 - { - [0, 0] = 1, - [0, 1] = 0, - [0, 2] = 0 - }; - var secondVectorMatrix = Vector3.Right.AsHorizontalMatrix(); - Assert.True(secondMatrix.Equals(secondVectorMatrix)); // Equals semantics as in MSTest - } - - [Fact] - public void CanConvertVectors() - { - var firstVector = new Vector3(2, 6, 8); - var newVector = firstVector.Convert(); - Assert.Equal(2, (int) newVector.Dimension); - Assert.Equal(2, newVector.X); - Assert.Equal(6, newVector.Y); - } - - [Fact] - public void CanCreateScalarTripleProduct() - { - var firstVector = new Vector3(2, 0, 5); - var secondVector = new Vector3(-1, 5, -2); - var thirdVector = new Vector3(2, 1, 2); - - var scalarTripleProduct = Vector3.ScalarTripleProduct(firstVector, secondVector, thirdVector); - Debug.Print(scalarTripleProduct.ToString()); - Assert.Equal(31, scalarTripleProduct); - } - - [Fact] - public void CanDetermineIfVectorsAreOrthogonal() - { - Assert.True(Vector3.Forward.CheckForOrthogonality(Vector3.Up)); - Assert.False(Vector3.Forward.CheckForOrthogonality(Vector3.Back)); - Assert.False(Vector3.Zero.CheckForOrthogonality(Vector3.UnitX)); - } - - [Fact] - public void CanDetermineIfVectorsAreOrthonormal() - { - Assert.True(Vector3.Forward.CheckForOrthonormality(Vector3.Up)); - Assert.True(Vector3.Back.CheckForOrthonormality(Vector3.Down)); - Assert.False(Vector3.Forward.CheckForOrthonormality(Vector3.Back)); - Assert.False(Vector3.Forward.CheckForOrthonormality(new Vector3(2, 3, 2))); - } - - [Fact] - public void CanDetermineIfVectorsAreParallel() - { - Assert.True(new Vector3(2, 3, 3).CheckForParallelism(new Vector3(4, 6, 6))); - Assert.True(new Vector3(1, 2, 3).CheckForParallelism(new Vector3(3, 6, 9))); - Assert.False(new Vector3(0, 1, 3).CheckForParallelism(new Vector3(0, 3, 2))); - } - - [Fact] - public void CanGetCorrectLaTeXString() - { - var vector = new Vector3(1, 6, 8); - Assert.Equal(@"\left( \begin{array}{c} 1 \\ 6 \\ 8 \end{array} \right)", vector.ToLaTeXString()); - } - - [Fact] - public void CanGetNegatedVector() - { - var vector = new Vector3(2, 3, 2); - Assert.Equal(new Vector3(-2, -3, -2), vector.Negate()); - Assert.Equal(new Vector3(-2, -3, -2), vector.Negate()); - } - - [Fact] - public void CanGetNormalizedVector() - { - var vector = new Vector2(3, 4); - Assert.Equal(new Vector2(3d / 5d, 4d / 5d), vector.Normalize()); - Assert.Equal(new Vector2(3d / 5d, 4d / 5d), vector.Normalize()); - } - - [Fact] - public void CanLerp() - { - var firstVector = new Vector3(2, 6, 8); - var secondVector = new Vector3(6, 10, 10); - var lerpResult = Vector3.Lerp(firstVector, secondVector, 0.5); - - // ((6,10,10)-(2,6,8))*0.5+(2,6,8) = (4,4,2)*0.5+(2,6,8) = (2,2,1)+(2,6,8) = (4,8,9) - Assert.Equal(new Vector3(4, 8, 9), lerpResult); - - var thirdVector = new Vector3(13, 2, 9); - var fourthVector = new Vector3(3, 10, 5); - var secondLerpResult = Vector3.Lerp(thirdVector, fourthVector, 0.25); - - // ((3,10,5)-(13,2,9))*0.25+(13,2,9) = (-10,8,-4)*0.25+(13,2,9) = (-2.5,2,-1)+(13,2,9) = (10.5,4,8) - Assert.Equal(new Vector3(10.5, 4, 8), secondLerpResult); - } - - [Fact] - public void CanNotCalculateAngleBetweenZeroVectors() - { - Assert.Throws(() => - { - var angle = Vector2.Zero.Angle(Vector2.UnitX); - }); - } - - [Fact] - public void CanNotUseIndexerWithInvalidIndex() - { - Assert.Throws(() => - { - var vector = new Vector3(10, 5, 3); - var w = vector[3]; // Throws an exception - }); - } - - [Fact] - public void CanRotateTwoDimensionalVector() - { - var firstVector = new Vector2(0, 1); - var rotatedVector = Matrix3x3.Rotation(MathHelper.DegreesToRadians(180)) * firstVector; - Assert.True(FloatingNumber.AreApproximatelyEqual(firstVector.X, 0)); - Assert.Equal(-1, rotatedVector.Y); - } - - [Fact] - public void CanUseIndexer() - { - var vector = new Vector3(10, 5, 3); - var x = vector[0]; - var y = vector[1]; - var z = vector[2]; - - Assert.Equal(x, 10); - Assert.Equal(y, 5); - Assert.Equal(z, 3); - } - - [Fact] - public void CompareAreaCalculations() - { - var stopWatch = new Stopwatch(); - - // ------------------------- 3D ------------------------------- - - var firstVector = new Vector3(3, 4, 4); - var secondVector = new Vector3(1, -2, 3); - - stopWatch.Start(); - var crossProductArea = Vector3.VectorProduct(firstVector, secondVector).Magnitude; - stopWatch.Stop(); - // This is faster, if the vectors are already 3-dimensional, because we have no arccos, sin etc. - Debug.Print("Vector3 area calculation over the cross product takes " + stopWatch.ElapsedMilliseconds + - " milliseconds."); - - stopWatch.Restart(); - var defaultFormulaArea = firstVector.Magnitude * Math.Sin(firstVector.Angle(secondVector)) * - secondVector.Magnitude; - stopWatch.Stop(); - Debug.Print("Vector3 area calculation over the default formula takes " + stopWatch.ElapsedMilliseconds + - " milliseconds."); - - // ------------------------- 2D ------------------------------- - - var thirdVector = new Vector2(3, 4); - var fourthVector = new Vector2(1, -2); - - stopWatch.Restart(); - var secondCrossProductArea = - Vector3.VectorProduct(thirdVector.Convert(), fourthVector.Convert()).Magnitude; - stopWatch.Stop(); - Debug.Print("Vector2 area calculation over the cross product takes " + stopWatch.ElapsedMilliseconds + - " milliseconds."); - - stopWatch.Restart(); - var secondDefaultFormulaArea = thirdVector.Magnitude * Math.Sin(thirdVector.Angle(fourthVector)) * - fourthVector.Magnitude; - stopWatch.Stop(); - // This is faster because we don't convert the vector - Debug.Print("Vector2 area calculation over the default formula takes " + stopWatch.ElapsedMilliseconds + - " milliseconds."); - } - } -} \ No newline at end of file From 316269f0845504103a46e8dc6c4d99c86763cb64 Mon Sep 17 00:00:00 2001 From: Dominic Beger Date: Sun, 11 Oct 2026 02:09:25 +0200 Subject: [PATCH 03/14] Solve linear equation systems with LU decomposition and validate their shape --- .../Equations/EquationNotSolvableException.cs | 31 +++++++ src/SharpMath/Equations/LinearEquation.cs | 31 +++++++ .../Equations/LinearEquationSystem.cs | 75 +++++++++++++++++ .../Equations/LinearEquationSystemTests.cs | 80 +++++++++++++++++++ 4 files changed, 217 insertions(+) create mode 100644 src/SharpMath/Equations/EquationNotSolvableException.cs create mode 100644 src/SharpMath/Equations/LinearEquation.cs create mode 100644 src/SharpMath/Equations/LinearEquationSystem.cs create mode 100644 tests/SharpMath.Tests/Equations/LinearEquationSystemTests.cs diff --git a/src/SharpMath/Equations/EquationNotSolvableException.cs b/src/SharpMath/Equations/EquationNotSolvableException.cs new file mode 100644 index 0000000..98cc9f8 --- /dev/null +++ b/src/SharpMath/Equations/EquationNotSolvableException.cs @@ -0,0 +1,31 @@ +namespace SharpMath.Equations; + +/// +/// The exception that is thrown when an equation system has no unique solution. +/// +public class EquationNotSolvableException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public EquationNotSolvableException() : base("The equation system has no unique solution.") + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + public EquationNotSolvableException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + /// The exception that caused this exception. + public EquationNotSolvableException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/src/SharpMath/Equations/LinearEquation.cs b/src/SharpMath/Equations/LinearEquation.cs new file mode 100644 index 0000000..f936d5e --- /dev/null +++ b/src/SharpMath/Equations/LinearEquation.cs @@ -0,0 +1,31 @@ +using System.Collections.ObjectModel; + +namespace SharpMath.Equations; + +/// +/// Represents an immutable linear equation a₁·x₁ + a₂·x₂ + … + aₙ·xₙ = result. +/// +public sealed class LinearEquation +{ + /// + /// Initializes a new instance of the class. + /// + /// The coefficients of the variables, in order, e.g. [2.0, -1, 3]. + /// The right-hand side. + public LinearEquation(IEnumerable coefficients, double result) + { + ArgumentNullException.ThrowIfNull(coefficients); + Coefficients = Array.AsReadOnly(coefficients.ToArray()); + Result = result; + } + + /// + /// Gets the coefficients of the variables, in order. + /// + public ReadOnlyCollection Coefficients { get; } + + /// + /// Gets the right-hand side. + /// + public double Result { get; } +} diff --git a/src/SharpMath/Equations/LinearEquationSystem.cs b/src/SharpMath/Equations/LinearEquationSystem.cs new file mode 100644 index 0000000..dcfe5a7 --- /dev/null +++ b/src/SharpMath/Equations/LinearEquationSystem.cs @@ -0,0 +1,75 @@ +using System.Collections.ObjectModel; +using SharpMath.Geometry; + +namespace SharpMath.Equations; + +/// +/// Represents an immutable system of n linear equations in n variables. +/// +public sealed class LinearEquationSystem +{ + /// + /// Initializes a new instance of the class. + /// + /// The equations; at least one, each with exactly one coefficient per equation. + /// The equations do not form a square system. + public LinearEquationSystem(IEnumerable equations) + { + ArgumentNullException.ThrowIfNull(equations); + Equations = Validate(equations.ToArray()); + } + + /// + /// Initializes a new instance of the class. + /// + /// The equations; at least one, each with exactly one coefficient per equation. + /// The equations do not form a square system. + public LinearEquationSystem(params ReadOnlySpan equations) + { + Equations = Validate(equations.ToArray()); + } + + /// + /// Gets the equations. + /// + public ReadOnlyCollection Equations { get; } + + /// + /// Solves the system using an LU decomposition with partial pivoting. + /// + /// The value of each variable, in the order of the coefficients. + /// The system has no unique solution. + public double[] Solve() + { + var count = Equations.Count; + var coefficients = new double[count * count]; + var results = new double[count]; + for (var row = 0; row < count; row++) + { + Equations[row].Coefficients.CopyTo(coefficients, row * count); + results[row] = Equations[row].Result; + } + + var matrix = new MatrixMxN(count, count, coefficients); + if (!matrix.TrySolve(results, out var solution)) + throw new EquationNotSolvableException("The linear equation system has no unique solution."); + return solution; + } + + private static ReadOnlyCollection Validate(LinearEquation[] equations) + { + if (equations.Length == 0) + throw new ArgumentException("There must be at least one equation.", nameof(equations)); + for (var row = 0; row < equations.Length; row++) + { + var equation = equations[row] ?? + throw new ArgumentException($"Equation {row} is null.", nameof(equations)); + if (equation.Coefficients.Count != equations.Length) + throw new ArgumentException( + $"Equation {row} has {equation.Coefficients.Count} coefficients, but the system has {equations.Length} equations. " + + "Each equation needs exactly one coefficient per variable.", nameof(equations)); + } + + return Array.AsReadOnly(equations); + } +} diff --git a/tests/SharpMath.Tests/Equations/LinearEquationSystemTests.cs b/tests/SharpMath.Tests/Equations/LinearEquationSystemTests.cs new file mode 100644 index 0000000..d427fd7 --- /dev/null +++ b/tests/SharpMath.Tests/Equations/LinearEquationSystemTests.cs @@ -0,0 +1,80 @@ +using SharpMath.Equations; + +namespace SharpMath.Tests.Equations; + +public class LinearEquationSystemTests +{ + [Fact] + public void Solve_ThreeVariables() + { + var system = new LinearEquationSystem( + new LinearEquation([1.0, -1.0, 2.0], 6), + new LinearEquation([2.0, 3.0, 2.0], 11), + new LinearEquation([3.0, 2.0, 1.0], 8)); + var results = system.Solve(); + Assert.Equal(1, results[0], 12); + Assert.Equal(1, results[1], 12); + Assert.Equal(3, results[2], 12); + } + + [Fact] + public void Solve_SingleEquationAndParamsConstructor() + { + Assert.Equal(0.5, new LinearEquationSystem(new LinearEquation([2.0], 1)).Solve()[0]); + var equation = new LinearEquation([1.0, 1.0], 6); + Assert.Equal([1.0, 1.0], equation.Coefficients); + Assert.Equal(6, equation.Result); + } + + [Fact] + public void Solve_RequiresAPivotSearchDownTheColumn() + { + // Regression: y + z = 5, y = 2, x = 1 used to be reported as not solvable. + var system = new LinearEquationSystem(new List + { + new([0.0, 1.0, 1.0], 5), + new([0.0, 1.0, 0.0], 2), + new([1.0, 0.0, 0.0], 1) + }); + Assert.Equal([1.0, 2.0, 3.0], system.Solve()); + } + + [Fact] + public void Solve_WorksAtSmallScales() + { + // Regression: 1e-10·x = 1e-10 used to be rejected by an absolute epsilon. + Assert.Equal(1, new LinearEquationSystem(new LinearEquation([1e-10], 1e-10)).Solve()[0], 12); + } + + [Fact] + public void Solve_ThrowsWithoutUniqueSolution() + { + Assert.Throws(() => + new LinearEquationSystem(new LinearEquation([0.0], 0)).Solve()); + Assert.Throws(() => new LinearEquationSystem( + new LinearEquation([1.0, 2.0], 3), new LinearEquation([2.0, 4.0], 6)).Solve()); + } + + [Fact] + public void Constructor_ValidatesTheShape() + { + Assert.Equal("equations", Assert.Throws(() => new LinearEquationSystem()).ParamName); + + // Regression: too many coefficients used to throw IndexOutOfRangeException; too few were padded with zeros. + Assert.Throws(() => new LinearEquationSystem(new LinearEquation([1.0, 2.0], 3))); + Assert.Throws(() => new LinearEquationSystem( + new LinearEquation([1.0], 3), new LinearEquation([1.0, 2.0], 3))); + Assert.Throws(() => new LinearEquationSystem(new LinearEquation[] { null! })); + Assert.Throws(() => new LinearEquationSystem((IEnumerable) null!)); + Assert.Throws(() => new LinearEquation(null!, 1)); + } + + [Fact] + public void Exception_HasStandardConstructors() + { + Assert.NotEmpty(new EquationNotSolvableException().Message); + Assert.Equal("x", new EquationNotSolvableException("x").Message); + var inner = new InvalidOperationException(); + Assert.Same(inner, new EquationNotSolvableException("x", inner).InnerException); + } +} From 69cb52511f29f2f93ad9d9c25fd24a7511abb9d4 Mon Sep 17 00:00:00 2001 From: Dominic Beger Date: Sun, 11 Oct 2026 02:09:25 +0200 Subject: [PATCH 04/14] Rewrite the expression parser Recursive-descent parser with variables, parse-once/evaluate-many, implicit multiplication, comma-separated arguments, scientific notation, modulo, factorial, more built-ins and per-instance ExpressionContext. Every syntax error is a ParserException with a position. --- .../Expressions/ExpressionContext.cs | 210 +++++++++++++ src/SharpMath/Expressions/ExpressionParser.cs | 278 ++++++++++++++++++ src/SharpMath/Expressions/MathExpression.cs | 155 ++++++++++ src/SharpMath/Expressions/Nodes.cs | 118 ++++++++ src/SharpMath/Expressions/ParserException.cs | 48 +++ src/SharpMath/Expressions/Tokenizer.cs | 150 ++++++++++ .../Expressions/ExpressionContextTests.cs | 106 +++++++ .../Expressions/MathExpressionTests.cs | 207 +++++++++++++ 8 files changed, 1272 insertions(+) create mode 100644 src/SharpMath/Expressions/ExpressionContext.cs create mode 100644 src/SharpMath/Expressions/ExpressionParser.cs create mode 100644 src/SharpMath/Expressions/MathExpression.cs create mode 100644 src/SharpMath/Expressions/Nodes.cs create mode 100644 src/SharpMath/Expressions/ParserException.cs create mode 100644 src/SharpMath/Expressions/Tokenizer.cs create mode 100644 tests/SharpMath.Tests/Expressions/ExpressionContextTests.cs create mode 100644 tests/SharpMath.Tests/Expressions/MathExpressionTests.cs diff --git a/src/SharpMath/Expressions/ExpressionContext.cs b/src/SharpMath/Expressions/ExpressionContext.cs new file mode 100644 index 0000000..21cbe1f --- /dev/null +++ b/src/SharpMath/Expressions/ExpressionContext.cs @@ -0,0 +1,210 @@ +using System.Diagnostics.CodeAnalysis; + +namespace SharpMath.Expressions; + +/// +/// Defines the functions, constants and multi-letter variables available when parsing a +/// . +/// +/// +/// +/// A new context contains the built-in functions sin cos tan asin acos atan atan2(y, x) sinh cosh tanh sqrt +/// abs ln lg log(x, base) log2 exp floor ceil round sign pow(x, y) min(…) max(…) and the constants +/// pi, e and tau. Names are case-insensitive and consist of a letter followed by letters or +/// digits. Adding a name that already exists replaces it. new ExpressionContext() is the default context; +/// use to derive variants from a configured context. +/// +/// +/// Every instance is independent. A parsed expression captures the definitions at parse time, so later changes +/// to the context do not affect it. +/// +/// +public sealed class ExpressionContext +{ + private readonly Dictionary _symbols = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Initializes a new instance of the class with the built-in functions and + /// constants. + /// + public ExpressionContext() + { + AddBuiltIn("sin", Math.Sin); + AddBuiltIn("cos", Math.Cos); + AddBuiltIn("tan", Math.Tan); + AddBuiltIn("asin", Math.Asin); + AddBuiltIn("acos", Math.Acos); + AddBuiltIn("atan", Math.Atan); + AddBuiltIn("atan2", Math.Atan2); + AddBuiltIn("sinh", Math.Sinh); + AddBuiltIn("cosh", Math.Cosh); + AddBuiltIn("tanh", Math.Tanh); + AddBuiltIn("sqrt", Math.Sqrt); + AddBuiltIn("abs", Math.Abs); + AddBuiltIn("ln", x => Math.Log(x)); + AddBuiltIn("lg", Math.Log10); + AddBuiltIn("log", (x, b) => Math.Log(x, b)); + AddBuiltIn("log2", Math.Log2); + AddBuiltIn("exp", Math.Exp); + AddBuiltIn("floor", Math.Floor); + AddBuiltIn("ceil", Math.Ceiling); + AddBuiltIn("round", x => Math.Round(x, MidpointRounding.AwayFromZero)); + AddBuiltIn("sign", x => double.IsNaN(x) ? double.NaN : Math.Sign(x)); + AddBuiltIn("pow", Math.Pow); + _symbols["min"] = new FunctionSymbol(1, int.MaxValue, Variadic: Min, IsPure: true); + _symbols["max"] = new FunctionSymbol(1, int.MaxValue, Variadic: Max, IsPure: true); + _symbols["pi"] = new ConstantSymbol(Math.PI); + _symbols["e"] = new ConstantSymbol(Math.E); + _symbols["tau"] = new ConstantSymbol(Math.Tau); + UpdateLongestName(); + } + + internal static ExpressionContext Default { get; } = new(); + + internal int LongestNameLength { get; private set; } + + /// + /// Adds or replaces a function with one argument. + /// + /// The name. + /// The function. + /// This context, for chaining. + /// is not a valid name. + public ExpressionContext AddFunction(string name, Func function) + { + ArgumentNullException.ThrowIfNull(function); + return Add(name, new FunctionSymbol(1, 1, Unary: function)); + } + + /// + /// Adds or replaces a function with two arguments. + /// + /// The name. + /// The function. + /// This context, for chaining. + /// is not a valid name. + public ExpressionContext AddFunction(string name, Func function) + { + ArgumentNullException.ThrowIfNull(function); + return Add(name, new FunctionSymbol(2, 2, Binary: function)); + } + + /// + /// Adds or replaces a function with a variable number of arguments. + /// + /// The name. + /// The minimum number of arguments. Must not be negative. + /// + /// The maximum number of arguments; at least . Use + /// for no limit. + /// + /// The function; it receives the evaluated arguments. + /// This context, for chaining. + /// is not a valid name. + /// The argument counts are invalid. + public ExpressionContext AddFunction(string name, int minArguments, int maxArguments, + Func, double> function) + { + ArgumentNullException.ThrowIfNull(function); + ArgumentOutOfRangeException.ThrowIfNegative(minArguments); + ArgumentOutOfRangeException.ThrowIfLessThan(maxArguments, minArguments); + return Add(name, new FunctionSymbol(minArguments, maxArguments, Variadic: function)); + } + + /// + /// Adds or replaces a constant. + /// + /// The name. + /// The value. + /// This context, for chaining. + /// is not a valid name. + public ExpressionContext AddConstant(string name, double value) => Add(name, new ConstantSymbol(value)); + + /// + /// Declares a variable whose name has more than one character. Single letters that are not otherwise defined are + /// variables automatically. + /// + /// The name. + /// This context, for chaining. + /// is not a valid name. + public ExpressionContext DeclareVariable(string name) => Add(name, VariableSymbol.Declared); + + /// + /// Creates an independent copy of this context, e.g. to extend a shared configuration. + /// + /// The copy. + public ExpressionContext Clone() + { + var clone = new ExpressionContext(); + clone._symbols.Clear(); + foreach (var (name, symbol) in _symbols) + clone._symbols.Add(name, symbol); + clone.UpdateLongestName(); + return clone; + } + + internal bool TryGetSymbol(ReadOnlySpan name, [NotNullWhen(true)] out Symbol? symbol) => + _symbols.GetAlternateLookup>().TryGetValue(name, out symbol); + + private ExpressionContext Add(string name, Symbol symbol) + { + ValidateName(name); + _symbols[name] = symbol; + UpdateLongestName(); + return this; + } + + private void AddBuiltIn(string name, Func function) => + _symbols[name] = new FunctionSymbol(1, 1, Unary: function, IsPure: true); + + private void AddBuiltIn(string name, Func function) => + _symbols[name] = new FunctionSymbol(2, 2, Binary: function, IsPure: true); + + private void UpdateLongestName() => LongestNameLength = _symbols.Keys.Max(key => key.Length); + + private static void ValidateName(string name) + { + ArgumentNullException.ThrowIfNull(name); + if (name.Length == 0 || !char.IsAsciiLetter(name[0]) || !name.All(char.IsAsciiLetterOrDigit)) + throw new ArgumentException( + "A name must start with an ASCII letter and contain only ASCII letters and digits.", nameof(name)); + } + + private static double Min(ReadOnlySpan values) + { + var min = values[0]; + foreach (var value in values[1..]) + min = Math.Min(min, value); + return min; + } + + private static double Max(ReadOnlySpan values) + { + var max = values[0]; + foreach (var value in values[1..]) + max = Math.Max(max, value); + return max; + } +} + +internal abstract record Symbol; + +internal sealed record ConstantSymbol(double Value) : Symbol; + +/// +/// A variable: either declared in the context (multi-letter names) or an implicit single letter. +/// +internal sealed record VariableSymbol(bool IsDeclared) : Symbol +{ + public static VariableSymbol Declared { get; } = new(true); + + public static VariableSymbol Implicit { get; } = new(false); +} + +internal sealed record FunctionSymbol( + int MinArguments, + int MaxArguments, + Func? Unary = null, + Func? Binary = null, + Func, double>? Variadic = null, + bool IsPure = false) : Symbol; diff --git a/src/SharpMath/Expressions/ExpressionParser.cs b/src/SharpMath/Expressions/ExpressionParser.cs new file mode 100644 index 0000000..13fc515 --- /dev/null +++ b/src/SharpMath/Expressions/ExpressionParser.cs @@ -0,0 +1,278 @@ +namespace SharpMath.Expressions; + +/// +/// A recursive-descent parser for mathematical expressions. +/// +/// +/// Grammar, from lowest to highest precedence: +/// +/// additive = multiplicative { ("+" | "-") multiplicative } +/// multiplicative = unary { ("*" | "/" | "%") unary | implicit power } +/// unary = ("+" | "-") unary | power +/// power = postfix [ "^" unary ] (right-associative) +/// postfix = primary { "!" } +/// primary = number | constant | variable | function "(" [ additive { "," additive } ] ")" | "(" additive ")" +/// +/// Implicit multiplication applies when an operand is directly followed by a name or an opening parenthesis. +/// +internal sealed class ExpressionParser +{ + /// + /// The maximum nesting of parentheses, signs and exponents. Parsing recurses several frames per level. + /// + public const int MaxNesting = 256; + + /// + /// The maximum height of the expression tree, e.g. the number of terms in a sum. Evaluation recurses one frame + /// per level. Both limits prevent stack overflows, which cannot be caught. + /// + public const int MaxTreeDepth = 4096; + + private readonly string _text; + private readonly List _tokens; + private int _depth; + private readonly Dictionary _variableSlots = new(StringComparer.OrdinalIgnoreCase); + private readonly List _variableNames = []; + private int _index; + + private ExpressionParser(string text, ExpressionContext context) + { + _text = text; + _tokens = Tokenizer.Tokenize(text, context); + } + + private Token Current => _tokens[_index]; + + public static (Node Root, IReadOnlyList Variables) Parse(string text, ExpressionContext context) + { + if (string.IsNullOrWhiteSpace(text)) + throw new ParserException("The expression is empty.", 0); + + var parser = new ExpressionParser(text, context); + var root = parser.ParseAdditive(); + if (parser.Current.Kind != TokenKind.End) + throw parser.Unexpected(parser.Current); + return (root, parser._variableNames.AsReadOnly()); + } + + private Node ParseAdditive() + { + Enter(); + var left = ParseMultiplicative(); + while (Current.Kind is TokenKind.Plus or TokenKind.Minus) + { + var op = Next().Kind == TokenKind.Plus ? BinaryOperator.Add : BinaryOperator.Subtract; + left = Binary(op, left, ParseMultiplicative()); + } + + _depth--; + return left; + } + + private Node ParseMultiplicative() + { + var left = ParseUnary(); + while (true) + { + switch (Current.Kind) + { + case TokenKind.Star: + Next(); + left = Binary(BinaryOperator.Multiply, left, ParseUnary()); + break; + case TokenKind.Slash: + Next(); + left = Binary(BinaryOperator.Divide, left, ParseUnary()); + break; + case TokenKind.Percent: + Next(); + left = Binary(BinaryOperator.Remainder, left, ParseUnary()); + break; + case TokenKind.Name or TokenKind.OpenParenthesis: + left = Binary(BinaryOperator.Multiply, left, ParsePower()); + break; + case TokenKind.Number: + throw new ParserException("A number cannot directly follow another operand; add an operator.", + Current.Position); + default: + return left; + } + } + } + + private Node ParseUnary() + { + Enter(); + Node result; + switch (Current.Kind) + { + case TokenKind.Plus: + Next(); + result = ParseUnary(); + break; + case TokenKind.Minus: + var position = Next().Position; + var operand = ParseUnary(); + result = operand is ConstantNode constant + ? new ConstantNode(-constant.Value) + : Limit(new NegateNode(operand), position); + break; + default: + result = ParsePower(); + break; + } + + _depth--; + return result; + } + + private Node ParsePower() + { + var basis = ParsePostfix(); + if (Current.Kind != TokenKind.Caret) + return basis; + + Next(); + return Binary(BinaryOperator.Power, basis, ParseUnary()); + } + + private Node ParsePostfix() + { + var operand = ParsePrimary(); + while (Current.Kind == TokenKind.Exclamation) + { + var position = Next().Position; + operand = operand is ConstantNode constant + ? new ConstantNode(FactorialNode.Factorial(constant.Value)) + : Limit(new FactorialNode(operand), position); + } + + return operand; + } + + private Node ParsePrimary() + { + var token = Next(); + switch (token.Kind) + { + case TokenKind.Number: + return new ConstantNode(token.Number); + case TokenKind.OpenParenthesis: + var inner = ParseAdditive(); + Expect(TokenKind.CloseParenthesis, token); + return inner; + case TokenKind.Name: + return token.Symbol switch + { + ConstantSymbol constant => new ConstantNode(constant.Value), + FunctionSymbol function => ParseFunction(token, function), + _ => Variable(token) + }; + default: + throw Unexpected(token); + } + } + + private Node ParseFunction(Token nameToken, FunctionSymbol function) + { + var name = _text.Substring(nameToken.Position, nameToken.Length); + if (Current.Kind != TokenKind.OpenParenthesis) + throw new ParserException($"The function '{name}' must be followed by '('.", Current.Position); + var openParenthesis = Next(); + + var arguments = new List(); + if (Current.Kind != TokenKind.CloseParenthesis) + { + arguments.Add(ParseAdditive()); + while (Current.Kind == TokenKind.Comma) + { + Next(); + arguments.Add(ParseAdditive()); + } + } + + Expect(TokenKind.CloseParenthesis, openParenthesis); + + if (arguments.Count < function.MinArguments || arguments.Count > function.MaxArguments) + throw new ParserException( + $"The function '{name}' expects {DescribeArity(function)}, but got {arguments.Count}.", + nameToken.Position); + + Node node = function switch + { + { Unary: { } unary } => new UnaryFunctionNode(unary, arguments[0]), + { Binary: { } binary } => new BinaryFunctionNode(binary, arguments[0], arguments[1]), + _ => new VariadicFunctionNode(function.Variadic!, [.. arguments]) + }; + + return function.IsPure && arguments.TrueForAll(argument => argument is ConstantNode) + ? new ConstantNode(node.Evaluate([])) + : Limit(node, nameToken.Position); + } + + private VariableNode Variable(Token token) + { + var name = _text.Substring(token.Position, token.Length).ToLowerInvariant(); + if (!_variableSlots.TryGetValue(name, out var slot)) + { + slot = _variableNames.Count; + _variableSlots.Add(name, slot); + _variableNames.Add(name); + } + + return new VariableNode(slot); + } + + private Node Binary(BinaryOperator op, Node left, Node right) => + left is ConstantNode l && right is ConstantNode r + ? new ConstantNode(BinaryNode.Apply(op, l.Value, r.Value)) + : Limit(new BinaryNode(op, left, right), Current.Position); + + private void Enter() + { + if (++_depth > MaxNesting) + throw new ParserException($"The expression is nested more than {MaxNesting} levels deep.", Current.Position); + } + + private static T Limit(T node, int position) where T : Node => + node.Depth <= MaxTreeDepth + ? node + : throw new ParserException($"The expression is too complex (more than {MaxTreeDepth} nested operations).", + position); + + private Token Next() + { + var token = _tokens[_index]; + if (token.Kind != TokenKind.End) + _index++; + return token; + } + + private void Expect(TokenKind kind, Token openingToken) + { + if (Current.Kind == kind) + { + Next(); + return; + } + + if (Current.Kind == TokenKind.End) + throw new ParserException($"The parenthesis opened at position {openingToken.Position} is not closed.", + Current.Position); + throw Unexpected(Current); + } + + private ParserException Unexpected(Token token) => token.Kind switch + { + TokenKind.End => new ParserException("Unexpected end of expression.", token.Position), + TokenKind.CloseParenthesis => new ParserException("Unmatched ')'.", token.Position), + _ => new ParserException($"Unexpected '{_text.Substring(token.Position, token.Length)}'.", token.Position) + }; + + private static string DescribeArity(FunctionSymbol function) => + function.MinArguments == function.MaxArguments + ? $"{function.MinArguments} argument(s)" + : function.MaxArguments == int.MaxValue + ? $"at least {function.MinArguments} argument(s)" + : $"{function.MinArguments} to {function.MaxArguments} arguments"; +} diff --git a/src/SharpMath/Expressions/MathExpression.cs b/src/SharpMath/Expressions/MathExpression.cs new file mode 100644 index 0000000..5551d1d --- /dev/null +++ b/src/SharpMath/Expressions/MathExpression.cs @@ -0,0 +1,155 @@ +namespace SharpMath.Expressions; + +/// +/// Represents a parsed mathematical expression that can be evaluated many times, e.g. 2x^2 - 3x + sin(x). +/// +/// +/// +/// Supported syntax: numbers (12, 1.5, .5, 1e5), the operators + - * / % ^ +/// (^ is right-associative and binds tighter than unary minus, so -2^2 = -4), postfix factorial +/// !, parentheses, function calls with comma-separated arguments, constants and variables (see +/// ). +/// +/// +/// Multiplication may be implicit when an operand is directly followed by a name or an opening parenthesis: +/// 2x, 2pi, 3(x+1), (x+1)(x-1). It has the same precedence as *, so +/// 1/2x is (1/2)·x. Letter sequences are split by longest match against the known names, and any +/// other single letter is a variable: xy is x·y, pix is pi·x. +/// +/// Instances are immutable and thread-safe. Evaluation does not allocate. +/// +public sealed class MathExpression +{ + private readonly Node _root; + + private MathExpression(string text, Node root, IReadOnlyList variables) + { + Text = text; + _root = root; + Variables = variables; + } + + /// + /// Gets the source text. + /// + public string Text { get; } + + /// + /// Gets the names of the variables used in the expression, in lower case and in order of first occurrence. + /// + public IReadOnlyList Variables { get; } + + /// + /// Parses an expression. + /// + /// The expression. + /// The functions, constants and variables to use; the built-ins if . + /// The parsed expression. + /// The expression is invalid; tells where. + public static MathExpression Parse(string text, ExpressionContext? context = null) + { + ArgumentNullException.ThrowIfNull(text); + var (root, variables) = ExpressionParser.Parse(text, context ?? ExpressionContext.Default); + return new MathExpression(text, root, variables); + } + + /// + /// Evaluates an expression without variables. + /// + /// The result. + /// The expression uses variables. + public double Evaluate() + { + if (Variables.Count > 0) + throw MissingVariables(Variables); + return _root.Evaluate([]); + } + + /// + /// Evaluates an expression with at most one variable. + /// + /// The name of the variable (case-insensitive). + /// The value of the variable. + /// The result. + /// The expression uses another variable. + public double Evaluate(string name, double value) + { + ArgumentNullException.ThrowIfNull(name); + switch (Variables.Count) + { + case 0: + return _root.Evaluate([]); + case 1 when string.Equals(Variables[0], name, StringComparison.OrdinalIgnoreCase): + return _root.Evaluate([value]); + default: + throw MissingVariables(Variables.Where(variable => + !string.Equals(variable, name, StringComparison.OrdinalIgnoreCase))); + } + } + + /// + /// Evaluates the expression with the specified variable values. Names are matched case-insensitively; unused + /// entries are ignored. + /// + /// The values of the variables. + /// The result. + /// A variable used in the expression has no value. + public double Evaluate(IReadOnlyDictionary variables) + { + ArgumentNullException.ThrowIfNull(variables); + Span values = Variables.Count <= 32 ? stackalloc double[Variables.Count] : new double[Variables.Count]; + List? missing = null; + for (var i = 0; i < Variables.Count; i++) + { + if (TryFind(variables, Variables[i], out var value)) + values[i] = value; + else + (missing ??= []).Add(Variables[i]); + } + + if (missing is not null) + throw MissingVariables(missing); + return _root.Evaluate(values); + } + + /// + /// Creates a function of one variable, e.g. for plotting. + /// + /// The name of the variable (case-insensitive); the expression may also not use it at all. + /// A delegate that evaluates the expression without allocating. + /// The expression uses other variables. + public Func ToFunc(string variable) + { + ArgumentNullException.ThrowIfNull(variable); + var others = Variables.Where(name => !string.Equals(name, variable, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (others.Count > 0) + throw MissingVariables(others); + + var root = _root; + if (Variables.Count == 0) + return _ => root.Evaluate([]); + return x => root.Evaluate([x]); + } + + /// + public override string ToString() => Text; + + private static bool TryFind(IReadOnlyDictionary variables, string name, out double value) + { + if (variables.TryGetValue(name, out value)) + return true; + foreach (var (key, candidate) in variables) + { + if (!string.Equals(key, name, StringComparison.OrdinalIgnoreCase)) + continue; + value = candidate; + return true; + } + + return false; + } + + private static ArgumentException MissingVariables(IEnumerable names) => + new($"No value was given for the variable(s): {string.Join(", ", names)}."); +} diff --git a/src/SharpMath/Expressions/Nodes.cs b/src/SharpMath/Expressions/Nodes.cs new file mode 100644 index 0000000..b5625fb --- /dev/null +++ b/src/SharpMath/Expressions/Nodes.cs @@ -0,0 +1,118 @@ +namespace SharpMath.Expressions; + +/// +/// A node of a parsed expression tree. Variables are resolved to slot indices at parse time, so evaluation does not +/// allocate. +/// +internal abstract class Node(int depth) +{ + /// + /// Gets the height of the tree below this node (1 for leaves); evaluation recurses this deep. + /// + public int Depth { get; } = depth; + + public abstract double Evaluate(ReadOnlySpan variables); + + protected static int DepthOf(Node[] children) + { + var depth = 0; + foreach (var child in children) + depth = Math.Max(depth, child.Depth); + return depth + 1; + } +} + +internal sealed class ConstantNode(double value) : Node(1) +{ + public double Value { get; } = value; + + public override double Evaluate(ReadOnlySpan variables) => Value; +} + +internal sealed class VariableNode(int slot) : Node(1) +{ + public override double Evaluate(ReadOnlySpan variables) => variables[slot]; +} + +internal sealed class NegateNode(Node operand) : Node(operand.Depth + 1) +{ + public override double Evaluate(ReadOnlySpan variables) => -operand.Evaluate(variables); +} + +internal sealed class FactorialNode(Node operand) : Node(operand.Depth + 1) +{ + private static readonly double[] Factorials = CreateFactorials(); + + public override double Evaluate(ReadOnlySpan variables) => Factorial(operand.Evaluate(variables)); + + public static double Factorial(double value) + { + if (!(value >= 0) || value != Math.Floor(value)) + return double.NaN; + return value < Factorials.Length ? Factorials[(int) value] : double.PositiveInfinity; + } + + private static double[] CreateFactorials() + { + // 170! is the largest factorial representable as a double. + var factorials = new double[171]; + factorials[0] = 1; + for (var i = 1; i < factorials.Length; i++) + factorials[i] = factorials[i - 1] * i; + return factorials; + } +} + +internal enum BinaryOperator +{ + Add, + Subtract, + Multiply, + Divide, + Remainder, + Power +} + +internal sealed class BinaryNode(BinaryOperator op, Node left, Node right) : Node(Math.Max(left.Depth, right.Depth) + 1) +{ + public override double Evaluate(ReadOnlySpan variables) => + Apply(op, left.Evaluate(variables), right.Evaluate(variables)); + + public static double Apply(BinaryOperator op, double left, double right) => op switch + { + BinaryOperator.Add => left + right, + BinaryOperator.Subtract => left - right, + BinaryOperator.Multiply => left * right, + BinaryOperator.Divide => left / right, + BinaryOperator.Remainder => left % right, + _ => Math.Pow(left, right) + }; +} + +internal sealed class UnaryFunctionNode(Func function, Node argument) : Node(argument.Depth + 1) +{ + public override double Evaluate(ReadOnlySpan variables) => function(argument.Evaluate(variables)); +} + +internal sealed class BinaryFunctionNode(Func function, Node first, Node second) + : Node(Math.Max(first.Depth, second.Depth) + 1) +{ + public override double Evaluate(ReadOnlySpan variables) => + function(first.Evaluate(variables), second.Evaluate(variables)); +} + +internal sealed class VariadicFunctionNode(Func, double> function, Node[] arguments) + : Node(DepthOf(arguments)) +{ + private const int StackLimit = 32; + + public override double Evaluate(ReadOnlySpan variables) + { + Span values = arguments.Length <= StackLimit + ? stackalloc double[arguments.Length] + : new double[arguments.Length]; + for (var i = 0; i < arguments.Length; i++) + values[i] = arguments[i].Evaluate(variables); + return function(values); + } +} diff --git a/src/SharpMath/Expressions/ParserException.cs b/src/SharpMath/Expressions/ParserException.cs new file mode 100644 index 0000000..167e73f --- /dev/null +++ b/src/SharpMath/Expressions/ParserException.cs @@ -0,0 +1,48 @@ +namespace SharpMath.Expressions; + +/// +/// The exception that is thrown when an expression cannot be parsed. +/// +public class ParserException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public ParserException() : this("The expression is invalid.") + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + public ParserException(string message) : base(message) + { + Position = -1; + } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + /// The exception that caused this exception. + public ParserException(string message, Exception innerException) : base(message, innerException) + { + Position = -1; + } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + /// The zero-based position in the expression where the error was detected. + public ParserException(string message, int position) : base($"{message} (at position {position})") + { + Position = position; + } + + /// + /// Gets the zero-based position in the expression where the error was detected, or −1 if unknown. + /// + public int Position { get; } +} diff --git a/src/SharpMath/Expressions/Tokenizer.cs b/src/SharpMath/Expressions/Tokenizer.cs new file mode 100644 index 0000000..8897258 --- /dev/null +++ b/src/SharpMath/Expressions/Tokenizer.cs @@ -0,0 +1,150 @@ +using System.Globalization; + +namespace SharpMath.Expressions; + +internal enum TokenKind +{ + Number, + Name, + Plus, + Minus, + Star, + Slash, + Percent, + Caret, + Exclamation, + OpenParenthesis, + CloseParenthesis, + Comma, + End +} + +internal readonly record struct Token(TokenKind Kind, int Position, int Length, double Number = 0, Symbol? Symbol = null); + +/// +/// Splits an expression into tokens. Runs of letters are split into names by longest match against the context +/// (functions, constants, declared variables); any remaining single letter becomes a variable. Digits after a +/// letter that are not part of a known name start a number. +/// +internal static class Tokenizer +{ + public static List Tokenize(string text, ExpressionContext context) + { + var tokens = new List(); + var position = 0; + while (position < text.Length) + { + var current = text[position]; + if (char.IsWhiteSpace(current)) + { + position++; + continue; + } + + if (char.IsAsciiDigit(current) || current == '.') + { + tokens.Add(ReadNumber(text, ref position)); + continue; + } + + if (char.IsAsciiLetter(current)) + { + ReadNames(text, context, ref position, tokens); + continue; + } + + var kind = current switch + { + '+' => TokenKind.Plus, + '-' => TokenKind.Minus, + '*' => TokenKind.Star, + '/' => TokenKind.Slash, + '%' => TokenKind.Percent, + '^' => TokenKind.Caret, + '!' => TokenKind.Exclamation, + '(' => TokenKind.OpenParenthesis, + ')' => TokenKind.CloseParenthesis, + ',' => TokenKind.Comma, + _ => throw new ParserException($"Unexpected character '{current}'.", position) + }; + tokens.Add(new Token(kind, position, 1)); + position++; + } + + tokens.Add(new Token(TokenKind.End, text.Length, 0)); + return tokens; + } + + private static Token ReadNumber(string text, ref int position) + { + var start = position; + while (position < text.Length && char.IsAsciiDigit(text[position])) + position++; + if (position < text.Length && text[position] == '.') + { + position++; + while (position < text.Length && char.IsAsciiDigit(text[position])) + position++; + } + + // Scientific notation only if the exponent has digits; "2e" is 2·e. + if (position < text.Length && text[position] is 'e' or 'E') + { + var exponent = position + 1; + if (exponent < text.Length && text[exponent] is '+' or '-') + exponent++; + if (exponent < text.Length && char.IsAsciiDigit(text[exponent])) + { + position = exponent; + while (position < text.Length && char.IsAsciiDigit(text[position])) + position++; + } + } + + var literal = text.AsSpan(start, position - start); + if (!double.TryParse(literal, NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent, + CultureInfo.InvariantCulture, out var value)) + throw new ParserException($"'{literal}' is not a valid number.", start); + return new Token(TokenKind.Number, start, position - start, value); + } + + /// + /// Splits a run of letters and digits into names. A run of two or more characters directly in front of "(" must + /// end with a function or a known name; otherwise it is reported as an unknown function. + /// + private static void ReadNames(string text, ExpressionContext context, ref int position, List tokens) + { + var runStart = position; + var runEnd = position; + while (runEnd < text.Length && char.IsAsciiLetterOrDigit(text[runEnd])) + runEnd++; + + while (position < runEnd && char.IsAsciiLetter(text[position])) + tokens.Add(ReadName(text, context, runEnd, ref position)); + + var next = runEnd; + while (next < text.Length && char.IsWhiteSpace(text[next])) + next++; + var lastSymbol = tokens[^1].Symbol; + if (position == runEnd && runEnd - runStart >= 2 && next < text.Length && text[next] == '(' && + lastSymbol is VariableSymbol { IsDeclared: false }) + throw new ParserException($"Unknown function '{text[runStart..runEnd]}'.", runStart); + } + + private static Token ReadName(string text, ExpressionContext context, int runEnd, ref int position) + { + var start = position; + var maxLength = Math.Min(runEnd - start, context.LongestNameLength); + for (var length = maxLength; length > 0; length--) + { + if (!context.TryGetSymbol(text.AsSpan(start, length), out var symbol)) + continue; + position += length; + return new Token(TokenKind.Name, start, length, Symbol: symbol); + } + + // Undefined single letter: an implicit variable. + position++; + return new Token(TokenKind.Name, start, 1, Symbol: VariableSymbol.Implicit); + } +} diff --git a/tests/SharpMath.Tests/Expressions/ExpressionContextTests.cs b/tests/SharpMath.Tests/Expressions/ExpressionContextTests.cs new file mode 100644 index 0000000..7bb0bcc --- /dev/null +++ b/tests/SharpMath.Tests/Expressions/ExpressionContextTests.cs @@ -0,0 +1,106 @@ +using SharpMath.Expressions; + +namespace SharpMath.Tests.Expressions; + +public class ExpressionContextTests +{ + [Fact] + public void CustomFunctionsAndConstants() + { + var context = new ExpressionContext() + .AddFunction("sec", x => 1 / Math.Cos(x)) + .AddFunction("hypot", (a, b) => Math.Sqrt(a * a + b * b)) + .AddFunction("sum", 0, int.MaxValue, values => + { + var sum = 0.0; + foreach (var value in values) + sum += value; + return sum; + }) + .AddConstant("phi", (1 + Math.Sqrt(5)) / 2); + + Assert.Equal(1, MathExpression.Parse("sec(0)", context).Evaluate()); + Assert.Equal(5, MathExpression.Parse("hypot(3, 4)", context).Evaluate()); + Assert.Equal(0, MathExpression.Parse("sum()", context).Evaluate()); + Assert.Equal(6, MathExpression.Parse("sum(1, 2, 3)", context).Evaluate()); + Assert.Equal((1 + Math.Sqrt(5)) / 2, MathExpression.Parse("PHI", context).Evaluate()); + } + + [Fact] + public void LongestMatch_PrefersLongerNames() + { + // Regression: the old tokenizer stopped at the shortest match, so "sinh" was read as "sin" + "h". + Assert.Equal(Math.Sinh(1), MathExpression.Parse("sinh(1)").Evaluate(), 12); + var context = new ExpressionContext().AddFunction("expm", x => Math.Exp(x) - 1); + Assert.Equal(0, MathExpression.Parse("expm(0)", context).Evaluate()); + } + + [Fact] + public void DeclaredVariables_MayHaveSeveralLetters() + { + var context = new ExpressionContext().DeclareVariable("speed").DeclareVariable("t1"); + var expression = MathExpression.Parse("speed * t1", context); + Assert.Equal(["speed", "t1"], expression.Variables); + Assert.Equal(6, expression.Evaluate(new Dictionary { ["speed"] = 2, ["T1"] = 3 })); + } + + [Fact] + public void Contexts_AreIndependentAndCapturedAtParseTime() + { + var context = new ExpressionContext().AddConstant("k", 2); + var expression = MathExpression.Parse("k", context); + context.AddConstant("k", 3); + Assert.Equal(2, expression.Evaluate()); + Assert.Equal(3, MathExpression.Parse("k", context).Evaluate()); + Assert.Equal(["k"], MathExpression.Parse("k").Variables); + } + + [Fact] + public void Clone_CreatesAnIndependentCopy() + { + var original = new ExpressionContext().AddConstant("k", 2).DeclareVariable("speed"); + var clone = original.Clone().AddConstant("k", 5); + Assert.Equal(2, MathExpression.Parse("k", original).Evaluate()); + Assert.Equal(5, MathExpression.Parse("k", clone).Evaluate()); + Assert.Equal(["speed"], MathExpression.Parse("speed", clone).Variables); + Assert.Equal(1, MathExpression.Parse("sin(pi/2)", clone).Evaluate(), 12); + Assert.Equal(6, MathExpression.Parse("speed(2)", clone).Evaluate("speed", 3)); + } + + [Fact] + public void ImpureFunctions_AreNotFolded() + { + var calls = 0; + var context = new ExpressionContext().AddFunction("count", _ => ++calls); + var expression = MathExpression.Parse("count(1)", context); + Assert.Equal(0, calls); + Assert.Equal(1, expression.Evaluate()); + Assert.Equal(2, expression.Evaluate()); + } + + [Theory] + [InlineData("")] + [InlineData("1x")] + [InlineData("a b")] + [InlineData("ä")] + [InlineData("a_b")] + public void Names_AreValidated(string name) + { + var context = new ExpressionContext(); + Assert.Throws(() => context.AddConstant(name, 1)); + } + + [Fact] + public void Arguments_AreValidated() + { + var context = new ExpressionContext(); + Assert.Throws(() => context.AddConstant(null!, 1)); + Assert.Throws(() => context.AddFunction("f", (Func) null!)); + Assert.Throws(() => context.AddFunction("f", (Func) null!)); + Assert.Throws(() => context.AddFunction("f", 1, 2, null!)); + Assert.Throws(() => context.AddFunction("f", -1, 2, _ => 0)); + Assert.Throws(() => context.AddFunction("f", 3, 2, _ => 0)); + Assert.Throws(() => MathExpression.Parse("f(1, 2, 3)", + new ExpressionContext().AddFunction("f", 1, 2, _ => 0))); + } +} diff --git a/tests/SharpMath.Tests/Expressions/MathExpressionTests.cs b/tests/SharpMath.Tests/Expressions/MathExpressionTests.cs new file mode 100644 index 0000000..720713b --- /dev/null +++ b/tests/SharpMath.Tests/Expressions/MathExpressionTests.cs @@ -0,0 +1,207 @@ +using SharpMath.Expressions; + +namespace SharpMath.Tests.Expressions; + +public class MathExpressionTests +{ + private static double Eval(string text) => MathExpression.Parse(text).Evaluate(); + + [Theory] + [InlineData("(-2+3)*(cos(0)*(2/3))", 2.0 / 3.0)] + [InlineData("3*4^2", 48)] + [InlineData("2^3^2", 512)] + [InlineData("-2^2", -4)] + [InlineData("(-2)^2", 4)] + [InlineData("10 - 4 - 3", 3)] + [InlineData("12 / 3 / 2", 2)] + [InlineData("1 + 2 * 3", 7)] + [InlineData(" 7 ", 7)] + public void Evaluate_RespectsPrecedenceAndAssociativity(string text, double expected) => + Assert.Equal(expected, Eval(text), 12); + + [Theory] + [InlineData("5-(-3)", 8)] + [InlineData("1-(-1)", 2)] + [InlineData("4-(-2)*3", 10)] + [InlineData("+3", 3)] + [InlineData("(+3)", 3)] + [InlineData("2*(+3)", 6)] + [InlineData("2*-3", -6)] + [InlineData("2^-1", 0.5)] + [InlineData("--3", 3)] + [InlineData("-(-(-3))", -3)] + public void Evaluate_HandlesSignsEverywhere(string text, double expected) => + Assert.Equal(expected, Eval(text), 12); + + [Theory] + [InlineData("5%3", 2)] + [InlineData("-7%3", -1)] + [InlineData("2*5%3", 1)] + [InlineData("5!", 120)] + [InlineData("0!", 1)] + [InlineData("3!^2", 36)] + [InlineData("2^3!", 64)] + [InlineData(".5 + 1.", 1.5)] + [InlineData("1e3", 1000)] + [InlineData("2.5E-3", 0.0025)] + [InlineData("1e+2", 100)] + public void Evaluate_SupportsOperatorsAndNumberFormats(string text, double expected) => + Assert.Equal(expected, Eval(text), 12); + + [Theory] + [InlineData("log(8, 2)", 3)] + [InlineData("atan2(1, 1)", Math.PI / 4)] + [InlineData("pow(2, 10)", 1024)] + [InlineData("min(3, -1, 2)", -1)] + [InlineData("max(3, -1, 2)", 3)] + [InlineData("max(4)", 4)] + [InlineData("sqrt(16) + abs(-2)", 6)] + [InlineData("ln(e) + lg(100) + log2(8)", 6)] + [InlineData("exp(0) + floor(2.7) + ceil(2.1) + round(2.5) + round(-2.5)", 6)] + [InlineData("sign(-5) + sign(0) + sign(3)", 0)] + [InlineData("sin(0) + cos(0) + tan(0)", 1)] + [InlineData("asin(1) + acos(1) + atan(0)", Math.PI / 2)] + [InlineData("sinh(0) + cosh(0) + tanh(0)", 1)] + [InlineData("SIN(PI/2)", 1)] + [InlineData("tau", Math.Tau)] + [InlineData("sin (pi / 2)", 1)] + public void Evaluate_KnowsBuiltIns(string text, double expected) => Assert.Equal(expected, Eval(text), 12); + + [Fact] + public void Factorial_IsNaNOutsideItsDomain() + { + Assert.True(double.IsNaN(Eval("2.5!"))); + Assert.True(double.IsNaN(Eval("(-1)!"))); + Assert.True(double.IsPositiveInfinity(Eval("171!"))); + Assert.True(double.IsNaN(Eval("sign(0/0)"))); + } + + [Theory] + [InlineData("2x", 3, 6)] + [InlineData("2x^2", 3, 18)] + [InlineData("3(x+1)", 3, 12)] + [InlineData("(x+1)(x-1)", 3, 8)] + [InlineData("2pi", 0, 2 * Math.PI)] + [InlineData("pix", 2, 2 * Math.PI)] + [InlineData("2sin(x)", 0, 0)] + [InlineData("x!", 3, 6)] + [InlineData("1/2x", 4, 2)] + [InlineData("2e", 0, 2 * Math.E)] + [InlineData("exp(x)", 0, 1)] + [InlineData("-x^2", 3, -9)] + public void Evaluate_SupportsImplicitMultiplication(string text, double x, double expected) => + Assert.Equal(expected, MathExpression.Parse(text).Evaluate("x", x), 12); + + [Fact] + public void Variables_AreCollectedInOrderAndCaseInsensitive() + { + var expression = MathExpression.Parse("x*Y + y - X + z"); + Assert.Equal(["x", "y", "z"], expression.Variables); + var result = expression.Evaluate(new Dictionary { ["X"] = 2, ["y"] = 3, ["Z"] = 1, ["w"] = 9 }); + Assert.Equal(2 * 3 + 3 - 2 + 1, result); + Assert.Equal(6, MathExpression.Parse("xy").Evaluate(new Dictionary { ["x"] = 2, ["y"] = 3 })); + } + + [Fact] + public void Evaluate_ReportsMissingVariables() + { + var expression = MathExpression.Parse("x + y"); + var exception = Assert.Throws(() => + expression.Evaluate(new Dictionary { ["x"] = 1 })); + Assert.Contains("y", exception.Message, StringComparison.Ordinal); + Assert.Throws(() => expression.Evaluate()); + Assert.Throws(() => expression.Evaluate("x", 1)); + Assert.Throws(() => MathExpression.Parse("y").Evaluate("x", 1)); + Assert.Equal(4, MathExpression.Parse("4").Evaluate("x", 1)); + Assert.Equal(2, MathExpression.Parse("X").Evaluate("x", 2)); + Assert.Throws(() => expression.Evaluate((IReadOnlyDictionary) null!)); + Assert.Throws(() => expression.Evaluate(null!, 1)); + } + + [Fact] + public void ToFunc_CreatesReusableDelegate() + { + var function = MathExpression.Parse("x^2 + 1").ToFunc("x"); + Assert.Equal(1, function(0)); + Assert.Equal(10, function(3)); + Assert.Equal(5, MathExpression.Parse("5").ToFunc("x")(42)); + Assert.Throws(() => MathExpression.Parse("x + y").ToFunc("x")); + Assert.Throws(() => MathExpression.Parse("x").ToFunc(null!)); + } + + [Fact] + public void TextAndToString_ReturnSource() + { + var expression = MathExpression.Parse("1 + x"); + Assert.Equal("1 + x", expression.Text); + Assert.Equal("1 + x", expression.ToString()); + Assert.Throws(() => MathExpression.Parse(null!)); + } + + [Theory] + [InlineData("", 0)] + [InlineData(" ", 0)] + [InlineData("3+", 2)] + [InlineData("*3", 0)] + [InlineData("()", 1)] + [InlineData("3+4)", 3)] + [InlineData("(3+4", 4)] + [InlineData("((3)", 4)] + [InlineData("2 3", 2)] + [InlineData("x2", 1)] + [InlineData("sin()", 0)] + [InlineData("sin 5", 4)] + [InlineData("log(8)", 0)] + [InlineData("atan2(1, 2, 3)", 0)] + [InlineData("3 # 4", 2)] + [InlineData(".", 0)] + [InlineData("1,2", 1)] + [InlineData("min()", 0)] + public void Parse_ReportsErrorsWithPosition(string text, int position) + { + var exception = Assert.Throws(() => MathExpression.Parse(text)); + Assert.Equal(position, exception.Position); + } + + [Theory] + [InlineData("cot(x)", 0)] + [InlineData("1 + foo(2)", 4)] + [InlineData("xy(2)", 0)] + [InlineData("2 * sinx (2)", 4)] + public void Parse_ReportsUnknownFunctions(string text, int position) + { + var exception = Assert.Throws(() => MathExpression.Parse(text)); + Assert.Equal(position, exception.Position); + Assert.Contains("Unknown function", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("x(x+1)", 2, 6)] + [InlineData("pi(2)", 0, 2 * Math.PI)] + [InlineData("xsin(x)", 0, 0)] + [InlineData("2 (x)", 3, 6)] + public void Parentheses_AfterVariablesAndConstants_StillMultiply(string text, double x, double expected) => + Assert.Equal(expected, MathExpression.Parse(text).Evaluate("x", x), 12); + + [Fact] + public void DeeplyNestedOrHugeInput_IsRejectedInsteadOfCrashing() + { + Assert.Throws(() => MathExpression.Parse(new string('(', 300) + "1" + new string(')', 300))); + Assert.Throws(() => MathExpression.Parse(new string('-', 2000) + "x")); + Assert.Throws(() => MathExpression.Parse(string.Join("^", Enumerable.Repeat("x", 2000)))); + Assert.Throws(() => MathExpression.Parse(string.Join("+", Enumerable.Repeat("x", 5000)))); + + Assert.Equal(1, MathExpression.Parse(new string('(', 100) + "1" + new string(')', 100)).Evaluate()); + Assert.Equal(3000, MathExpression.Parse(string.Join("+", Enumerable.Repeat("x", 3000))).Evaluate("x", 1)); + Assert.Equal(5000, MathExpression.Parse(string.Join("+", Enumerable.Repeat("1", 5000))).Evaluate()); + } + + [Fact] + public void ParserException_HasStandardConstructors() + { + Assert.Equal(-1, new ParserException().Position); + Assert.Equal(-1, new ParserException("x").Position); + Assert.Equal(-1, new ParserException("x", new InvalidOperationException()).Position); + Assert.Contains("position 3", new ParserException("x", 3).Message, StringComparison.Ordinal); + } +} From 086c99e1e0536049ed53759deeff4a353d567d6f Mon Sep 17 00:00:00 2001 From: Dominic Beger Date: Sun, 11 Oct 2026 02:17:17 +0200 Subject: [PATCH 05/14] Add platform-neutral canvas model with SVG renderer Canvas2D now renders through IDrawingContext and owns viewport, grid, nice-number ticks, function sampling with pole detection, clipping and input handling. Fixes the zoom drift and overflows from issue #3 (Fixes #3). Adds geometry and expression items and a configurable style. --- SharpMath.slnx | 2 + src/SharpMath.Canvas/Canvas2D.cs | 309 ++++++++++++++++++ src/SharpMath.Canvas/CanvasItems.cs | 79 +++++ src/SharpMath.Canvas/CanvasStyle.cs | 116 +++++++ src/SharpMath.Canvas/Clipping.cs | 97 ++++++ src/SharpMath.Canvas/Color.cs | 66 ++++ src/SharpMath.Canvas/FunctionSampler.cs | 104 ++++++ src/SharpMath.Canvas/GridRenderer.cs | 99 ++++++ src/SharpMath.Canvas/IDrawingContext.cs | 88 +++++ src/SharpMath.Canvas/ItemRenderer.cs | 117 +++++++ src/SharpMath.Canvas/Primitives.cs | 100 ++++++ src/SharpMath.Canvas/SharpMath.Canvas.csproj | 11 + src/SharpMath.Canvas/SvgDrawingContext.cs | 173 ++++++++++ src/SharpMath.Canvas/SvgExport.cs | 35 ++ src/SharpMath.Canvas/Ticks.cs | 113 +++++++ src/SharpMath.Canvas/Viewport.cs | 197 +++++++++++ tests/SharpMath.Canvas.Tests/Canvas2DTests.cs | 270 +++++++++++++++ tests/SharpMath.Canvas.Tests/ClippingTests.cs | 51 +++ .../FunctionSamplerTests.cs | 63 ++++ tests/SharpMath.Canvas.Tests/Golden/empty.svg | 29 ++ tests/SharpMath.Canvas.Tests/Golden/items.svg | 63 ++++ .../RecordingDrawingContext.cs | 52 +++ .../SharpMath.Canvas.Tests.csproj | 11 + tests/SharpMath.Canvas.Tests/SvgTests.cs | 82 +++++ tests/SharpMath.Canvas.Tests/TicksTests.cs | 69 ++++ tests/SharpMath.Canvas.Tests/ViewportTests.cs | 127 +++++++ 26 files changed, 2523 insertions(+) create mode 100644 src/SharpMath.Canvas/Canvas2D.cs create mode 100644 src/SharpMath.Canvas/CanvasItems.cs create mode 100644 src/SharpMath.Canvas/CanvasStyle.cs create mode 100644 src/SharpMath.Canvas/Clipping.cs create mode 100644 src/SharpMath.Canvas/Color.cs create mode 100644 src/SharpMath.Canvas/FunctionSampler.cs create mode 100644 src/SharpMath.Canvas/GridRenderer.cs create mode 100644 src/SharpMath.Canvas/IDrawingContext.cs create mode 100644 src/SharpMath.Canvas/ItemRenderer.cs create mode 100644 src/SharpMath.Canvas/Primitives.cs create mode 100644 src/SharpMath.Canvas/SharpMath.Canvas.csproj create mode 100644 src/SharpMath.Canvas/SvgDrawingContext.cs create mode 100644 src/SharpMath.Canvas/SvgExport.cs create mode 100644 src/SharpMath.Canvas/Ticks.cs create mode 100644 src/SharpMath.Canvas/Viewport.cs create mode 100644 tests/SharpMath.Canvas.Tests/Canvas2DTests.cs create mode 100644 tests/SharpMath.Canvas.Tests/ClippingTests.cs create mode 100644 tests/SharpMath.Canvas.Tests/FunctionSamplerTests.cs create mode 100644 tests/SharpMath.Canvas.Tests/Golden/empty.svg create mode 100644 tests/SharpMath.Canvas.Tests/Golden/items.svg create mode 100644 tests/SharpMath.Canvas.Tests/RecordingDrawingContext.cs create mode 100644 tests/SharpMath.Canvas.Tests/SharpMath.Canvas.Tests.csproj create mode 100644 tests/SharpMath.Canvas.Tests/SvgTests.cs create mode 100644 tests/SharpMath.Canvas.Tests/TicksTests.cs create mode 100644 tests/SharpMath.Canvas.Tests/ViewportTests.cs diff --git a/SharpMath.slnx b/SharpMath.slnx index 44b90c3..418312d 100644 --- a/SharpMath.slnx +++ b/SharpMath.slnx @@ -1,8 +1,10 @@ + + diff --git a/src/SharpMath.Canvas/Canvas2D.cs b/src/SharpMath.Canvas/Canvas2D.cs new file mode 100644 index 0000000..eaa0374 --- /dev/null +++ b/src/SharpMath.Canvas/Canvas2D.cs @@ -0,0 +1,309 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using SharpMath.Expressions; +using SharpMath.Geometry; + +namespace SharpMath.Canvas; + +/// +/// A platform-neutral two-dimensional coordinate system that plots functions, vectors, points, lines, segments and +/// polygons. Hosts (e.g. a WinForms or Avalonia control) forward input to it and call +/// with a renderer. +/// +public sealed class Canvas2D +{ + private const double IndicatorPadding = 3; + private const double IndicatorOffset = 15; + + private readonly ItemRenderer _itemRenderer = new(); + private CanvasPoint? _dragPosition; + private CanvasPoint? _pointer; + private bool _rendering; + private bool _showTrackingLines; + private bool _showValueIndicator; + private CanvasStyle _style = CanvasStyle.Default; + + /// + /// Initializes a new instance of the class. + /// + public Canvas2D() + { + Items.CollectionChanged += OnItemsChanged; + Viewport.Changed += (_, _) => RaiseChanged(); + } + + /// + /// Occurs when the canvas needs to be redrawn, e.g. because an item, the style or the viewport changed. + /// + public event EventHandler? Changed; + + /// + /// Gets the items to draw, in drawing order. + /// + public ObservableCollection Items { get; } = []; + + /// + /// Gets the mapping between world and screen coordinates. + /// + public Viewport Viewport { get; } = new(); + + /// + /// Gets or sets the appearance. + /// + public CanvasStyle Style + { + get => _style; + set + { + ArgumentNullException.ThrowIfNull(value); + if (ReferenceEquals(_style, value)) + return; + _style = value; + RaiseChanged(); + } + } + + /// + /// Gets or sets a value indicating whether horizontal and vertical lines are drawn through the pointer. + /// + public bool ShowTrackingLines + { + get => _showTrackingLines; + set + { + if (_showTrackingLines == value) + return; + _showTrackingLines = value; + RaiseChanged(); + } + } + + /// + /// Gets or sets a value indicating whether the world coordinates of the pointer are shown next to it. + /// + public bool ShowValueIndicator + { + get => _showValueIndicator; + set + { + if (_showValueIndicator == value) + return; + _showValueIndicator = value; + RaiseChanged(); + } + } + + /// + /// Gets the last known pointer position in screen coordinates, or if the pointer is outside. + /// + public CanvasPoint? PointerPosition => _pointer; + + /// + /// Adds the graph of a function. + /// + /// The function. + /// The style, or for the default function style. + /// The added item. + public FunctionItem AddFunction(Func function, ItemStyle? style = null) => + Add(new FunctionItem(function) { Style = style }); + + /// + /// Adds the graph of an expression in the variable x, e.g. "2x^2 - 3x". + /// + /// The expression. + /// The style, or for the default function style. + /// The functions, constants and variables to use; the built-ins if . + /// The added item. + /// The expression is invalid. + /// The expression uses variables other than x. + public FunctionItem AddFunction(string expression, ItemStyle? style = null, ExpressionContext? context = null) => + Add(FunctionItem.FromExpression(expression, context) with { Style = style }); + + /// + /// Adds a vector drawn as an arrow. + /// + /// The vector. + /// The start of the arrow; the origin if . + /// The style, or for the default vector style. + /// The added item. + public VectorItem AddVector(Vector2 vector, Point2D? tail = null, ItemStyle? style = null) => + Add(new VectorItem(vector) { Tail = tail ?? Point2D.Origin, Style = style }); + + /// + /// Adds a point. + /// + /// The point. + /// The style, or for the default point style. + /// The added item. + public PointItem AddPoint(Point2D point, ItemStyle? style = null) => Add(new PointItem(point) { Style = style }); + + /// + /// Adds an infinite line. + /// + /// The line. + /// The style, or for the default line style. + /// The added item. + public LineItem AddLine(Line2D line, ItemStyle? style = null) => Add(new LineItem(line) { Style = style }); + + /// + /// Adds a line segment. + /// + /// The segment. + /// The style, or for the default segment style. + /// The added item. + public SegmentItem AddSegment(LineSegment2D segment, ItemStyle? style = null) => + Add(new SegmentItem(segment) { Style = style }); + + /// + /// Adds a polygon. + /// + /// The polygon. + /// The style, or for the default polygon style. + /// The added item. + public PolygonItem AddPolygon(Polygon polygon, ItemStyle? style = null) + { + ArgumentNullException.ThrowIfNull(polygon); + return Add(new PolygonItem(polygon) { Style = style }); + } + + /// + /// Starts panning at the specified position (e.g. on mouse button down). + /// + /// The pointer position in screen coordinates. + public void PointerPressed(CanvasPoint position) + { + _dragPosition = position; + UpdatePointer(position); + } + + /// + /// Updates the pointer position and pans if a drag is in progress. + /// + /// The pointer position in screen coordinates. + public void PointerMoved(CanvasPoint position) + { + if (_dragPosition is { } previous) + { + _dragPosition = position; + Viewport.Pan(position.X - previous.X, position.Y - previous.Y); + } + + UpdatePointer(position); + } + + /// + /// Ends panning (e.g. on mouse button up). + /// + /// The pointer position in screen coordinates. + public void PointerReleased(CanvasPoint position) + { + PointerMoved(position); + _dragPosition = null; + } + + /// + /// Notifies the canvas that the pointer left it. + /// + public void PointerExited() + { + _dragPosition = null; + if (_pointer is null) + return; + _pointer = null; + if (ShowTrackingLines || ShowValueIndicator) + RaiseChanged(); + } + + /// + /// Zooms around the pointer (e.g. on mouse wheel). Each notch zooms by a factor of 1.25. + /// + /// The pointer position in screen coordinates. + /// The number of wheel notches; positive values zoom in. + public void Wheel(CanvasPoint position, double notches) + { + if (notches == 0 || !double.IsFinite(notches)) + return; + Viewport.ZoomAt(position, Math.Pow(1.25, notches)); + } + + /// + /// Draws the canvas. + /// + /// The renderer. + /// The width of the drawing area in pixels. + /// The height of the drawing area in pixels. + public void Render(IDrawingContext context, double width, double height) + { + ArgumentNullException.ThrowIfNull(context); + _rendering = true; + try + { + Viewport.Resize(width, height); + } + finally + { + _rendering = false; + } + + var bounds = new CanvasRect(0, 0, width, height); + var guardBand = new CanvasRect(-10 * width - 10, -10 * height - 10, 21 * width + 20, 21 * height + 20); + + context.Clear(Style.Background); + context.PushClip(bounds); + var gridStep = GridRenderer.Draw(context, Viewport, Style); + foreach (var item in Items) + _itemRenderer.Draw(context, item, Viewport, Style, guardBand); + DrawPointerFeatures(context, gridStep); + context.PopClip(); + } + + private T Add(T item) where T : CanvasItem + { + Items.Add(item); + return item; + } + + private void OnItemsChanged(object? sender, NotifyCollectionChangedEventArgs e) => RaiseChanged(); + + private void UpdatePointer(CanvasPoint position) + { + if (_pointer == position) + return; + _pointer = position; + if (ShowTrackingLines || ShowValueIndicator) + RaiseChanged(); + } + + private void RaiseChanged() + { + if (!_rendering) + Changed?.Invoke(this, EventArgs.Empty); + } + + private void DrawPointerFeatures(IDrawingContext context, (double X, double Y) gridStep) + { + if (_pointer is not { } pointer) + return; + + if (ShowTrackingLines) + { + context.DrawLine(new CanvasPoint(pointer.X, 0), new CanvasPoint(pointer.X, Viewport.Height), + Style.TrackingStroke); + context.DrawLine(new CanvasPoint(0, pointer.Y), new CanvasPoint(Viewport.Width, pointer.Y), + Style.TrackingStroke); + } + + if (!ShowValueIndicator) + return; + + // One decimal more than the grid step needs, so the value changes visibly while moving at any zoom level. + var world = Viewport.ScreenToWorld(pointer); + var text = Ticks.FormatValue(world.X, gridStep.X) + " | " + Ticks.FormatValue(world.Y, gridStep.Y); + var size = context.MeasureText(text, Style.LabelFont); + var box = new CanvasRect(pointer.X + IndicatorOffset, pointer.Y + IndicatorOffset, + Math.Max(80, size.Width + 2 * IndicatorPadding), Math.Max(25, size.Height + 2 * IndicatorPadding)); + context.DrawRectangle(box, Style.IndicatorBorder, Style.IndicatorBackground); + context.DrawText(text, new CanvasPoint(box.X + box.Width / 2, box.Y + (box.Height - size.Height) / 2), + Style.LabelFont, Style.IndicatorText, TextAlignment.Center); + } +} diff --git a/src/SharpMath.Canvas/CanvasItems.cs b/src/SharpMath.Canvas/CanvasItems.cs new file mode 100644 index 0000000..1e730e1 --- /dev/null +++ b/src/SharpMath.Canvas/CanvasItems.cs @@ -0,0 +1,79 @@ +using SharpMath.Expressions; +using SharpMath.Geometry; + +namespace SharpMath.Canvas; + +/// +/// The base class of everything a draws. Items are immutable; replace an item in +/// to change it. +/// +public abstract record CanvasItem +{ + /// + /// Gets the style of this item, or to use the default style for its type from + /// . + /// + public ItemStyle? Style { get; init; } +} + +/// +/// The graph of a function y = f(x). +/// +/// The function. Non-finite results and arithmetic exceptions leave gaps in the graph. +public sealed record FunctionItem(Func Function) : CanvasItem +{ + /// + /// Gets the parsed expression if the item was created from one; otherwise, . + /// + public MathExpression? Expression { get; init; } + + /// + /// Creates the graph of an expression in the variable x, e.g. "2x^2 - 3x". + /// + /// The expression. + /// The functions, constants and variables to use; the built-ins if . + /// The item. + /// The expression is invalid. + /// The expression uses variables other than x. + public static FunctionItem FromExpression(string expression, ExpressionContext? context = null) + { + var parsed = MathExpression.Parse(expression, context); + return new FunctionItem(parsed.ToFunc("x")) { Expression = parsed }; + } +} + +/// +/// A vector drawn as an arrow from to Tail + Vector. +/// +/// The vector. +public sealed record VectorItem(Vector2 Vector) : CanvasItem +{ + /// + /// Gets the start of the arrow; the origin by default. + /// + public Point2D Tail { get; init; } +} + +/// +/// A point drawn as a dot. +/// +/// The point. +public sealed record PointItem(Point2D Point) : CanvasItem; + +/// +/// An infinite line, clipped to the visible area. +/// +/// The line. +public sealed record LineItem(Line2D Line) : CanvasItem; + +/// +/// A line segment. +/// +/// The segment. +public sealed record SegmentItem(LineSegment2D Segment) : CanvasItem; + +/// +/// A polygon with an outline and an optional fill. +/// +/// The polygon. +public sealed record PolygonItem(Polygon Polygon) : CanvasItem; diff --git a/src/SharpMath.Canvas/CanvasStyle.cs b/src/SharpMath.Canvas/CanvasStyle.cs new file mode 100644 index 0000000..12d9501 --- /dev/null +++ b/src/SharpMath.Canvas/CanvasStyle.cs @@ -0,0 +1,116 @@ +namespace SharpMath.Canvas; + +/// +/// Describes how an item is drawn. +/// +/// The stroke for lines and outlines. +/// The fill color for polygons, points and arrowheads, or for none. +/// The radius of points in pixels. +public sealed record ItemStyle(Stroke Stroke, Color? Fill = null, double PointRadius = 3); + +/// +/// Describes the appearance of a . The defaults reproduce the look of the original canvas. +/// +/// +/// The style is immutable; change it with a expression and assign it to +/// , e.g. canvas.Style = canvas.Style with { Background = Color.Black };. +/// +public sealed record CanvasStyle +{ + /// + /// Gets the default style. + /// + public static CanvasStyle Default { get; } = new(); + + /// + /// Gets the background color. + /// + public Color Background { get; init; } = Color.White; + + /// + /// Gets the stroke of the fine grid lines. + /// + public Stroke MinorGridStroke { get; init; } = new(Color.LightGray); + + /// + /// Gets the stroke of the grid lines at labelled ticks. + /// + public Stroke MajorGridStroke { get; init; } = new(Color.LightGray); + + /// + /// Gets the stroke of the coordinate axes. + /// + public Stroke AxisStroke { get; init; } = new(Color.FromRgb(245, 125, 0)); + + /// + /// Gets the font of the axis labels. + /// + public FontSpec LabelFont { get; init; } = new("Segoe UI", 11); + + /// + /// Gets the color of the axis labels. + /// + public Color LabelColor { get; init; } = Color.Black; + + /// + /// Gets the minimum distance between fine grid lines in pixels. + /// + public double MinGridSpacing { get; init; } = 20; + + /// + /// Gets the minimum distance between labelled ticks in pixels. + /// + public double MinLabelSpacing { get; init; } = 80; + + /// + /// Gets the stroke of the tracking lines through the pointer. + /// + public Stroke TrackingStroke { get; init; } = new(Color.Gray); + + /// + /// Gets the background of the value indicator. + /// + public Color IndicatorBackground { get; init; } = Color.White; + + /// + /// Gets the border of the value indicator. + /// + public Stroke IndicatorBorder { get; init; } = new(Color.LightGray); + + /// + /// Gets the text color of the value indicator. + /// + public Color IndicatorText { get; init; } = Color.LightGray; + + /// + /// Gets the default style of function graphs. + /// + public ItemStyle FunctionStyle { get; init; } = new(new Stroke(Color.Black)); + + /// + /// Gets the default style of vectors; the fill is used for the arrowhead. + /// + public ItemStyle VectorStyle { get; init; } = + new(new Stroke(Color.FromRgb(122, 125, 201)), Color.FromRgb(122, 125, 201)); + + /// + /// Gets the default style of points. + /// + public ItemStyle PointStyle { get; init; } = new(new Stroke(Color.Black), Color.Black); + + /// + /// Gets the default style of infinite lines. + /// + public ItemStyle LineStyle { get; init; } = new(new Stroke(Color.FromRgb(0, 128, 128))); + + /// + /// Gets the default style of line segments. + /// + public ItemStyle SegmentStyle { get; init; } = new(new Stroke(Color.FromRgb(0, 128, 128), 2)); + + /// + /// Gets the default style of polygons. + /// + public ItemStyle PolygonStyle { get; init; } = + new(new Stroke(Color.FromRgb(46, 139, 87)), Color.FromArgb(48, 46, 139, 87)); +} diff --git a/src/SharpMath.Canvas/Clipping.cs b/src/SharpMath.Canvas/Clipping.cs new file mode 100644 index 0000000..7de03b3 --- /dev/null +++ b/src/SharpMath.Canvas/Clipping.cs @@ -0,0 +1,97 @@ +namespace SharpMath.Canvas; + +/// +/// Clips geometry in screen coordinates to a rectangle, so renderers never receive huge coordinates. +/// +internal static class Clipping +{ + /// + /// Clips a segment with the Liang–Barsky algorithm. + /// + /// if a part of the segment lies inside the rectangle. + public static bool ClipSegment(CanvasRect bounds, ref CanvasPoint from, ref CanvasPoint to) + { + if (!IsFinite(from) || !IsFinite(to)) + return false; + + double t0 = 0, t1 = 1; + var dx = to.X - from.X; + var dy = to.Y - from.Y; + if (!Clip(-dx, from.X - bounds.X, ref t0, ref t1) || !Clip(dx, bounds.Right - from.X, ref t0, ref t1) || + !Clip(-dy, from.Y - bounds.Y, ref t0, ref t1) || !Clip(dy, bounds.Bottom - from.Y, ref t0, ref t1)) + return false; + + var start = from; + if (t1 < 1) + to = new CanvasPoint(start.X + t1 * dx, start.Y + t1 * dy); + if (t0 > 0) + from = new CanvasPoint(start.X + t0 * dx, start.Y + t0 * dy); + return true; + } + + /// + /// Clips a polygon with the Sutherland–Hodgman algorithm. + /// + public static List ClipPolygon(CanvasRect bounds, IReadOnlyList polygon) + { + var output = polygon.Where(IsFinite).ToList(); + output = ClipEdge(output, p => p.X >= bounds.X, (a, b) => AtX(a, b, bounds.X)); + output = ClipEdge(output, p => p.X <= bounds.Right, (a, b) => AtX(a, b, bounds.Right)); + output = ClipEdge(output, p => p.Y >= bounds.Y, (a, b) => AtY(a, b, bounds.Y)); + output = ClipEdge(output, p => p.Y <= bounds.Bottom, (a, b) => AtY(a, b, bounds.Bottom)); + return output; + } + + public static bool IsFinite(CanvasPoint point) => double.IsFinite(point.X) && double.IsFinite(point.Y); + + private static bool Clip(double denominator, double numerator, ref double t0, ref double t1) + { + if (denominator == 0) + return numerator >= 0; + + var t = numerator / denominator; + if (denominator < 0) + { + if (t > t1) + return false; + t0 = Math.Max(t0, t); + } + else + { + if (t < t0) + return false; + t1 = Math.Min(t1, t); + } + + return true; + } + + private static List ClipEdge(List input, Func inside, + Func intersect) + { + var output = new List(input.Count + 4); + for (var i = 0; i < input.Count; i++) + { + var current = input[i]; + var previous = input[(i + input.Count - 1) % input.Count]; + if (inside(current)) + { + if (!inside(previous)) + output.Add(intersect(previous, current)); + output.Add(current); + } + else if (inside(previous)) + { + output.Add(intersect(previous, current)); + } + } + + return output; + } + + private static CanvasPoint AtX(CanvasPoint a, CanvasPoint b, double x) => + new(x, a.Y + (b.Y - a.Y) * (x - a.X) / (b.X - a.X)); + + private static CanvasPoint AtY(CanvasPoint a, CanvasPoint b, double y) => + new(a.X + (b.X - a.X) * (y - a.Y) / (b.Y - a.Y), y); +} diff --git a/src/SharpMath.Canvas/Color.cs b/src/SharpMath.Canvas/Color.cs new file mode 100644 index 0000000..df49287 --- /dev/null +++ b/src/SharpMath.Canvas/Color.cs @@ -0,0 +1,66 @@ +using System.Globalization; + +namespace SharpMath.Canvas; + +/// +/// Represents an immutable RGBA color with 8 bits per channel. +/// +/// The red channel. +/// The green channel. +/// The blue channel. +/// The alpha channel (255 is opaque). +public readonly record struct Color(byte R, byte G, byte B, byte A = 255) +{ + /// + /// Gets opaque white. + /// + public static Color White => new(255, 255, 255); + + /// + /// Gets opaque black. + /// + public static Color Black => new(0, 0, 0); + + /// + /// Gets the gray used for tracking lines (#808080). + /// + public static Color Gray => new(128, 128, 128); + + /// + /// Gets the light gray used for grid lines (#D3D3D3). + /// + public static Color LightGray => new(211, 211, 211); + + /// + /// Gets a fully transparent color. + /// + public static Color Transparent => new(0, 0, 0, 0); + + /// + /// Creates an opaque color. + /// + /// The red channel. + /// The green channel. + /// The blue channel. + /// The color. + public static Color FromRgb(byte red, byte green, byte blue) => new(red, green, blue); + + /// + /// Creates a color with an alpha channel. + /// + /// The alpha channel (255 is opaque). + /// The red channel. + /// The green channel. + /// The blue channel. + /// The color. + public static Color FromArgb(byte alpha, byte red, byte green, byte blue) => new(red, green, blue, alpha); + + /// + /// Returns the color as #RRGGBB (opaque) or #RRGGBBAA. + /// + /// The hexadecimal representation. + public string ToHex() => + A == 255 + ? string.Create(CultureInfo.InvariantCulture, $"#{R:X2}{G:X2}{B:X2}") + : string.Create(CultureInfo.InvariantCulture, $"#{R:X2}{G:X2}{B:X2}{A:X2}"); +} diff --git a/src/SharpMath.Canvas/FunctionSampler.cs b/src/SharpMath.Canvas/FunctionSampler.cs new file mode 100644 index 0000000..076e861 --- /dev/null +++ b/src/SharpMath.Canvas/FunctionSampler.cs @@ -0,0 +1,104 @@ +namespace SharpMath.Canvas; + +/// +/// Samples a function across the visible area and splits the graph into polylines at gaps and poles. +/// +internal static class FunctionSampler +{ + private const int MaxRefinementDepth = 6; + private const double RefinementPixels = 2; + + public static List Sample(Func function, Viewport viewport, CanvasRect guardBand) + { + var polylines = new List(); + var current = new List(); + var columns = (int) Math.Ceiling(viewport.Width); + + var previousX = viewport.ScreenToWorld(new CanvasPoint(0, 0)).X; + var previousY = Evaluate(function, previousX); + if (double.IsFinite(previousY)) + current.Add(ToScreen(viewport, guardBand, previousX, previousY)); + + for (var column = 1; column <= columns; column++) + { + var x = viewport.ScreenToWorld(new CanvasPoint(column, 0)).X; + var y = Evaluate(function, x); + if (!double.IsFinite(y)) + { + Flush(polylines, current); + previousX = x; + previousY = y; + continue; + } + + if (double.IsFinite(previousY) && !Refine(function, viewport, guardBand, previousX, previousY, x, y, + MaxRefinementDepth, current)) + { + Flush(polylines, current); + } + + current.Add(ToScreen(viewport, guardBand, x, y)); + previousX = x; + previousY = y; + } + + Flush(polylines, current); + return polylines; + } + + /// + /// Adds intermediate samples where the graph is steep. Returns if there is a gap or a pole + /// between the two samples; the points before the break have been added in that case. + /// + private static bool Refine(Func function, Viewport viewport, CanvasRect guardBand, double x0, + double y0, double x1, double y1, int depth, List output) + { + var pixelDistance = Math.Abs(y1 - y0) * viewport.ScaleY; + if (pixelDistance <= RefinementPixels) + return true; + + var xm = (x0 + x1) / 2; + var ym = Evaluate(function, xm); + if (!double.IsFinite(ym)) + return false; + + // A pole: the jump is larger than the visible area and the midpoint does not lie between the end points. + var lower = Math.Min(y0, y1); + var upper = Math.Max(y0, y1); + if (pixelDistance > viewport.Height && (ym < lower || ym > upper)) + return false; + + if (depth == 0) + return pixelDistance <= viewport.Height || (ym >= lower && ym <= upper); + + if (!Refine(function, viewport, guardBand, x0, y0, xm, ym, depth - 1, output)) + return false; + output.Add(ToScreen(viewport, guardBand, xm, ym)); + return Refine(function, viewport, guardBand, xm, ym, x1, y1, depth - 1, output); + } + + private static double Evaluate(Func function, double x) + { + try + { + return function(x); + } + catch (ArithmeticException) + { + return double.NaN; + } + } + + private static CanvasPoint ToScreen(Viewport viewport, CanvasRect guardBand, double x, double y) + { + var point = viewport.WorldToScreen(new Geometry.Point2D(x, y)); + return new CanvasPoint(point.X, Math.Clamp(point.Y, guardBand.Y, guardBand.Bottom)); + } + + private static void Flush(List polylines, List current) + { + if (current.Count >= 2) + polylines.Add([.. current]); + current.Clear(); + } +} diff --git a/src/SharpMath.Canvas/GridRenderer.cs b/src/SharpMath.Canvas/GridRenderer.cs new file mode 100644 index 0000000..eeca06e --- /dev/null +++ b/src/SharpMath.Canvas/GridRenderer.cs @@ -0,0 +1,99 @@ +using SharpMath.Geometry; + +namespace SharpMath.Canvas; + +/// +/// Draws the grid, the axis labels and the axes of a . +/// +internal static class GridRenderer +{ + private const double LabelPadding = 3; + + /// + /// Draws grid lines, labels and axes and returns the grid step in world units (X, Y). + /// + public static (double StepX, double StepY) Draw(IDrawingContext context, Viewport viewport, CanvasStyle style) + { + var area = viewport.VisibleArea; + var gridSpacing = Ticks.SafeSpacing(style.MinGridSpacing); + var labelSpacing = Math.Max(gridSpacing, Ticks.SafeSpacing(style.MinLabelSpacing)); + var minorX = Ticks.NiceStep(gridSpacing, viewport.ScaleX); + var minorY = Ticks.NiceStep(gridSpacing, viewport.ScaleY); + + // Labelled ticks are multiples of the grid step, so every label sits on a (major) grid line. + var majorX = Ticks.NiceMultiple(labelSpacing, viewport.ScaleX, minorX); + var majorY = Ticks.NiceMultiple(labelSpacing, viewport.ScaleY, minorY); + + var (firstX, lastX) = Ticks.Range(area.MinX, area.MaxX, minorX); + for (var i = firstX; i <= lastX; i++) + { + var stroke = IsMultiple(i * minorX, majorX) ? style.MajorGridStroke : style.MinorGridStroke; + var x = viewport.WorldToScreen(new Point2D(i * minorX, 0)).X; + context.DrawLine(new CanvasPoint(x, 0), new CanvasPoint(x, viewport.Height), stroke); + } + + var (firstY, lastY) = Ticks.Range(area.MinY, area.MaxY, minorY); + for (var i = firstY; i <= lastY; i++) + { + var stroke = IsMultiple(i * minorY, majorY) ? style.MajorGridStroke : style.MinorGridStroke; + var y = viewport.WorldToScreen(new Point2D(0, i * minorY)).Y; + context.DrawLine(new CanvasPoint(0, y), new CanvasPoint(viewport.Width, y), stroke); + } + + DrawLabels(context, viewport, style, area, majorX, majorY); + DrawAxes(context, viewport, style.AxisStroke); + return (minorX, minorY); + } + + private static void DrawLabels(IDrawingContext context, Viewport viewport, CanvasStyle style, BoundingBox2D area, + double stepX, double stepY) + { + var font = style.LabelFont; + var lineHeight = context.MeasureText("0", font).Height; + + // Labels stay next to the axes; if an axis is off-screen they stick to the nearest edge. Labels that would be + // cut off at the edges are skipped. + var labelTop = Math.Clamp(viewport.Origin.Y + LabelPadding, LabelPadding, + Math.Max(LabelPadding, viewport.Height - lineHeight - LabelPadding)); + var (firstX, lastX) = Ticks.Range(area.MinX, area.MaxX, stepX); + for (var i = firstX; i <= lastX; i++) + { + if (i == 0) + continue; + var x = viewport.WorldToScreen(new Point2D(i * stepX, 0)).X; + var text = Ticks.Format(i, stepX); + var halfWidth = context.MeasureText(text, font).Width / 2; + if (x - halfWidth < 0 || x + halfWidth > viewport.Width) + continue; + context.DrawText(text, new CanvasPoint(x, labelTop), font, style.LabelColor, TextAlignment.Center); + } + + var (firstY, lastY) = Ticks.Range(area.MinY, area.MaxY, stepY); + for (var i = firstY; i <= lastY; i++) + { + var top = i == 0 ? labelTop : viewport.WorldToScreen(new Point2D(0, i * stepY)).Y - lineHeight / 2; + if (top < 0 || top + lineHeight > viewport.Height) + continue; + var text = Ticks.Format(i, stepY); + var width = context.MeasureText(text, font).Width; + var labelLeft = Math.Clamp(viewport.Origin.X + LabelPadding, LabelPadding, + Math.Max(LabelPadding, viewport.Width - width - LabelPadding)); + context.DrawText(text, new CanvasPoint(labelLeft, top), font, style.LabelColor, TextAlignment.Left); + } + } + + private static void DrawAxes(IDrawingContext context, Viewport viewport, Stroke stroke) + { + var origin = viewport.Origin; + if (origin.Y >= 0 && origin.Y <= viewport.Height) + context.DrawLine(new CanvasPoint(0, origin.Y), new CanvasPoint(viewport.Width, origin.Y), stroke); + if (origin.X >= 0 && origin.X <= viewport.Width) + context.DrawLine(new CanvasPoint(origin.X, 0), new CanvasPoint(origin.X, viewport.Height), stroke); + } + + private static bool IsMultiple(double value, double step) + { + var ratio = value / step; + return Math.Abs(ratio - Math.Round(ratio)) < 1e-6; + } +} diff --git a/src/SharpMath.Canvas/IDrawingContext.cs b/src/SharpMath.Canvas/IDrawingContext.cs new file mode 100644 index 0000000..cf2be71 --- /dev/null +++ b/src/SharpMath.Canvas/IDrawingContext.cs @@ -0,0 +1,88 @@ +namespace SharpMath.Canvas; + +/// +/// The drawing operations a renderer provides to the . All coordinates are in pixels with the +/// origin in the top-left corner and Y pointing down. +/// +/// +/// The canvas clips coordinates to a guard band around the visible area before calling a renderer, so implementations +/// never receive huge or non-finite coordinates. +/// +public interface IDrawingContext +{ + /// + /// Fills the whole drawing area. + /// + /// The color. + void Clear(Color color); + + /// + /// Draws a straight line. + /// + /// The start point. + /// The end point. + /// The stroke. + void DrawLine(CanvasPoint startPoint, CanvasPoint endPoint, Stroke stroke); + + /// + /// Draws connected line segments through the points. + /// + /// The points; at least two. + /// The stroke. + void DrawPolyline(ReadOnlySpan points, Stroke stroke); + + /// + /// Draws a closed polygon. + /// + /// The vertices; at least three. + /// The outline, or for none. + /// The fill color, or for none. + void DrawPolygon(ReadOnlySpan points, Stroke? stroke, Color? fill); + + /// + /// Draws an axis-aligned rectangle. + /// + /// The rectangle. + /// The outline, or for none. + /// The fill color, or for none. + void DrawRectangle(CanvasRect rect, Stroke? stroke, Color? fill); + + /// + /// Draws an axis-aligned ellipse. + /// + /// The center. + /// The horizontal radius. + /// The vertical radius. + /// The outline, or for none. + /// The fill color, or for none. + void DrawEllipse(CanvasPoint center, double radiusX, double radiusY, Stroke? stroke, Color? fill); + + /// + /// Draws a single line of text. + /// + /// The text. + /// The anchor: the top edge of the text and, depending on the alignment, its left edge, center or right edge. + /// The font. + /// The color. + /// The horizontal alignment relative to the anchor. + void DrawText(string text, CanvasPoint position, FontSpec font, Color color, TextAlignment alignment); + + /// + /// Measures a single line of text. + /// + /// The text. + /// The font. + /// The size of the text. + TextSize MeasureText(string text, FontSpec font); + + /// + /// Restricts subsequent drawing to a rectangle until the matching . + /// + /// The clip rectangle. + void PushClip(CanvasRect rect); + + /// + /// Removes the clip rectangle added by the last . + /// + void PopClip(); +} diff --git a/src/SharpMath.Canvas/ItemRenderer.cs b/src/SharpMath.Canvas/ItemRenderer.cs new file mode 100644 index 0000000..2475286 --- /dev/null +++ b/src/SharpMath.Canvas/ItemRenderer.cs @@ -0,0 +1,117 @@ +namespace SharpMath.Canvas; + +/// +/// Draws the items of a , clipping them to a guard band around the visible area. +/// +internal sealed class ItemRenderer +{ + private const double ArrowLength = 10; + private const double ArrowHalfWidth = 4; + + private readonly List _polygon = []; + + public void Draw(IDrawingContext context, CanvasItem item, Viewport viewport, CanvasStyle style, + CanvasRect guardBand) + { + switch (item) + { + case FunctionItem function: + var stroke = (function.Style ?? style.FunctionStyle).Stroke; + foreach (var polyline in FunctionSampler.Sample(function.Function, viewport, guardBand)) + context.DrawPolyline(polyline, stroke); + break; + case VectorItem vector: + DrawVector(context, vector, viewport, vector.Style ?? style.VectorStyle, guardBand); + break; + case PointItem point: + DrawPoint(context, point, viewport, point.Style ?? style.PointStyle); + break; + case LineItem line: + DrawLine(context, line, viewport, (line.Style ?? style.LineStyle).Stroke); + break; + case SegmentItem segment: + DrawSegment(context, viewport.WorldToScreen(segment.Segment.Start), + viewport.WorldToScreen(segment.Segment.End), (segment.Style ?? style.SegmentStyle).Stroke, + guardBand); + break; + case PolygonItem polygon: + DrawPolygon(context, polygon, viewport, polygon.Style ?? style.PolygonStyle, guardBand); + break; + } + } + + private static void DrawVector(IDrawingContext context, VectorItem item, Viewport viewport, ItemStyle style, + CanvasRect guardBand) + { + var tail = viewport.WorldToScreen(item.Tail); + var tip = viewport.WorldToScreen(item.Tail + item.Vector); + var dx = tip.X - tail.X; + var dy = tip.Y - tail.Y; + var length = Math.Sqrt(dx * dx + dy * dy); + if (!double.IsFinite(length) || length == 0) + return; + + // The shaft ends at the base of the arrowhead. + var ux = dx / length; + var uy = dy / length; + var headLength = Math.Min(ArrowLength, length); + var basePoint = new CanvasPoint(tip.X - ux * headLength, tip.Y - uy * headLength); + DrawSegment(context, tail, basePoint, style.Stroke, guardBand); + + if (!guardBand.Contains(tip)) + return; + var halfWidth = ArrowHalfWidth * headLength / ArrowLength; + ReadOnlySpan head = + [ + tip, + new(basePoint.X - uy * halfWidth, basePoint.Y + ux * halfWidth), + new(basePoint.X + uy * halfWidth, basePoint.Y - ux * halfWidth) + ]; + context.DrawPolygon(head, style.Stroke, style.Fill ?? style.Stroke.Color); + } + + private static void DrawPoint(IDrawingContext context, PointItem item, Viewport viewport, ItemStyle style) + { + var center = viewport.WorldToScreen(item.Point); + var radius = style.PointRadius; + var visible = new CanvasRect(-radius, -radius, viewport.Width + 2 * radius, viewport.Height + 2 * radius); + if (visible.Contains(center)) + context.DrawEllipse(center, radius, radius, style.Stroke, style.Fill); + } + + private static void DrawLine(IDrawingContext context, LineItem item, Viewport viewport, Stroke stroke) + { + var line = item.Line; + if (!double.IsFinite(line.A) || (line.A == 0 && line.B == 0)) + return; + + // Two points beyond the visible area, clipped to the drawing area. + var area = viewport.VisibleArea; + var center = line.ClosestPoint(area.Center); + var reach = Math.Sqrt(area.Width * area.Width + area.Height * area.Height) + 1; + var from = viewport.WorldToScreen(center - line.Direction * reach); + var to = viewport.WorldToScreen(center + line.Direction * reach); + var bounds = new CanvasRect(-1, -1, viewport.Width + 2, viewport.Height + 2); + if (Clipping.ClipSegment(bounds, ref from, ref to)) + context.DrawLine(from, to, stroke); + } + + private static void DrawSegment(IDrawingContext context, CanvasPoint from, CanvasPoint to, Stroke stroke, + CanvasRect guardBand) + { + if (Clipping.ClipSegment(guardBand, ref from, ref to)) + context.DrawLine(from, to, stroke); + } + + private void DrawPolygon(IDrawingContext context, PolygonItem item, Viewport viewport, ItemStyle style, + CanvasRect guardBand) + { + _polygon.Clear(); + foreach (var point in item.Polygon.Points) + _polygon.Add(viewport.WorldToScreen(point)); + var clipped = Clipping.ClipPolygon(guardBand, _polygon); + if (clipped.Count >= 3) + context.DrawPolygon(System.Runtime.InteropServices.CollectionsMarshal.AsSpan(clipped), style.Stroke, + style.Fill); + } +} diff --git a/src/SharpMath.Canvas/Primitives.cs b/src/SharpMath.Canvas/Primitives.cs new file mode 100644 index 0000000..341faaf --- /dev/null +++ b/src/SharpMath.Canvas/Primitives.cs @@ -0,0 +1,100 @@ +namespace SharpMath.Canvas; + +/// +/// A point in screen coordinates (pixels, Y pointing down). +/// +/// The horizontal position. +/// The vertical position. +public readonly record struct CanvasPoint(double X, double Y); + +/// +/// A rectangle in screen coordinates (pixels, Y pointing down). +/// +/// The left edge. +/// The top edge. +/// The width. +/// The height. +public readonly record struct CanvasRect(double X, double Y, double Width, double Height) +{ + /// + /// Gets the right edge. + /// + public double Right => X + Width; + + /// + /// Gets the bottom edge. + /// + public double Bottom => Y + Height; + + /// + /// Determines whether a point lies inside the rectangle or on its border. + /// + /// The point. + /// if the rectangle contains the point; otherwise, . + public bool Contains(CanvasPoint point) => + point.X >= X && point.X <= Right && point.Y >= Y && point.Y <= Bottom; +} + +/// +/// The dash pattern of a stroke. +/// +public enum DashStyle +{ + /// + /// A continuous line. + /// + Solid, + + /// + /// A dashed line. + /// + Dashed, + + /// + /// A dotted line. + /// + Dotted +} + +/// +/// Describes how lines are drawn. +/// +/// The color. +/// The width in pixels. +/// The dash pattern. +public readonly record struct Stroke(Color Color, double Width = 1, DashStyle Dash = DashStyle.Solid); + +/// +/// Describes a font. +/// +/// The font family; renderers fall back to a default font if it is not available. +/// The size in pixels. +public readonly record struct FontSpec(string Family, double Size); + +/// +/// The measured size of a text in pixels. +/// +/// The width. +/// The height (line height). +public readonly record struct TextSize(double Width, double Height); + +/// +/// The horizontal alignment of a text relative to its position. +/// +public enum TextAlignment +{ + /// + /// The position is the left edge of the text. + /// + Left, + + /// + /// The position is the horizontal center of the text. + /// + Center, + + /// + /// The position is the right edge of the text. + /// + Right +} diff --git a/src/SharpMath.Canvas/SharpMath.Canvas.csproj b/src/SharpMath.Canvas/SharpMath.Canvas.csproj new file mode 100644 index 0000000..ed97d6b --- /dev/null +++ b/src/SharpMath.Canvas/SharpMath.Canvas.csproj @@ -0,0 +1,11 @@ + + + + Platform-neutral 2D canvas for SharpMath: plots functions, vectors, points, lines and polygons through a renderer abstraction. Includes an SVG renderer. + + + + + + + diff --git a/src/SharpMath.Canvas/SvgDrawingContext.cs b/src/SharpMath.Canvas/SvgDrawingContext.cs new file mode 100644 index 0000000..e2c8c0a --- /dev/null +++ b/src/SharpMath.Canvas/SvgDrawingContext.cs @@ -0,0 +1,173 @@ +using System.Globalization; +using System.Security; +using System.Text; + +namespace SharpMath.Canvas; + +/// +/// An that produces an SVG document. It needs no native dependencies. +/// +/// +/// SVG has no access to font metrics, so estimates the width as 0.6 times the font size per +/// character and the height as 1.2 times the font size. +/// +public sealed class SvgDrawingContext : IDrawingContext +{ + private readonly StringBuilder _body = new(); + private readonly StringBuilder _definitions = new(); + private readonly double _height; + private readonly double _width; + private int _clipCount; + private int _openGroups; + + /// + /// Initializes a new instance of the class. + /// + /// The width of the document in pixels. + /// The height of the document in pixels. + public SvgDrawingContext(double width, double height) + { + ArgumentOutOfRangeException.ThrowIfNegative(width); + ArgumentOutOfRangeException.ThrowIfNegative(height); + _width = width; + _height = height; + } + + /// + public void Clear(Color color) + { + _body.Clear(); + _definitions.Clear(); + _openGroups = 0; + _body.Append(CultureInfo.InvariantCulture, + $"\n"); + } + + /// + public void DrawLine(CanvasPoint startPoint, CanvasPoint endPoint, Stroke stroke) => + _body.Append(CultureInfo.InvariantCulture, + $"\n"); + + /// + public void DrawPolyline(ReadOnlySpan points, Stroke stroke) + { + if (points.Length < 2) + return; + _body.Append("\n"); + } + + /// + public void DrawPolygon(ReadOnlySpan points, Stroke? stroke, Color? fill) + { + if (points.Length < 3) + return; + _body.Append("\n"); + } + + /// + public void DrawRectangle(CanvasRect rect, Stroke? stroke, Color? fill) => + _body.Append(CultureInfo.InvariantCulture, + $"\n"); + + /// + public void DrawEllipse(CanvasPoint center, double radiusX, double radiusY, Stroke? stroke, Color? fill) => + _body.Append(CultureInfo.InvariantCulture, + $"\n"); + + /// + public void DrawText(string text, CanvasPoint position, FontSpec font, Color color, TextAlignment alignment) + { + ArgumentNullException.ThrowIfNull(text); + var anchor = alignment switch + { + TextAlignment.Center => "middle", + TextAlignment.Right => "end", + _ => "start" + }; + + // SVG positions text at its baseline; 0.8 · size approximates the ascent. + var baseline = position.Y + 0.8 * font.Size; + _body.Append(CultureInfo.InvariantCulture, + $"{Escape(text)}\n"); + } + + /// + public TextSize MeasureText(string text, FontSpec font) + { + ArgumentNullException.ThrowIfNull(text); + return new TextSize(0.6 * font.Size * text.Length, 1.2 * font.Size); + } + + /// + public void PushClip(CanvasRect rect) + { + var id = "clip" + (++_clipCount).ToString(CultureInfo.InvariantCulture); + _definitions.Append(CultureInfo.InvariantCulture, + $"\n"); + _body.Append(CultureInfo.InvariantCulture, $"\n"); + _openGroups++; + } + + /// + public void PopClip() + { + if (_openGroups == 0) + throw new InvalidOperationException("PopClip was called without a matching PushClip."); + _body.Append("\n"); + _openGroups--; + } + + /// + /// Returns the SVG document drawn so far. Unclosed clip regions are closed. + /// + /// The SVG document. + public override string ToString() + { + var document = new StringBuilder(); + document.Append(CultureInfo.InvariantCulture, + $"\n"); + if (_definitions.Length > 0) + document.Append("\n").Append(_definitions).Append("\n"); + document.Append(_body); + for (var i = 0; i < _openGroups; i++) + document.Append("\n"); + return document.Append("\n").ToString(); + } + + private static string F(double value) => Math.Round(value, 3).ToString("0.###", CultureInfo.InvariantCulture); + + private static string Points(ReadOnlySpan points) + { + var builder = new StringBuilder(points.Length * 12); + foreach (var point in points) + builder.Append(F(point.X)).Append(',').Append(F(point.Y)).Append(' '); + return builder.ToString(0, builder.Length - 1); + } + + private static string Fill(Color color) => + color.A == 255 + ? $" fill=\"{color.ToHex()[..7]}\"" + : string.Create(CultureInfo.InvariantCulture, + $" fill=\"{color.ToHex()[..7]}\" fill-opacity=\"{F(color.A / 255.0)}\""); + + private static string StrokeAttributes(Stroke stroke) + { + var builder = new StringBuilder(); + builder.Append(CultureInfo.InvariantCulture, + $" stroke=\"{stroke.Color.ToHex()[..7]}\" stroke-width=\"{F(stroke.Width)}\""); + if (stroke.Color.A != 255) + builder.Append(CultureInfo.InvariantCulture, $" stroke-opacity=\"{F(stroke.Color.A / 255.0)}\""); + if (stroke.Dash == DashStyle.Dashed) + builder.Append(CultureInfo.InvariantCulture, $" stroke-dasharray=\"{F(4 * stroke.Width)},{F(2 * stroke.Width)}\""); + else if (stroke.Dash == DashStyle.Dotted) + builder.Append(CultureInfo.InvariantCulture, $" stroke-dasharray=\"{F(stroke.Width)},{F(2 * stroke.Width)}\""); + return builder.ToString(); + } + + private static string Paint(Stroke? stroke, Color? fill) => + (fill is { } color ? Fill(color) : " fill=\"none\"") + (stroke is { } s ? StrokeAttributes(s) : string.Empty); + + private static string Escape(string text) => SecurityElement.Escape(text); +} diff --git a/src/SharpMath.Canvas/SvgExport.cs b/src/SharpMath.Canvas/SvgExport.cs new file mode 100644 index 0000000..ccfc45d --- /dev/null +++ b/src/SharpMath.Canvas/SvgExport.cs @@ -0,0 +1,35 @@ +namespace SharpMath.Canvas; + +/// +/// Renders a to SVG. +/// +public static class SvgExport +{ + /// + /// Renders the canvas to an SVG document. + /// + /// The canvas. + /// The width in pixels. + /// The height in pixels. + /// The SVG document. + public static string RenderSvg(this Canvas2D canvas, double width, double height) + { + ArgumentNullException.ThrowIfNull(canvas); + var context = new SvgDrawingContext(width, height); + canvas.Render(context, width, height); + return context.ToString(); + } + + /// + /// Renders the canvas to an SVG document and writes it to a . + /// + /// The canvas. + /// The writer. + /// The width in pixels. + /// The height in pixels. + public static void RenderSvg(this Canvas2D canvas, TextWriter writer, double width, double height) + { + ArgumentNullException.ThrowIfNull(writer); + writer.Write(canvas.RenderSvg(width, height)); + } +} diff --git a/src/SharpMath.Canvas/Ticks.cs b/src/SharpMath.Canvas/Ticks.cs new file mode 100644 index 0000000..adc756a --- /dev/null +++ b/src/SharpMath.Canvas/Ticks.cs @@ -0,0 +1,113 @@ +using System.Globalization; + +namespace SharpMath.Canvas; + +/// +/// Calculates "nice" tick steps (1, 2 or 5 times a power of ten) and formats tick labels. +/// +internal static class Ticks +{ + /// + /// The largest number of ticks returned by ; beyond that (or once the tick indices lose + /// precision) no ticks are drawn. + /// + public const long MaxTicks = 10_000; + + /// + /// Returns the smallest nice step in world units whose on-screen length is at least . + /// + public static double NiceStep(double minPixels, double scale) + { + var raw = minPixels / scale; + var magnitude = Math.Pow(10, Math.Floor(Math.Log10(raw))); + foreach (var multiplier in (ReadOnlySpan) [1, 2, 5, 10]) + { + var step = multiplier * magnitude; + if (step >= raw * (1 - 1e-12)) + return step; + } + + return 10 * magnitude; + } + + + /// + /// Returns the smallest nice step that is a whole multiple of and whose on-screen + /// length is at least . + /// + public static double NiceMultiple(double minPixels, double scale, double baseStep) + { + var step = baseStep; + while (step * scale < minPixels * (1 - 1e-12) || !IsWholeMultiple(step, baseStep)) + step = NextNiceStep(step); + return step; + } + + /// + /// Formats a value with one decimal more than needs. + /// + public static string FormatValue(double value, double step) + { + var decimals = Math.Clamp(Decimals(step) + 1, 0, 15); + return Math.Round(value, decimals).ToString("F" + decimals.ToString(CultureInfo.InvariantCulture), + CultureInfo.InvariantCulture); + } + + /// + /// Returns a usable minimum spacing in pixels: at least 1, and 1 for invalid values. + /// + public static double SafeSpacing(double spacing) => double.IsFinite(spacing) && spacing >= 1 ? spacing : 1; + + /// + /// Returns the index range [first, last] of multiples of inside [min, max], or an + /// empty range if it would be too large or imprecise. + /// + public static (long First, long Last) Range(double min, double max, double step) + { + var first = Math.Ceiling(min / step); + var last = Math.Floor(max / step); + if (!double.IsFinite(first) || !double.IsFinite(last) || Math.Abs(first) > 1e15 || Math.Abs(last) > 1e15 || + last - first > MaxTicks) + return (1, 0); + return ((long) first, (long) last); + } + + /// + /// Formats the tick value index · step with as many decimals as the step needs. + /// + public static string Format(long index, double step) + { + var value = index * step; + if (index == 0) + return "0"; + + var decimals = Decimals(step); + var magnitude = Math.Abs(value); + if (decimals > 6 || magnitude >= 1e7) + return value.ToString("0.###E+0", CultureInfo.InvariantCulture); + return value.ToString("F" + decimals.ToString(CultureInfo.InvariantCulture), CultureInfo.InvariantCulture); + } + + private static int Decimals(double step) => Math.Max(0, (int) -Math.Floor(Math.Log10(step) + 1e-9)); + + private static bool IsWholeMultiple(double step, double baseStep) + { + var ratio = step / baseStep; + return Math.Abs(ratio - Math.Round(ratio)) < 1e-9; + } + + /// + /// Returns the next larger nice step (… 1, 2, 5, 10, 20, 50 …). + /// + private static double NextNiceStep(double step) + { + var magnitude = Math.Pow(10, Math.Floor(Math.Log10(step) + 1e-9)); + var leading = Math.Round(step / magnitude); + return leading switch + { + < 2 => 2 * magnitude, + < 5 => 5 * magnitude, + _ => 10 * magnitude + }; + } +} diff --git a/src/SharpMath.Canvas/Viewport.cs b/src/SharpMath.Canvas/Viewport.cs new file mode 100644 index 0000000..833121d --- /dev/null +++ b/src/SharpMath.Canvas/Viewport.cs @@ -0,0 +1,197 @@ +using SharpMath.Geometry; + +namespace SharpMath.Canvas; + +/// +/// Maps between world coordinates (Y up) and screen coordinates (pixels, Y down), and implements panning and zooming. +/// +public sealed class Viewport +{ + /// + /// The smallest allowed scale in pixels per unit. + /// + public const double MinScale = 1e-6; + + /// + /// The largest allowed scale in pixels per unit. + /// + public const double MaxScale = 1e9; + + private bool _originSet; + private CanvasPoint _panBeforeSizing; + private bool _sized; + + /// + /// Occurs when the origin, the scale or the size changes. + /// + public event EventHandler? Changed; + + /// + /// Gets the screen position of the world origin (0, 0). Until it is set or the viewport is panned or zoomed, the + /// origin is centered whenever the size changes. Panning before the first is applied relative + /// to the centered origin. + /// + public CanvasPoint Origin { get; private set; } + + /// + /// Gets the horizontal scale in pixels per unit. + /// + public double ScaleX { get; private set; } = 20; + + /// + /// Gets the vertical scale in pixels per unit. + /// + public double ScaleY { get; private set; } = 20; + + /// + /// Gets the width of the visible area in pixels. + /// + public double Width { get; private set; } + + /// + /// Gets the height of the visible area in pixels. + /// + public double Height { get; private set; } + + /// + /// Gets the visible world area. + /// + public BoundingBox2D VisibleArea + { + get + { + var topLeft = ScreenToWorld(new CanvasPoint(0, 0)); + var bottomRight = ScreenToWorld(new CanvasPoint(Width, Height)); + return new BoundingBox2D(topLeft.X, bottomRight.Y, bottomRight.X, topLeft.Y); + } + } + + /// + /// Sets the screen position of the world origin. + /// + /// The screen position; both coordinates must be finite. + /// A coordinate is not finite. + public void SetOrigin(CanvasPoint origin) + { + if (!double.IsFinite(origin.X) || !double.IsFinite(origin.Y)) + throw new ArgumentOutOfRangeException(nameof(origin), origin, "The origin must be finite."); + _originSet = true; + Update(origin, ScaleX, ScaleY, Width, Height); + } + + /// + /// Sets the scale. + /// + /// The horizontal scale in pixels per unit, between and . + /// The vertical scale in pixels per unit, between and . + /// A scale is out of range. + public void SetScale(double scaleX, double scaleY) + { + ValidateScale(scaleX, nameof(scaleX)); + ValidateScale(scaleY, nameof(scaleY)); + Update(Origin, scaleX, scaleY, Width, Height); + } + + /// + /// Sets the size of the visible area. Renderers call this before drawing. + /// + /// The width in pixels; must be finite and not negative. + /// The height in pixels; must be finite and not negative. + /// A dimension is invalid. + public void Resize(double width, double height) + { + if (!(width >= 0) || !double.IsFinite(width)) + throw new ArgumentOutOfRangeException(nameof(width), width, "The width must be finite and not negative."); + if (!(height >= 0) || !double.IsFinite(height)) + throw new ArgumentOutOfRangeException(nameof(height), height, "The height must be finite and not negative."); + + var origin = _originSet + ? Origin + : new CanvasPoint(width / 2 + _panBeforeSizing.X, height / 2 + _panBeforeSizing.Y); + if (!_sized && _panBeforeSizing != default) + _originSet = true; + _sized = true; + Update(origin, ScaleX, ScaleY, width, height); + } + + /// + /// Moves the visible area by the specified number of pixels. + /// + /// The horizontal distance. + /// The vertical distance. + public void Pan(double deltaX, double deltaY) + { + if (!double.IsFinite(deltaX) || !double.IsFinite(deltaY)) + return; + if (!_sized && !_originSet) + _panBeforeSizing = new CanvasPoint(_panBeforeSizing.X + deltaX, _panBeforeSizing.Y + deltaY); + else + _originSet = true; + Update(new CanvasPoint(Origin.X + deltaX, Origin.Y + deltaY), ScaleX, ScaleY, Width, Height); + } + + /// + /// Zooms by a factor while keeping the world point under fixed. The scale is clamped to + /// [, ]. + /// + /// The screen position that stays fixed, e.g. the mouse position. + /// The zoom factor; greater than 1 zooms in. Must be positive and finite. + /// is not positive and finite. + public void ZoomAt(CanvasPoint anchor, double factor) + { + if (!(factor > 0) || !double.IsFinite(factor)) + throw new ArgumentOutOfRangeException(nameof(factor), factor, "The zoom factor must be positive and finite."); + + var scaleX = Math.Clamp(ScaleX * factor, MinScale, MaxScale); + var scaleY = Math.Clamp(ScaleY * factor, MinScale, MaxScale); + if (!_sized && !_originSet) + { + // Without a size the anchor has no meaning yet; only the scale changes. + Update(Origin, scaleX, scaleY, Width, Height); + return; + } + + var factorX = scaleX / ScaleX; + var factorY = scaleY / ScaleY; + var origin = new CanvasPoint(anchor.X - (anchor.X - Origin.X) * factorX, + anchor.Y - (anchor.Y - Origin.Y) * factorY); + _originSet = true; + Update(origin, scaleX, scaleY, Width, Height); + } + + /// + /// Converts a world position to screen coordinates. + /// + /// The world position. + /// The screen position. + public CanvasPoint WorldToScreen(Point2D point) => + new(Origin.X + point.X * ScaleX, Origin.Y - point.Y * ScaleY); + + /// + /// Converts a screen position to world coordinates. + /// + /// The screen position. + /// The world position. + public Point2D ScreenToWorld(CanvasPoint point) => + new((point.X - Origin.X) / ScaleX, (Origin.Y - point.Y) / ScaleY); + + private static void ValidateScale(double scale, string parameterName) + { + if (!(scale >= MinScale && scale <= MaxScale)) + throw new ArgumentOutOfRangeException(parameterName, scale, + $"The scale must be between {MinScale} and {MaxScale} pixels per unit."); + } + + private void Update(CanvasPoint origin, double scaleX, double scaleY, double width, double height) + { + if (origin == Origin && scaleX == ScaleX && scaleY == ScaleY && width == Width && height == Height) + return; + + Origin = origin; + ScaleX = scaleX; + ScaleY = scaleY; + Width = width; + Height = height; + Changed?.Invoke(this, EventArgs.Empty); + } +} diff --git a/tests/SharpMath.Canvas.Tests/Canvas2DTests.cs b/tests/SharpMath.Canvas.Tests/Canvas2DTests.cs new file mode 100644 index 0000000..0bc3b42 --- /dev/null +++ b/tests/SharpMath.Canvas.Tests/Canvas2DTests.cs @@ -0,0 +1,270 @@ +using SharpMath.Geometry; + +namespace SharpMath.Canvas.Tests; + +public class Canvas2DTests +{ + private static RecordingDrawingContext Render(Canvas2D canvas, double width = 400, double height = 300) + { + var context = new RecordingDrawingContext(); + canvas.Render(context, width, height); + return context; + } + + [Fact] + public void Render_DrawsBackgroundGridAxesAndLabels() + { + var canvas = new Canvas2D(); + var context = Render(canvas); + + Assert.Equal([Color.White], context.Clears); + Assert.Equal(0, context.ClipDepth); + Assert.Equal(1, context.MaxClipDepth); + + // 1-unit grid at 20 px per unit: 21 vertical and 15 horizontal lines (−10…10, −7…7), plus two axes. + var axisColor = CanvasStyle.Default.AxisStroke; + Assert.Equal(2, context.Lines.Count(line => line.Stroke == axisColor)); + Assert.Equal(21 + 15, context.Lines.Count(line => line.Stroke == CanvasStyle.Default.MinorGridStroke)); + + // Labels every 5 units; "0" only once; labels that would be cut off at the edges are skipped. + var labels = context.Texts.Select(text => text.Text).ToList(); + Assert.Equal(["-5", "5", "-5", "0", "5"], labels); + Assert.All(context.Texts, text => Assert.Equal(Color.Black, text.Color)); + } + + [Fact] + public void Labels_SitOnGridLines_AtEveryZoomLevel() + { + // Regression: at 16 px per unit the grid step was 2 but labels were placed every 5 units. + var canvas = new Canvas2D(); + canvas.Viewport.Resize(400, 300); + canvas.Viewport.SetScale(16, 16); + var context = Render(canvas); + var gridX = context.Lines.Where(line => line.Start.X == line.End.X).Select(line => Math.Floor(line.Start.X)).ToHashSet(); + var xLabels = context.Texts.Where(text => text.Alignment == TextAlignment.Center).ToList(); + Assert.NotEmpty(xLabels); + Assert.All(xLabels, label => Assert.Contains(Math.Floor(label.Position.X), gridX)); + Assert.Equal(["-10", "10"], xLabels.Select(label => label.Text)); + } + + [Fact] + public void Labels_ThatWouldBeCutOff_AreSkipped() + { + var canvas = new Canvas2D(); + canvas.Viewport.Resize(400, 300); + canvas.Viewport.SetOrigin(new CanvasPoint(200, 297)); + var context = Render(canvas); + Assert.All(context.Texts, text => + { + Assert.InRange(text.Position.Y, 0, 300); + Assert.InRange(text.Position.Y + 11 * 1.2, 0, 300); + }); + } + + [Fact] + public void Labels_StickToTheEdgeWhenTheAxisIsOffScreen() + { + var canvas = new Canvas2D(); + canvas.Viewport.Resize(400, 300); + canvas.Viewport.Pan(0, 1000); + var context = Render(canvas); + var xLabels = context.Texts.Where(text => text.Alignment == TextAlignment.Center).ToList(); + Assert.NotEmpty(xLabels); + Assert.All(xLabels, text => Assert.InRange(text.Position.Y, 0, 300)); + Assert.DoesNotContain(context.Lines, line => line.Stroke == CanvasStyle.Default.AxisStroke && + line.Start.Y == line.End.Y); + } + + [Fact] + public void Render_DrawsEveryItemType() + { + var canvas = new Canvas2D(); + canvas.AddFunction(x => x); + canvas.AddFunction("x^2"); + canvas.AddVector(new Vector2(4, 8)); + canvas.AddPoint(new Point2D(1, 1)); + canvas.AddLine(Line2D.FromGeneralForm(1, 0, -2)); + canvas.AddSegment(new LineSegment2D(new Point2D(0, 0), new Point2D(3, -3))); + canvas.AddPolygon(new Polygon(new Point2D(0, 0), new Point2D(2, 0), new Point2D(1, 2))); + var context = Render(canvas); + var style = CanvasStyle.Default; + + Assert.Equal(2, context.Polylines.Count(polyline => polyline.Stroke == style.FunctionStyle.Stroke)); + Assert.Contains(context.Lines, line => line.Stroke == style.VectorStyle.Stroke); + var arrowhead = Assert.Single(context.Polygons, polygon => polygon.Fill == style.VectorStyle.Fill); + Assert.Equal(new CanvasPoint(280, -10), arrowhead.Points[0]); + var point = Assert.Single(context.Ellipses); + Assert.Equal(new CanvasPoint(220, 130), point.Center); + var line = Assert.Single(context.Lines, line => line.Stroke == style.LineStyle.Stroke); + Assert.Equal(240, line.Start.X, 9); + Assert.Equal(240, line.End.X, 9); + Assert.Contains(context.Lines, line => line.Stroke == style.SegmentStyle.Stroke && + line.Start == new CanvasPoint(200, 150) && + line.End == new CanvasPoint(260, 210)); + Assert.Single(context.Polygons, polygon => polygon.Fill == style.PolygonStyle.Fill); + } + + [Fact] + public void ItemStyles_OverrideTheDefaults() + { + var canvas = new Canvas2D(); + var red = new ItemStyle(new Stroke(Color.FromRgb(255, 0, 0), 3, DashStyle.Dashed)); + canvas.AddFunction(x => x, red); + canvas.AddPoint(new Point2D(0, 0), red with { Fill = Color.Black, PointRadius = 5 }); + var context = Render(canvas); + Assert.Equal(red.Stroke, Assert.Single(context.Polylines).Stroke); + Assert.Equal(5, Assert.Single(context.Ellipses).RadiusX); + } + + [Fact] + public void FarAwayItems_AreClippedOrSkipped() + { + // Regression for issue #3: huge coordinates must never reach the renderer. + var canvas = new Canvas2D(); + canvas.AddVector(new Vector2(1e12, 1e12)); + canvas.AddSegment(new LineSegment2D(new Point2D(-1e15, 0), new Point2D(1e15, 1))); + canvas.AddPolygon(new Polygon(new Point2D(-1e9, -1e9), new Point2D(1e9, -1e9), new Point2D(0, 1e9))); + canvas.AddPoint(new Point2D(1e9, 1e9)); + canvas.AddFunction(x => Math.Exp(x * 100)); + canvas.AddLine(Line2D.FromGeneralForm(1, 0, -1e9)); + canvas.Viewport.Resize(400, 300); + for (var i = 0; i < 50; i++) + canvas.Wheel(new CanvasPoint(390, 5), 1); + + var context = Render(canvas); + Assert.All(context.AllPoints, point => + { + Assert.InRange(point.X, -5000, 9000); + Assert.InRange(point.Y, -4000, 7000); + }); + Assert.Empty(context.Ellipses); + Assert.DoesNotContain(context.Lines, line => line.Stroke == CanvasStyle.Default.LineStyle.Stroke); + } + + [Fact] + public void DegenerateItems_AreIgnored() + { + var canvas = new Canvas2D(); + canvas.AddVector(Vector2.Zero); + canvas.Items.Add(new LineItem(default)); + var context = Render(canvas); + Assert.Empty(context.Polygons); + Assert.DoesNotContain(context.Lines, line => line.Stroke == CanvasStyle.Default.VectorStyle.Stroke); + } + + [Fact] + public void InvalidSpacing_DoesNotHang() + { + // Regression: a grid size of 0 used to make the drawing loops spin forever. + var canvas = new Canvas2D { Style = CanvasStyle.Default with { MinGridSpacing = 0, MinLabelSpacing = double.NaN } }; + var context = Render(canvas, 100, 100); + Assert.InRange(context.Lines.Count, 2, 300); + } + + [Fact] + public void Input_PansZoomsAndTracksThePointer() + { + var canvas = new Canvas2D(); + canvas.Viewport.Resize(400, 300); + canvas.PointerPressed(new CanvasPoint(100, 100)); + canvas.PointerMoved(new CanvasPoint(110, 90)); + canvas.PointerReleased(new CanvasPoint(120, 80)); + Assert.Equal(new CanvasPoint(220, 130), canvas.Viewport.Origin); + canvas.PointerMoved(new CanvasPoint(0, 0)); + Assert.Equal(new CanvasPoint(220, 130), canvas.Viewport.Origin); + Assert.Equal(new CanvasPoint(0, 0), canvas.PointerPosition); + + canvas.Wheel(new CanvasPoint(220, 130), 2); + Assert.Equal(20 * 1.25 * 1.25, canvas.Viewport.ScaleX, 9); + Assert.Equal(new CanvasPoint(220, 130), canvas.Viewport.Origin); + canvas.Wheel(new CanvasPoint(0, 0), 0); + canvas.Wheel(new CanvasPoint(0, 0), double.NaN); + Assert.Equal(20 * 1.25 * 1.25, canvas.Viewport.ScaleX, 9); + + canvas.PointerExited(); + Assert.Null(canvas.PointerPosition); + } + + [Fact] + public void TrackingLinesAndValueIndicator() + { + var canvas = new Canvas2D { ShowTrackingLines = true, ShowValueIndicator = true }; + canvas.Viewport.Resize(400, 300); + canvas.PointerMoved(new CanvasPoint(250, 100)); + var context = Render(canvas); + + Assert.Equal(2, context.Lines.Count(line => line.Stroke == CanvasStyle.Default.TrackingStroke)); + var box = Assert.Single(context.Rectangles); + Assert.Equal(265, box.Rect.X); + Assert.Contains(context.Texts, text => text.Text == "2.5 | 2.5" && text.Color == CanvasStyle.Default.IndicatorText); + + canvas.PointerExited(); + var hidden = Render(canvas); + Assert.Empty(hidden.Rectangles); + } + + [Fact] + public void Changed_IsRaisedForRelevantChanges() + { + var canvas = new Canvas2D(); + var count = 0; + canvas.Changed += (_, _) => count++; + + Render(canvas); + Assert.Equal(0, count); + + canvas.AddPoint(Point2D.Origin); + Assert.Equal(1, count); + canvas.Items.Clear(); + Assert.Equal(2, count); + canvas.Style = canvas.Style with { Background = Color.Black }; + Assert.Equal(3, count); + canvas.Style = canvas.Style; + Assert.Equal(3, count); + canvas.Viewport.Pan(1, 1); + Assert.Equal(4, count); + + canvas.PointerMoved(new CanvasPoint(1, 1)); + Assert.Equal(4, count); + canvas.ShowTrackingLines = true; + canvas.ShowTrackingLines = true; + Assert.Equal(5, count); + canvas.PointerMoved(new CanvasPoint(2, 2)); + Assert.Equal(6, count); + canvas.PointerMoved(new CanvasPoint(2, 2)); + Assert.Equal(6, count); + canvas.ShowValueIndicator = true; + Assert.Equal(7, count); + canvas.PointerExited(); + Assert.Equal(8, count); + canvas.PointerExited(); + Assert.Equal(8, count); + Assert.Throws(() => canvas.Style = null!); + Assert.Throws(() => canvas.Render(null!, 1, 1)); + Assert.Throws(() => canvas.AddPolygon(null!)); + } + + [Fact] + public void FunctionItems_FromExpressions() + { + var item = FunctionItem.FromExpression("2x + 1"); + Assert.Equal(7, item.Function(3)); + Assert.Equal("2x + 1", item.Expression!.Text); + Assert.Throws(() => FunctionItem.FromExpression("x + y")); + Assert.Throws(() => new Canvas2D().AddFunction("x +")); + var context = new SharpMath.Expressions.ExpressionContext().AddFunction("sq", x => x * x); + var custom = new Canvas2D().AddFunction("sq(x) + 1", context: context); + Assert.Equal(10, custom.Function(3)); + } + + [Fact] + public void Colors() + { + Assert.Equal("#F57D00", Color.FromRgb(245, 125, 0).ToHex()); + Assert.Equal("#11223344", Color.FromArgb(0x44, 0x11, 0x22, 0x33).ToHex()); + Assert.Equal(0, Color.Transparent.A); + Assert.True(new CanvasRect(0, 0, 10, 10).Contains(new CanvasPoint(10, 10))); + Assert.Equal(10, new CanvasRect(5, 5, 5, 5).Right); + Assert.Equal(10, new CanvasRect(5, 5, 5, 5).Bottom); + } +} diff --git a/tests/SharpMath.Canvas.Tests/ClippingTests.cs b/tests/SharpMath.Canvas.Tests/ClippingTests.cs new file mode 100644 index 0000000..8b5858a --- /dev/null +++ b/tests/SharpMath.Canvas.Tests/ClippingTests.cs @@ -0,0 +1,51 @@ +namespace SharpMath.Canvas.Tests; + +public class ClippingTests +{ + private static readonly CanvasRect Bounds = new(0, 0, 100, 100); + + [Fact] + public void ClipSegment_ShortensSegmentsToTheRectangle() + { + var from = new CanvasPoint(-50, 50); + var to = new CanvasPoint(150, 50); + Assert.True(Clipping.ClipSegment(Bounds, ref from, ref to)); + Assert.Equal(new CanvasPoint(0, 50), from); + Assert.Equal(new CanvasPoint(100, 50), to); + + var inside = new CanvasPoint(10, 10); + var alsoInside = new CanvasPoint(20, 20); + Assert.True(Clipping.ClipSegment(Bounds, ref inside, ref alsoInside)); + Assert.Equal(new CanvasPoint(10, 10), inside); + + var outside = new CanvasPoint(-10, -10); + var stillOutside = new CanvasPoint(-5, 200); + Assert.False(Clipping.ClipSegment(Bounds, ref outside, ref stillOutside)); + + var huge = new CanvasPoint(50, -1e300); + var end = new CanvasPoint(50, 1e300); + Assert.True(Clipping.ClipSegment(Bounds, ref huge, ref end)); + Assert.Equal(new CanvasPoint(50, 0), huge); + + var nan = new CanvasPoint(double.NaN, 0); + var other = new CanvasPoint(1, 1); + Assert.False(Clipping.ClipSegment(Bounds, ref nan, ref other)); + + var vertical = new CanvasPoint(150, 0); + var verticalEnd = new CanvasPoint(150, 50); + Assert.False(Clipping.ClipSegment(Bounds, ref vertical, ref verticalEnd)); + } + + [Fact] + public void ClipPolygon_KeepsOnlyTheInsidePart() + { + var clipped = Clipping.ClipPolygon(Bounds, + [new CanvasPoint(-50, -50), new CanvasPoint(50, -50), new CanvasPoint(50, 50), new CanvasPoint(-50, 50)]); + Assert.Equal(4, clipped.Count); + Assert.All(clipped, point => Assert.True(Bounds.Contains(point))); + Assert.Contains(new CanvasPoint(50, 50), clipped); + + Assert.Empty(Clipping.ClipPolygon(Bounds, + [new CanvasPoint(200, 200), new CanvasPoint(300, 200), new CanvasPoint(300, 300)])); + } +} diff --git a/tests/SharpMath.Canvas.Tests/FunctionSamplerTests.cs b/tests/SharpMath.Canvas.Tests/FunctionSamplerTests.cs new file mode 100644 index 0000000..31ad248 --- /dev/null +++ b/tests/SharpMath.Canvas.Tests/FunctionSamplerTests.cs @@ -0,0 +1,63 @@ +namespace SharpMath.Canvas.Tests; + +public class FunctionSamplerTests +{ + private static Viewport CreateViewport() + { + var viewport = new Viewport(); + viewport.Resize(400, 300); + return viewport; + } + + private static CanvasRect GuardBand(Viewport viewport) => + new(-10 * viewport.Width, -10 * viewport.Height, 21 * viewport.Width, 21 * viewport.Height); + + [Fact] + public void ContinuousFunction_IsOnePolyline() + { + var viewport = CreateViewport(); + var polylines = FunctionSampler.Sample(x => x * x * x, viewport, GuardBand(viewport)); + Assert.Single(polylines); + Assert.True(polylines[0].Length >= 401); + Assert.All(polylines[0], point => Assert.InRange(point.Y, -3000, 3300)); + } + + [Fact] + public void Poles_BreakTheGraph() + { + // Regression: the graph used to be connected across asymptotes. + var viewport = CreateViewport(); + var polylines = FunctionSampler.Sample(Math.Tan, viewport, GuardBand(viewport)); + + // tan has poles at ±π/2, ±3π/2, ±5π/2 within x ∈ [−10, 10]. + Assert.Equal(7, polylines.Count); + foreach (var polyline in polylines) + for (var i = 1; i < polyline.Length; i++) + Assert.True(polyline[i].X >= polyline[i - 1].X); + } + + [Fact] + public void NonFiniteValuesAndArithmeticExceptions_LeaveGaps() + { + var viewport = CreateViewport(); + var sqrt = FunctionSampler.Sample(Math.Sqrt, viewport, GuardBand(viewport)); + Assert.Single(sqrt); + Assert.All(sqrt[0], point => Assert.True(point.X >= 199)); + + var throwing = FunctionSampler.Sample(x => x < 0 ? throw new OverflowException() : 1, viewport, + GuardBand(viewport)); + Assert.Single(throwing); + + Assert.Empty(FunctionSampler.Sample(_ => double.NaN, viewport, GuardBand(viewport))); + var reciprocal = FunctionSampler.Sample(x => 1 / x, viewport, GuardBand(viewport)); + Assert.Equal(2, reciprocal.Count); + } + + [Fact] + public void SteepParts_AreRefined() + { + var viewport = CreateViewport(); + var polylines = FunctionSampler.Sample(x => Math.Exp(3 * x), viewport, GuardBand(viewport)); + Assert.True(polylines.Sum(polyline => polyline.Length) > 401); + } +} diff --git a/tests/SharpMath.Canvas.Tests/Golden/empty.svg b/tests/SharpMath.Canvas.Tests/Golden/empty.svg new file mode 100644 index 0000000..b7a8177 --- /dev/null +++ b/tests/SharpMath.Canvas.Tests/Golden/empty.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + +0 + + + + diff --git a/tests/SharpMath.Canvas.Tests/Golden/items.svg b/tests/SharpMath.Canvas.Tests/Golden/items.svg new file mode 100644 index 0000000..a45118b --- /dev/null +++ b/tests/SharpMath.Canvas.Tests/Golden/items.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +-5 +5 +-5 +0 +5 + + + + + + + + + + + + + +5.0 | 4.5 + + diff --git a/tests/SharpMath.Canvas.Tests/RecordingDrawingContext.cs b/tests/SharpMath.Canvas.Tests/RecordingDrawingContext.cs new file mode 100644 index 0000000..a929636 --- /dev/null +++ b/tests/SharpMath.Canvas.Tests/RecordingDrawingContext.cs @@ -0,0 +1,52 @@ +namespace SharpMath.Canvas.Tests; + +/// +/// Records all drawing calls so tests can assert on them without a graphics library. +/// +internal sealed class RecordingDrawingContext : IDrawingContext +{ + public List Clears { get; } = []; + public List<(CanvasPoint Start, CanvasPoint End, Stroke Stroke)> Lines { get; } = []; + public List<(CanvasPoint[] Points, Stroke Stroke)> Polylines { get; } = []; + public List<(CanvasPoint[] Points, Stroke? Stroke, Color? Fill)> Polygons { get; } = []; + public List<(CanvasRect Rect, Stroke? Stroke, Color? Fill)> Rectangles { get; } = []; + public List<(CanvasPoint Center, double RadiusX, double RadiusY, Stroke? Stroke, Color? Fill)> Ellipses { get; } = []; + public List<(string Text, CanvasPoint Position, Color Color, TextAlignment Alignment)> Texts { get; } = []; + public int ClipDepth { get; private set; } + public int MaxClipDepth { get; private set; } + + public IEnumerable AllPoints => + Lines.SelectMany(line => new[] { line.Start, line.End }) + .Concat(Polylines.SelectMany(polyline => polyline.Points)) + .Concat(Polygons.SelectMany(polygon => polygon.Points)) + .Concat(Ellipses.Select(ellipse => ellipse.Center)) + .Concat(Texts.Select(text => text.Position)); + + public void Clear(Color color) => Clears.Add(color); + + public void DrawLine(CanvasPoint startPoint, CanvasPoint endPoint, Stroke stroke) => + Lines.Add((startPoint, endPoint, stroke)); + + public void DrawPolyline(ReadOnlySpan points, Stroke stroke) => Polylines.Add((points.ToArray(), stroke)); + + public void DrawPolygon(ReadOnlySpan points, Stroke? stroke, Color? fill) => + Polygons.Add((points.ToArray(), stroke, fill)); + + public void DrawRectangle(CanvasRect rect, Stroke? stroke, Color? fill) => Rectangles.Add((rect, stroke, fill)); + + public void DrawEllipse(CanvasPoint center, double radiusX, double radiusY, Stroke? stroke, Color? fill) => + Ellipses.Add((center, radiusX, radiusY, stroke, fill)); + + public void DrawText(string text, CanvasPoint position, FontSpec font, Color color, TextAlignment alignment) => + Texts.Add((text, position, color, alignment)); + + public TextSize MeasureText(string text, FontSpec font) => new(text.Length * font.Size * 0.6, font.Size * 1.2); + + public void PushClip(CanvasRect rect) + { + ClipDepth++; + MaxClipDepth = Math.Max(MaxClipDepth, ClipDepth); + } + + public void PopClip() => ClipDepth--; +} diff --git a/tests/SharpMath.Canvas.Tests/SharpMath.Canvas.Tests.csproj b/tests/SharpMath.Canvas.Tests/SharpMath.Canvas.Tests.csproj new file mode 100644 index 0000000..84e310c --- /dev/null +++ b/tests/SharpMath.Canvas.Tests/SharpMath.Canvas.Tests.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/SharpMath.Canvas.Tests/SvgTests.cs b/tests/SharpMath.Canvas.Tests/SvgTests.cs new file mode 100644 index 0000000..ab76a6a --- /dev/null +++ b/tests/SharpMath.Canvas.Tests/SvgTests.cs @@ -0,0 +1,82 @@ +using System.Runtime.CompilerServices; +using SharpMath.Geometry; + +namespace SharpMath.Canvas.Tests; + +public class SvgTests +{ + /// + /// Compares with the golden file copied to the output directory. Set SHARPMATH_UPDATE_GOLDEN=1 to rewrite the + /// golden files in the source tree (the caller's file path is only meaningful in local, non-CI builds). + /// + private static void AssertGolden(string actual, string name, [CallerFilePath] string sourceFile = "") + { + if (Environment.GetEnvironmentVariable("SHARPMATH_UPDATE_GOLDEN") == "1") + { + File.WriteAllText(Path.Combine(Path.GetDirectoryName(sourceFile)!, "Golden", name), actual); + Assert.Fail($"The golden file {name} was rewritten; review and commit it, then run the tests again."); + } + + var path = Path.Combine(AppContext.BaseDirectory, "Golden", name); + Assert.True(File.Exists(path), $"The golden file {name} is missing; run the tests with SHARPMATH_UPDATE_GOLDEN=1."); + Assert.Equal(File.ReadAllText(path).ReplaceLineEndings("\n"), actual.ReplaceLineEndings("\n")); + } + + [Fact] + public void EmptyCanvas_MatchesGoldenFile() => AssertGolden(new Canvas2D().RenderSvg(200, 150), "empty.svg"); + + [Fact] + public void AllItems_MatchGoldenFile() + { + var canvas = new Canvas2D { ShowTrackingLines = true, ShowValueIndicator = true }; + canvas.AddFunction("x^3 / 10"); + canvas.AddFunction(x => 1.5 * Math.Sin(x), new ItemStyle(new Stroke(Color.FromRgb(200, 0, 0), 2, DashStyle.Dashed))); + canvas.AddVector(new Vector2(4, 3)); + canvas.AddPoint(new Point2D(-3, 2)); + canvas.AddLine(Line2D.FromSlopeIntercept(-0.5, -2)); + canvas.AddSegment(new LineSegment2D(new Point2D(-6, -4), new Point2D(-2, -6)), + new ItemStyle(new Stroke(Color.Black, 1, DashStyle.Dotted))); + canvas.AddPolygon(new Polygon(new Point2D(2, -2), new Point2D(6, -2), new Point2D(4, -5))); + canvas.PointerMoved(new CanvasPoint(300, 60)); + AssertGolden(canvas.RenderSvg(400, 300), "items.svg"); + } + + [Fact] + public void RenderSvg_WritesToATextWriter() + { + using var writer = new StringWriter(); + new Canvas2D().RenderSvg(writer, 10, 10); + Assert.StartsWith("(() => ((Canvas2D) null!).RenderSvg(1, 1)); + Assert.Throws(() => new Canvas2D().RenderSvg(null!, 1, 1)); + } + + [Fact] + public void Context_EscapesTextAndHandlesEdgeCases() + { + var context = new SvgDrawingContext(10, 10); + context.DrawText("", new CanvasPoint(1, 1), new FontSpec("A\"B", 10), Color.Black, TextAlignment.Right); + context.DrawPolyline([new CanvasPoint(0, 0)], new Stroke(Color.Black)); + context.DrawPolygon([new CanvasPoint(0, 0), new CanvasPoint(1, 1)], null, Color.Black); + context.DrawRectangle(new CanvasRect(0, 0, 1, 1), new Stroke(Color.FromArgb(128, 0, 0, 0)), null); + context.DrawEllipse(new CanvasPoint(5, 5), 1, 2, null, Color.FromArgb(128, 255, 0, 0)); + context.PushClip(new CanvasRect(0, 0, 5, 5)); + var svg = context.ToString(); + + Assert.Contains("<a & b>", svg, StringComparison.Ordinal); + Assert.Contains("font-family=\"A"B\"", svg, StringComparison.Ordinal); + Assert.Contains("text-anchor=\"end\"", svg, StringComparison.Ordinal); + Assert.DoesNotContain("\n\n", svg, StringComparison.Ordinal); + context.PopClip(); + Assert.Throws(() => context.PopClip()); + Assert.Equal(new TextSize(18, 12), context.MeasureText("abc", new FontSpec("x", 10))); + Assert.Throws(() => new SvgDrawingContext(-1, 1)); + Assert.Throws(() => context.MeasureText(null!, new FontSpec("x", 1))); + Assert.Throws(() => + context.DrawText(null!, default, new FontSpec("x", 1), Color.Black, TextAlignment.Left)); + } +} diff --git a/tests/SharpMath.Canvas.Tests/TicksTests.cs b/tests/SharpMath.Canvas.Tests/TicksTests.cs new file mode 100644 index 0000000..c8043fb --- /dev/null +++ b/tests/SharpMath.Canvas.Tests/TicksTests.cs @@ -0,0 +1,69 @@ +namespace SharpMath.Canvas.Tests; + +public class TicksTests +{ + [Theory] + [InlineData(20, 20, 1)] + [InlineData(80, 20, 5)] + [InlineData(40, 20, 2)] + [InlineData(20, 2000, 0.01)] + [InlineData(20, 0.001, 20000)] + [InlineData(80, 16, 5)] + [InlineData(81, 16, 10)] + public void NiceStep_Uses1_2_5(double minPixels, double scale, double expected) => + Assert.Equal(expected, Ticks.NiceStep(minPixels, scale), 9); + + [Theory] + [InlineData(80, 16, 2, 10)] + [InlineData(80, 20, 1, 5)] + [InlineData(80, 40, 0.5, 2)] + [InlineData(80, 18, 2, 10)] + [InlineData(20, 20, 1, 1)] + [InlineData(80, 0.001, 20000, 100000)] + public void NiceMultiple_IsAMultipleOfTheGridStep(double minPixels, double scale, double gridStep, double expected) + { + var step = Ticks.NiceMultiple(minPixels, scale, gridStep); + Assert.Equal(expected, step, 9); + Assert.Equal(Math.Round(step / gridStep), step / gridStep, 9); + } + + [Theory] + [InlineData(2.5, 1, "2.5")] + [InlineData(5, 1, "5.0")] + [InlineData(0.123456, 0.01, "0.123")] + [InlineData(-1234.5678, 100, "-1234.6")] + public void FormatValue_UsesOneMoreDecimalThanTheStep(double value, double step, string expected) => + Assert.Equal(expected, Ticks.FormatValue(value, step)); + + [Fact] + public void GetNearestScaleRegression_LargeAndSmallSteps() + { + // Regression: the old algorithm normalized with (d − 1) · 10 instead of 10^(d − 1): 400 → 200, 4000 → 300. + Assert.Equal(500, Ticks.NiceStep(400, 1), 9); + Assert.Equal(5000, Ticks.NiceStep(4000, 1), 9); + Assert.Equal(0.005, Ticks.NiceStep(0.004, 1), 12); + } + + [Theory] + [InlineData(0, 1, "0")] + [InlineData(3, 1, "3")] + [InlineData(-3, 0.5, "-1.5")] + [InlineData(7, 0.1, "0.7")] + [InlineData(5, 0.02, "0.10")] + [InlineData(2, 5e7, "1E+8")] + [InlineData(3, 1e-7, "3E-7")] + public void Format_UsesStepDecimalsAndInvariantCulture(long index, double step, string expected) => + Assert.Equal(expected, Ticks.Format(index, step)); + + [Fact] + public void Range_IsEmptyWhenTooLargeOrImprecise() + { + Assert.Equal((-2L, 3L), Ticks.Range(-2.5, 3.5, 1)); + Assert.Equal((1L, 0L), Ticks.Range(0, 1e9, 1)); + Assert.Equal((1L, 0L), Ticks.Range(1e20, 1e20 + 1, 1)); + Assert.Equal((1L, 0L), Ticks.Range(double.NaN, 1, 1)); + Assert.Equal(1, Ticks.SafeSpacing(0)); + Assert.Equal(1, Ticks.SafeSpacing(double.NaN)); + Assert.Equal(25, Ticks.SafeSpacing(25)); + } +} diff --git a/tests/SharpMath.Canvas.Tests/ViewportTests.cs b/tests/SharpMath.Canvas.Tests/ViewportTests.cs new file mode 100644 index 0000000..3bc942b --- /dev/null +++ b/tests/SharpMath.Canvas.Tests/ViewportTests.cs @@ -0,0 +1,127 @@ +using SharpMath.Geometry; + +namespace SharpMath.Canvas.Tests; + +public class ViewportTests +{ + [Fact] + public void Resize_CentersTheOriginUntilItIsSet() + { + var viewport = new Viewport(); + viewport.Resize(400, 200); + Assert.Equal(new CanvasPoint(200, 100), viewport.Origin); + viewport.Resize(600, 300); + Assert.Equal(new CanvasPoint(300, 150), viewport.Origin); + + // Regression: an origin of (0, 0) used to be impossible because it was the "not set" sentinel. + viewport.SetOrigin(new CanvasPoint(0, 0)); + viewport.Resize(800, 400); + Assert.Equal(new CanvasPoint(0, 0), viewport.Origin); + } + + [Fact] + public void Conversions_FlipTheYAxis() + { + var viewport = new Viewport(); + viewport.Resize(400, 200); + Assert.Equal(new CanvasPoint(220, 60), viewport.WorldToScreen(new Point2D(1, 2))); + Assert.Equal(new Point2D(1, 2), viewport.ScreenToWorld(new CanvasPoint(220, 60))); + Assert.Equal(new BoundingBox2D(-10, -5, 10, 5), viewport.VisibleArea); + } + + [Fact] + public void Pan_MovesTheOrigin() + { + var viewport = new Viewport(); + viewport.Resize(400, 200); + viewport.Pan(10, -5); + Assert.Equal(new CanvasPoint(210, 95), viewport.Origin); + viewport.Pan(double.NaN, 1); + Assert.Equal(new CanvasPoint(210, 95), viewport.Origin); + } + + [Fact] + public void ZoomAt_KeepsTheAnchorFixed() + { + var viewport = new Viewport(); + viewport.Resize(400, 200); + var anchor = new CanvasPoint(300, 40); + var before = viewport.ScreenToWorld(anchor); + viewport.ZoomAt(anchor, 1.25); + Assert.Equal(25, viewport.ScaleX, 12); + Assert.True(viewport.ScreenToWorld(anchor).ApproximatelyEquals(before)); + viewport.ZoomAt(anchor, 0.8); + Assert.True(viewport.ScreenToWorld(anchor).ApproximatelyEquals(before)); + } + + [Fact] + public void ZoomAt_DoesNotDriftOrOverflow_Issue3() + { + // Regression for issue #3: zooming in repeatedly near a point far away used to move the origin by 1.25 times the + // distance per step, so the world point under the cursor drifted and coordinates overflowed. + var viewport = new Viewport(); + viewport.Resize(400, 300); + viewport.Pan(0, 5_000); + var anchor = new CanvasPoint(350, 10); + var target = viewport.ScreenToWorld(anchor); + for (var i = 0; i < 200; i++) + viewport.ZoomAt(anchor, 1.25); + + Assert.Equal(Viewport.MaxScale, viewport.ScaleX); + Assert.True(viewport.ScreenToWorld(anchor).ApproximatelyEquals(target, 1e-6, 1e-9)); + Assert.True(double.IsFinite(viewport.Origin.X) && double.IsFinite(viewport.Origin.Y)); + + for (var i = 0; i < 400; i++) + viewport.ZoomAt(anchor, 0.8); + Assert.Equal(Viewport.MinScale, viewport.ScaleY); + } + + [Fact] + public void PanBeforeTheFirstResize_IsRelativeToTheCenteredOrigin() + { + // Regression: panning or zooming before the first render moved the origin to the top-left corner for good. + var viewport = new Viewport(); + viewport.Pan(10, 5); + viewport.ZoomAt(new CanvasPoint(1, 1), 2); + viewport.Resize(400, 200); + Assert.Equal(new CanvasPoint(210, 105), viewport.Origin); + Assert.Equal(40, viewport.ScaleX); + viewport.Resize(600, 300); + Assert.Equal(new CanvasPoint(210, 105), viewport.Origin); + + var zoomedOnly = new Viewport(); + zoomedOnly.ZoomAt(new CanvasPoint(1, 1), 2); + zoomedOnly.Resize(400, 200); + zoomedOnly.Resize(600, 300); + Assert.Equal(new CanvasPoint(300, 150), zoomedOnly.Origin); + } + + [Fact] + public void Validation() + { + var viewport = new Viewport(); + Assert.Throws(() => viewport.ZoomAt(default, 0)); + Assert.Throws(() => viewport.ZoomAt(default, double.PositiveInfinity)); + Assert.Throws(() => viewport.SetScale(0, 1)); + Assert.Throws(() => viewport.SetScale(1, double.NaN)); + Assert.Throws(() => viewport.SetOrigin(new CanvasPoint(double.NaN, 0))); + Assert.Throws(() => viewport.Resize(-1, 1)); + Assert.Throws(() => viewport.Resize(1, double.PositiveInfinity)); + viewport.SetScale(2, 3); + Assert.Equal(2, viewport.ScaleX); + Assert.Equal(3, viewport.ScaleY); + } + + [Fact] + public void Changed_IsRaisedOnlyForRealChanges() + { + var viewport = new Viewport(); + var count = 0; + viewport.Changed += (_, _) => count++; + viewport.Resize(100, 100); + viewport.Resize(100, 100); + Assert.Equal(1, count); + viewport.Pan(1, 0); + Assert.Equal(2, count); + } +} From 1d08fb7616a1e81a4d0b2f2f65f0400509ce071e Mon Sep 17 00:00:00 2001 From: Dominic Beger Date: Sun, 11 Oct 2026 02:19:14 +0200 Subject: [PATCH 06/14] Add SkiaSharp renderer with PNG export and snap grid lines to pixels --- SharpMath.slnx | 2 + .../SharpMath.Canvas.Skia.csproj | 16 ++ .../SkiaDrawingContext.cs | 193 ++++++++++++++++++ src/SharpMath.Canvas.Skia/SkiaExport.cs | 57 ++++++ src/SharpMath.Canvas/Canvas2D.cs | 8 +- src/SharpMath.Canvas/GridRenderer.cs | 25 ++- .../SharpMath.Canvas.Skia.Tests.csproj | 7 + .../SkiaRenderingTests.cs | 112 ++++++++++ tests/SharpMath.Canvas.Tests/Golden/empty.svg | 40 ++-- tests/SharpMath.Canvas.Tests/Golden/items.svg | 80 ++++---- 10 files changed, 472 insertions(+), 68 deletions(-) create mode 100644 src/SharpMath.Canvas.Skia/SharpMath.Canvas.Skia.csproj create mode 100644 src/SharpMath.Canvas.Skia/SkiaDrawingContext.cs create mode 100644 src/SharpMath.Canvas.Skia/SkiaExport.cs create mode 100644 tests/SharpMath.Canvas.Skia.Tests/SharpMath.Canvas.Skia.Tests.csproj create mode 100644 tests/SharpMath.Canvas.Skia.Tests/SkiaRenderingTests.cs diff --git a/SharpMath.slnx b/SharpMath.slnx index 418312d..7bef3dc 100644 --- a/SharpMath.slnx +++ b/SharpMath.slnx @@ -1,9 +1,11 @@ + + diff --git a/src/SharpMath.Canvas.Skia/SharpMath.Canvas.Skia.csproj b/src/SharpMath.Canvas.Skia/SharpMath.Canvas.Skia.csproj new file mode 100644 index 0000000..0a4f09c --- /dev/null +++ b/src/SharpMath.Canvas.Skia/SharpMath.Canvas.Skia.csproj @@ -0,0 +1,16 @@ + + + + SkiaSharp renderer for the SharpMath 2D canvas: draw on any SKCanvas and export PNG images on Windows, Linux and macOS. + + + + + + + + + + + + diff --git a/src/SharpMath.Canvas.Skia/SkiaDrawingContext.cs b/src/SharpMath.Canvas.Skia/SkiaDrawingContext.cs new file mode 100644 index 0000000..1a38a49 --- /dev/null +++ b/src/SharpMath.Canvas.Skia/SkiaDrawingContext.cs @@ -0,0 +1,193 @@ +using SkiaSharp; + +namespace SharpMath.Canvas.Skia; + +/// +/// An that draws on a SkiaSharp . +/// +/// +/// Paints, fonts and typefaces are cached for the lifetime of the context; dispose it after drawing. The canvas +/// itself is not owned by the context. +/// +public sealed class SkiaDrawingContext : IDrawingContext, IDisposable +{ + private readonly SKCanvas _canvas; + private readonly Dictionary _fills = []; + private readonly Dictionary _fonts = []; + private readonly Dictionary _strokes = []; + private readonly Dictionary _typefaces = []; + private readonly SKPath _path = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The canvas to draw on. + public SkiaDrawingContext(SKCanvas canvas) + { + ArgumentNullException.ThrowIfNull(canvas); + _canvas = canvas; + } + + /// + public void Clear(Color color) => _canvas.Clear(ToSkia(color)); + + /// + public void DrawLine(CanvasPoint startPoint, CanvasPoint endPoint, Stroke stroke) => + _canvas.DrawLine((float) startPoint.X, (float) startPoint.Y, (float) endPoint.X, (float) endPoint.Y, + StrokePaint(stroke)); + + /// + public void DrawPolyline(ReadOnlySpan points, Stroke stroke) + { + if (points.Length < 2) + return; + BuildPath(points, false); + _canvas.DrawPath(_path, StrokePaint(stroke)); + } + + /// + public void DrawPolygon(ReadOnlySpan points, Stroke? stroke, Color? fill) + { + if (points.Length < 3) + return; + BuildPath(points, true); + if (fill is { } fillColor) + _canvas.DrawPath(_path, FillPaint(fillColor)); + if (stroke is { } outline) + _canvas.DrawPath(_path, StrokePaint(outline)); + } + + /// + public void DrawRectangle(CanvasRect rect, Stroke? stroke, Color? fill) + { + var skRect = SKRect.Create((float) rect.X, (float) rect.Y, (float) rect.Width, (float) rect.Height); + if (fill is { } fillColor) + _canvas.DrawRect(skRect, FillPaint(fillColor)); + if (stroke is { } outline) + _canvas.DrawRect(skRect, StrokePaint(outline)); + } + + /// + public void DrawEllipse(CanvasPoint center, double radiusX, double radiusY, Stroke? stroke, Color? fill) + { + if (fill is { } fillColor) + _canvas.DrawOval((float) center.X, (float) center.Y, (float) radiusX, (float) radiusY, FillPaint(fillColor)); + if (stroke is { } outline) + _canvas.DrawOval((float) center.X, (float) center.Y, (float) radiusX, (float) radiusY, StrokePaint(outline)); + } + + /// + public void DrawText(string text, CanvasPoint position, FontSpec font, Color color, TextAlignment alignment) + { + ArgumentNullException.ThrowIfNull(text); + var skFont = Font(font); + var align = alignment switch + { + TextAlignment.Center => SKTextAlign.Center, + TextAlignment.Right => SKTextAlign.Right, + _ => SKTextAlign.Left + }; + + // Skia positions text at its baseline; the ascent is negative. + var baseline = (float) position.Y - skFont.Metrics.Ascent; + _canvas.DrawText(text, (float) position.X, baseline, align, skFont, FillPaint(color)); + } + + /// + public TextSize MeasureText(string text, FontSpec font) + { + ArgumentNullException.ThrowIfNull(text); + var skFont = Font(font); + return new TextSize(skFont.MeasureText(text), skFont.Spacing); + } + + /// + public void PushClip(CanvasRect rect) + { + _canvas.Save(); + _canvas.ClipRect(SKRect.Create((float) rect.X, (float) rect.Y, (float) rect.Width, (float) rect.Height)); + } + + /// + public void PopClip() => _canvas.Restore(); + + /// + public void Dispose() + { + foreach (var paint in _strokes.Values.Concat(_fills.Values)) + { + paint.PathEffect?.Dispose(); + paint.Dispose(); + } + foreach (var font in _fonts.Values) + font.Dispose(); + foreach (var typeface in _typefaces.Values) + typeface.Dispose(); + _path.Dispose(); + _strokes.Clear(); + _fills.Clear(); + _fonts.Clear(); + _typefaces.Clear(); + } + + private static SKColor ToSkia(Color color) => new(color.R, color.G, color.B, color.A); + + private void BuildPath(ReadOnlySpan points, bool close) + { + _path.Reset(); + _path.MoveTo((float) points[0].X, (float) points[0].Y); + foreach (var point in points[1..]) + _path.LineTo((float) point.X, (float) point.Y); + if (close) + _path.Close(); + } + + private SKPaint StrokePaint(Stroke stroke) + { + if (_strokes.TryGetValue(stroke, out var paint)) + return paint; + + var width = (float) stroke.Width; + paint = new SKPaint + { + Color = ToSkia(stroke.Color), + StrokeWidth = width, + Style = SKPaintStyle.Stroke, + IsAntialias = true, + PathEffect = stroke.Dash switch + { + DashStyle.Dashed => SKPathEffect.CreateDash([4 * width, 2 * width], 0), + DashStyle.Dotted => SKPathEffect.CreateDash([width, 2 * width], 0), + _ => null + } + }; + _strokes.Add(stroke, paint); + return paint; + } + + private SKPaint FillPaint(Color color) + { + if (_fills.TryGetValue(color, out var paint)) + return paint; + + paint = new SKPaint { Color = ToSkia(color), Style = SKPaintStyle.Fill, IsAntialias = true }; + _fills.Add(color, paint); + return paint; + } + + private SKFont Font(FontSpec font) + { + if (_fonts.TryGetValue(font, out var skFont)) + return skFont; + + if (!_typefaces.TryGetValue(font.Family, out var typeface)) + { + typeface = SKTypeface.FromFamilyName(font.Family) ?? SKTypeface.CreateDefault(); + _typefaces.Add(font.Family, typeface); + } + + skFont = new SKFont(typeface, (float) font.Size) { Subpixel = true }; + _fonts.Add(font, skFont); + return skFont; + } +} diff --git a/src/SharpMath.Canvas.Skia/SkiaExport.cs b/src/SharpMath.Canvas.Skia/SkiaExport.cs new file mode 100644 index 0000000..3d1026c --- /dev/null +++ b/src/SharpMath.Canvas.Skia/SkiaExport.cs @@ -0,0 +1,57 @@ +using SkiaSharp; + +namespace SharpMath.Canvas.Skia; + +/// +/// Renders a to raster images with SkiaSharp. +/// +public static class SkiaExport +{ + /// + /// Renders the canvas onto a SkiaSharp canvas. + /// + /// The canvas to render. + /// The SkiaSharp canvas to draw on. + /// The width of the drawing area in pixels. + /// The height of the drawing area in pixels. + public static void Render(this Canvas2D canvas, SKCanvas target, double width, double height) + { + ArgumentNullException.ThrowIfNull(canvas); + using var context = new SkiaDrawingContext(target); + canvas.Render(context, width, height); + } + + /// + /// Renders the canvas into a new image. + /// + /// The canvas to render. + /// The width in pixels. Must be positive. + /// The height in pixels. Must be positive. + /// The image; the caller disposes it. + public static SKImage RenderImage(this Canvas2D canvas, int width, int height) + { + ArgumentNullException.ThrowIfNull(canvas); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height); + + using var surface = SKSurface.Create(new SKImageInfo(width, height, SKColorType.Rgba8888, SKAlphaType.Premul)) ?? + throw new InvalidOperationException("Skia could not create a drawing surface."); + canvas.Render(surface.Canvas, width, height); + return surface.Snapshot(); + } + + /// + /// Renders the canvas as a PNG image and writes it to a stream. + /// + /// The canvas to render. + /// The stream to write to. + /// The width in pixels. Must be positive. + /// The height in pixels. Must be positive. + public static void RenderPng(this Canvas2D canvas, Stream stream, int width, int height) + { + ArgumentNullException.ThrowIfNull(stream); + using var image = canvas.RenderImage(width, height); + using var data = image.Encode(SKEncodedImageFormat.Png, 100); + data.SaveTo(stream); + } +} diff --git a/src/SharpMath.Canvas/Canvas2D.cs b/src/SharpMath.Canvas/Canvas2D.cs index eaa0374..d188804 100644 --- a/src/SharpMath.Canvas/Canvas2D.cs +++ b/src/SharpMath.Canvas/Canvas2D.cs @@ -287,10 +287,10 @@ private void DrawPointerFeatures(IDrawingContext context, (double X, double Y) g if (ShowTrackingLines) { - context.DrawLine(new CanvasPoint(pointer.X, 0), new CanvasPoint(pointer.X, Viewport.Height), - Style.TrackingStroke); - context.DrawLine(new CanvasPoint(0, pointer.Y), new CanvasPoint(Viewport.Width, pointer.Y), - Style.TrackingStroke); + var x = GridRenderer.Snap(pointer.X, Style.TrackingStroke); + var y = GridRenderer.Snap(pointer.Y, Style.TrackingStroke); + context.DrawLine(new CanvasPoint(x, 0), new CanvasPoint(x, Viewport.Height), Style.TrackingStroke); + context.DrawLine(new CanvasPoint(0, y), new CanvasPoint(Viewport.Width, y), Style.TrackingStroke); } if (!ShowValueIndicator) diff --git a/src/SharpMath.Canvas/GridRenderer.cs b/src/SharpMath.Canvas/GridRenderer.cs index eeca06e..7e0b767 100644 --- a/src/SharpMath.Canvas/GridRenderer.cs +++ b/src/SharpMath.Canvas/GridRenderer.cs @@ -28,7 +28,7 @@ public static (double StepX, double StepY) Draw(IDrawingContext context, Viewpor for (var i = firstX; i <= lastX; i++) { var stroke = IsMultiple(i * minorX, majorX) ? style.MajorGridStroke : style.MinorGridStroke; - var x = viewport.WorldToScreen(new Point2D(i * minorX, 0)).X; + var x = Snap(viewport.WorldToScreen(new Point2D(i * minorX, 0)).X, stroke); context.DrawLine(new CanvasPoint(x, 0), new CanvasPoint(x, viewport.Height), stroke); } @@ -36,7 +36,7 @@ public static (double StepX, double StepY) Draw(IDrawingContext context, Viewpor for (var i = firstY; i <= lastY; i++) { var stroke = IsMultiple(i * minorY, majorY) ? style.MajorGridStroke : style.MinorGridStroke; - var y = viewport.WorldToScreen(new Point2D(0, i * minorY)).Y; + var y = Snap(viewport.WorldToScreen(new Point2D(0, i * minorY)).Y, stroke); context.DrawLine(new CanvasPoint(0, y), new CanvasPoint(viewport.Width, y), stroke); } @@ -45,6 +45,16 @@ public static (double StepX, double StepY) Draw(IDrawingContext context, Viewpor return (minorX, minorY); } + /// + /// Snaps the position of an axis-aligned line to the pixel grid so anti-aliasing renderers draw it crisply: lines + /// with an odd width are centered on a pixel, lines with an even width on a pixel border. + /// + public static double Snap(double coordinate, Stroke stroke) + { + var width = Math.Max(1, Math.Round(stroke.Width)); + return width % 2 == 1 ? Math.Floor(coordinate) + 0.5 : Math.Round(coordinate); + } + private static void DrawLabels(IDrawingContext context, Viewport viewport, CanvasStyle style, BoundingBox2D area, double stepX, double stepY) { @@ -86,9 +96,16 @@ private static void DrawAxes(IDrawingContext context, Viewport viewport, Stroke { var origin = viewport.Origin; if (origin.Y >= 0 && origin.Y <= viewport.Height) - context.DrawLine(new CanvasPoint(0, origin.Y), new CanvasPoint(viewport.Width, origin.Y), stroke); + { + var y = Snap(origin.Y, stroke); + context.DrawLine(new CanvasPoint(0, y), new CanvasPoint(viewport.Width, y), stroke); + } + if (origin.X >= 0 && origin.X <= viewport.Width) - context.DrawLine(new CanvasPoint(origin.X, 0), new CanvasPoint(origin.X, viewport.Height), stroke); + { + var x = Snap(origin.X, stroke); + context.DrawLine(new CanvasPoint(x, 0), new CanvasPoint(x, viewport.Height), stroke); + } } private static bool IsMultiple(double value, double step) diff --git a/tests/SharpMath.Canvas.Skia.Tests/SharpMath.Canvas.Skia.Tests.csproj b/tests/SharpMath.Canvas.Skia.Tests/SharpMath.Canvas.Skia.Tests.csproj new file mode 100644 index 0000000..5ee22e1 --- /dev/null +++ b/tests/SharpMath.Canvas.Skia.Tests/SharpMath.Canvas.Skia.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/SharpMath.Canvas.Skia.Tests/SkiaRenderingTests.cs b/tests/SharpMath.Canvas.Skia.Tests/SkiaRenderingTests.cs new file mode 100644 index 0000000..b8dd773 --- /dev/null +++ b/tests/SharpMath.Canvas.Skia.Tests/SkiaRenderingTests.cs @@ -0,0 +1,112 @@ +using SharpMath.Geometry; +using SkiaSharp; + +namespace SharpMath.Canvas.Skia.Tests; + +public class SkiaRenderingTests +{ + private static SKColor ToSkia(Color color) => new(color.R, color.G, color.B, color.A); + + private static SKBitmap Render(Canvas2D canvas, int width = 400, int height = 300) + { + using var image = canvas.RenderImage(width, height); + return SKBitmap.FromImage(image); + } + + [Fact] + public void RenderImage_DrawsBackgroundAxesAndGrid() + { + using var bitmap = Render(new Canvas2D()); + Assert.Equal(400, bitmap.Width); + Assert.Equal(300, bitmap.Height); + + // Probes away from text: background between grid lines, the axes and a grid line. + Assert.Equal(SKColors.White, bitmap.GetPixel(10, 280)); + Assert.Equal(ToSkia(CanvasStyle.Default.AxisStroke.Color), bitmap.GetPixel(30, 150)); + Assert.Equal(ToSkia(CanvasStyle.Default.AxisStroke.Color), bitmap.GetPixel(200, 30)); + // Grid lines are snapped to pixel centers, so they are crisp and exactly the grid color. + Assert.Equal(ToSkia(CanvasStyle.Default.MinorGridStroke.Color), bitmap.GetPixel(40, 280)); + } + + [Fact] + public void Items_AreDrawnWithTheirColors() + { + var canvas = new Canvas2D(); + var red = Color.FromRgb(255, 0, 0); + canvas.AddPolygon(new Polygon(new Point2D(2, 2), new Point2D(6, 2), new Point2D(6, 6), new Point2D(2, 6)), + new ItemStyle(new Stroke(red, 2), red)); + canvas.AddPoint(new Point2D(-5, -5), new ItemStyle(new Stroke(Color.Black), Color.Black, 5)); + using var bitmap = Render(canvas); + + Assert.Equal(SKColors.Red, bitmap.GetPixel(285, 75)); + Assert.Equal(SKColors.Black, bitmap.GetPixel(100, 250)); + } + + [Fact] + public void Text_IsMeasuredAndDrawn() + { + using var surface = SKSurface.Create(new SKImageInfo(100, 40)); + using var context = new SkiaDrawingContext(surface.Canvas); + var font = new FontSpec("Segoe UI", 20); + context.Clear(Color.White); + var size = context.MeasureText("1234", font); + Assert.InRange(size.Width, 10, 100); + Assert.InRange(size.Height, 15, 40); + context.DrawText("1234", new CanvasPoint(50, 5), font, Color.Black, TextAlignment.Center); + context.DrawText("1", new CanvasPoint(0, 5), font, Color.Black, TextAlignment.Left); + context.DrawText("1", new CanvasPoint(100, 5), font, Color.Black, TextAlignment.Right); + using var image = surface.Snapshot(); + using var bitmap = SKBitmap.FromImage(image); + var dark = 0; + for (var x = 0; x < 100; x++) + for (var y = 0; y < 40; y++) + if (bitmap.GetPixel(x, y).Red < 128) + dark++; + Assert.True(dark > 20, $"Expected the text to cover some pixels, got {dark}."); + } + + [Fact] + public void Context_DrawsEveryPrimitive() + { + using var surface = SKSurface.Create(new SKImageInfo(50, 50)); + using var context = new SkiaDrawingContext(surface.Canvas); + var blue = new Stroke(Color.FromRgb(0, 0, 255), 3, DashStyle.Dashed); + context.Clear(Color.White); + context.PushClip(new CanvasRect(0, 0, 40, 40)); + context.DrawRectangle(new CanvasRect(5, 5, 10, 10), blue, Color.FromRgb(0, 255, 0)); + context.DrawEllipse(new CanvasPoint(30, 30), 5, 5, new Stroke(Color.Black, 1, DashStyle.Dotted), Color.Black); + context.DrawPolyline([new CanvasPoint(0, 45), new CanvasPoint(50, 45)], blue); + context.DrawPolyline([new CanvasPoint(0, 0)], blue); + context.DrawPolygon([new CanvasPoint(0, 0), new CanvasPoint(1, 1)], blue, null); + context.PopClip(); + context.DrawLine(new CanvasPoint(45, 0), new CanvasPoint(45, 50), new Stroke(Color.Black, 2)); + using var image = surface.Snapshot(); + using var bitmap = SKBitmap.FromImage(image); + + Assert.Equal(new SKColor(0, 255, 0), bitmap.GetPixel(10, 10)); + Assert.Equal(SKColors.Black, bitmap.GetPixel(30, 30)); + Assert.Equal(SKColors.White, bitmap.GetPixel(25, 45)); + Assert.Equal(SKColors.Black, bitmap.GetPixel(45, 25)); + } + + [Fact] + public void RenderPng_WritesAPngFile() + { + using var stream = new MemoryStream(); + new Canvas2D().RenderPng(stream, 64, 48); + var bytes = stream.ToArray(); + Assert.Equal([0x89, (byte) 'P', (byte) 'N', (byte) 'G'], bytes[..4]); + using var decoded = SKBitmap.Decode(bytes); + Assert.Equal(64, decoded.Width); + } + + [Fact] + public void Validation() + { + var canvas = new Canvas2D(); + Assert.Throws(() => canvas.RenderImage(0, 1)); + Assert.Throws(() => canvas.RenderPng(null!, 1, 1)); + Assert.Throws(() => ((Canvas2D) null!).RenderImage(1, 1)); + Assert.Throws(() => new SkiaDrawingContext(null!)); + } +} diff --git a/tests/SharpMath.Canvas.Tests/Golden/empty.svg b/tests/SharpMath.Canvas.Tests/Golden/empty.svg index b7a8177..72a8795 100644 --- a/tests/SharpMath.Canvas.Tests/Golden/empty.svg +++ b/tests/SharpMath.Canvas.Tests/Golden/empty.svg @@ -4,26 +4,26 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + 0 - - + + diff --git a/tests/SharpMath.Canvas.Tests/Golden/items.svg b/tests/SharpMath.Canvas.Tests/Golden/items.svg index a45118b..51fe0c8 100644 --- a/tests/SharpMath.Canvas.Tests/Golden/items.svg +++ b/tests/SharpMath.Canvas.Tests/Golden/items.svg @@ -4,49 +4,49 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -5 5 -5 0 5 - - + + @@ -55,8 +55,8 @@ - - + + 5.0 | 4.5 From b5624c98bff5019f5e6198c6caf44120f01686d3 Mon Sep 17 00:00:00 2001 From: Dominic Beger Date: Sun, 11 Oct 2026 02:21:03 +0200 Subject: [PATCH 07/14] Add WinForms control and GDI+ renderer for the canvas --- SharpMath.slnx | 2 + .../Canvas2DControl.cs | 114 ++++++++++ .../GdiDrawingContext.cs | 203 ++++++++++++++++++ .../SharpMath.Canvas.WinForms.csproj | 14 ++ .../Canvas2DControlTests.cs | 57 +++++ .../GdiDrawingContextTests.cs | 80 +++++++ .../SharpMath.Canvas.WinForms.Tests.csproj | 19 ++ 7 files changed, 489 insertions(+) create mode 100644 src/SharpMath.Canvas.WinForms/Canvas2DControl.cs create mode 100644 src/SharpMath.Canvas.WinForms/GdiDrawingContext.cs create mode 100644 src/SharpMath.Canvas.WinForms/SharpMath.Canvas.WinForms.csproj create mode 100644 tests/SharpMath.Canvas.WinForms.Tests/Canvas2DControlTests.cs create mode 100644 tests/SharpMath.Canvas.WinForms.Tests/GdiDrawingContextTests.cs create mode 100644 tests/SharpMath.Canvas.WinForms.Tests/SharpMath.Canvas.WinForms.Tests.csproj diff --git a/SharpMath.slnx b/SharpMath.slnx index 7bef3dc..d32b017 100644 --- a/SharpMath.slnx +++ b/SharpMath.slnx @@ -1,12 +1,14 @@ + + diff --git a/src/SharpMath.Canvas.WinForms/Canvas2DControl.cs b/src/SharpMath.Canvas.WinForms/Canvas2DControl.cs new file mode 100644 index 0000000..8fb5942 --- /dev/null +++ b/src/SharpMath.Canvas.WinForms/Canvas2DControl.cs @@ -0,0 +1,114 @@ +using System.ComponentModel; + +namespace SharpMath.Canvas.WinForms; + +/// +/// A WinForms control that displays a . Drag with the left mouse button to pan and use the +/// mouse wheel to zoom. +/// +[ToolboxItem(true)] +[DesignerCategory("Code")] +public class Canvas2DControl : Control +{ + /// + /// Initializes a new instance of the class. + /// + public Canvas2DControl() + { + SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.UserPaint | + ControlStyles.ResizeRedraw, true); + Canvas.Changed += OnCanvasChanged; + } + + /// + /// Gets the canvas model: add items, change the style or move the viewport through it. + /// + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public Canvas2D Canvas { get; } = new(); + + /// + /// Gets or sets a value indicating whether horizontal and vertical lines are drawn through the mouse pointer. + /// + [Category("Behavior")] + [DefaultValue(false)] + [Description("Draws horizontal and vertical lines through the mouse pointer.")] + public bool ShowTrackingLines + { + get => Canvas.ShowTrackingLines; + set => Canvas.ShowTrackingLines = value; + } + + /// + /// Gets or sets a value indicating whether the world coordinates of the mouse pointer are shown next to it. + /// + [Category("Behavior")] + [DefaultValue(false)] + [Description("Shows the world coordinates of the mouse pointer.")] + public bool ShowValueIndicator + { + get => Canvas.ShowValueIndicator; + set => Canvas.ShowValueIndicator = value; + } + + /// + protected override void OnPaint(PaintEventArgs e) + { + ArgumentNullException.ThrowIfNull(e); + base.OnPaint(e); + using var context = new GdiDrawingContext(e.Graphics); + Canvas.Render(context, ClientSize.Width, ClientSize.Height); + } + + /// + protected override void OnMouseDown(MouseEventArgs e) + { + ArgumentNullException.ThrowIfNull(e); + base.OnMouseDown(e); + if (e.Button == MouseButtons.Left) + Canvas.PointerPressed(new CanvasPoint(e.X, e.Y)); + Focus(); + } + + /// + protected override void OnMouseMove(MouseEventArgs e) + { + ArgumentNullException.ThrowIfNull(e); + base.OnMouseMove(e); + Canvas.PointerMoved(new CanvasPoint(e.X, e.Y)); + } + + /// + protected override void OnMouseUp(MouseEventArgs e) + { + ArgumentNullException.ThrowIfNull(e); + base.OnMouseUp(e); + if (e.Button == MouseButtons.Left) + Canvas.PointerReleased(new CanvasPoint(e.X, e.Y)); + } + + /// + protected override void OnMouseLeave(EventArgs e) + { + base.OnMouseLeave(e); + Canvas.PointerExited(); + } + + /// + protected override void OnMouseWheel(MouseEventArgs e) + { + ArgumentNullException.ThrowIfNull(e); + base.OnMouseWheel(e); + Canvas.Wheel(new CanvasPoint(e.X, e.Y), (double) e.Delta / SystemInformation.MouseWheelScrollDelta); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + Canvas.Changed -= OnCanvasChanged; + base.Dispose(disposing); + } + + private void OnCanvasChanged(object? sender, EventArgs e) => Invalidate(); +} diff --git a/src/SharpMath.Canvas.WinForms/GdiDrawingContext.cs b/src/SharpMath.Canvas.WinForms/GdiDrawingContext.cs new file mode 100644 index 0000000..1fae996 --- /dev/null +++ b/src/SharpMath.Canvas.WinForms/GdiDrawingContext.cs @@ -0,0 +1,203 @@ +using System.Drawing.Drawing2D; +using System.Drawing.Text; +using DrawingColor = System.Drawing.Color; +using GdiDashStyle = System.Drawing.Drawing2D.DashStyle; + +namespace SharpMath.Canvas.WinForms; + +/// +/// An that draws with GDI+ on a (Windows only). +/// +/// +/// Pens, brushes and fonts are cached for the lifetime of the context; dispose it after drawing. The rendering +/// settings of the are changed for anti-aliasing and restored on disposal; the +/// itself is not owned by the context. +/// +public sealed class GdiDrawingContext : IDrawingContext, IDisposable +{ + private readonly Dictionary _brushes = []; + private readonly Stack _clips = new(); + private readonly Dictionary _fonts = []; + private readonly Graphics _graphics; + private readonly PixelOffsetMode _originalPixelOffsetMode; + private readonly SmoothingMode _originalSmoothingMode; + private readonly TextRenderingHint _originalTextRenderingHint; + private readonly Dictionary _pens = []; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The GDI+ surface to draw on. + public GdiDrawingContext(Graphics graphics) + { + ArgumentNullException.ThrowIfNull(graphics); + _graphics = graphics; + _originalSmoothingMode = graphics.SmoothingMode; + _originalPixelOffsetMode = graphics.PixelOffsetMode; + _originalTextRenderingHint = graphics.TextRenderingHint; + + // Pixel centers at .5 like the other renderers, so snapped grid lines stay crisp. + graphics.SmoothingMode = SmoothingMode.AntiAlias; + graphics.PixelOffsetMode = PixelOffsetMode.Half; + graphics.TextRenderingHint = TextRenderingHint.AntiAliasGridFit; + } + + /// + public void Clear(Color color) => _graphics.Clear(ToGdi(color)); + + /// + public void DrawLine(CanvasPoint startPoint, CanvasPoint endPoint, Stroke stroke) => + _graphics.DrawLine(Pen(stroke), ToGdi(startPoint), ToGdi(endPoint)); + + /// + public void DrawPolyline(ReadOnlySpan points, Stroke stroke) + { + if (points.Length >= 2) + _graphics.DrawLines(Pen(stroke), ToGdi(points)); + } + + /// + public void DrawPolygon(ReadOnlySpan points, Stroke? stroke, Color? fill) + { + if (points.Length < 3) + return; + var gdiPoints = ToGdi(points); + if (fill is { } fillColor) + _graphics.FillPolygon(Brush(fillColor), gdiPoints); + if (stroke is { } outline) + _graphics.DrawPolygon(Pen(outline), gdiPoints); + } + + /// + public void DrawRectangle(CanvasRect rect, Stroke? stroke, Color? fill) + { + var bounds = new RectangleF((float) rect.X, (float) rect.Y, (float) rect.Width, (float) rect.Height); + if (fill is { } fillColor) + _graphics.FillRectangle(Brush(fillColor), bounds); + if (stroke is { } outline) + _graphics.DrawRectangle(Pen(outline), bounds.X, bounds.Y, bounds.Width, bounds.Height); + } + + /// + public void DrawEllipse(CanvasPoint center, double radiusX, double radiusY, Stroke? stroke, Color? fill) + { + var bounds = new RectangleF((float) (center.X - radiusX), (float) (center.Y - radiusY), (float) (2 * radiusX), + (float) (2 * radiusY)); + if (fill is { } fillColor) + _graphics.FillEllipse(Brush(fillColor), bounds); + if (stroke is { } outline) + _graphics.DrawEllipse(Pen(outline), bounds); + } + + /// + public void DrawText(string text, CanvasPoint position, FontSpec font, Color color, TextAlignment alignment) + { + ArgumentNullException.ThrowIfNull(text); + using var format = (StringFormat) StringFormat.GenericTypographic.Clone(); + format.Alignment = alignment switch + { + TextAlignment.Center => StringAlignment.Center, + TextAlignment.Right => StringAlignment.Far, + _ => StringAlignment.Near + }; + _graphics.DrawString(text, Font(font), Brush(color), ToGdi(position), format); + } + + /// + public TextSize MeasureText(string text, FontSpec font) + { + ArgumentNullException.ThrowIfNull(text); + var gdiFont = Font(font); + var size = _graphics.MeasureString(text, gdiFont, PointF.Empty, StringFormat.GenericTypographic); + return new TextSize(size.Width, gdiFont.GetHeight(_graphics)); + } + + /// + public void PushClip(CanvasRect rect) + { + _clips.Push(_graphics.Save()); + _graphics.SetClip(new RectangleF((float) rect.X, (float) rect.Y, (float) rect.Width, (float) rect.Height), + CombineMode.Intersect); + } + + /// + public void PopClip() + { + if (_clips.Count == 0) + throw new InvalidOperationException("PopClip was called without a matching PushClip."); + _graphics.Restore(_clips.Pop()); + } + + /// + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + while (_clips.Count > 0) + _graphics.Restore(_clips.Pop()); + _graphics.SmoothingMode = _originalSmoothingMode; + _graphics.PixelOffsetMode = _originalPixelOffsetMode; + _graphics.TextRenderingHint = _originalTextRenderingHint; + + foreach (var pen in _pens.Values) + pen.Dispose(); + foreach (var brush in _brushes.Values) + brush.Dispose(); + foreach (var font in _fonts.Values) + font.Dispose(); + } + + private static DrawingColor ToGdi(Color color) => DrawingColor.FromArgb(color.A, color.R, color.G, color.B); + + private static PointF ToGdi(CanvasPoint point) => new((float) point.X, (float) point.Y); + + private static PointF[] ToGdi(ReadOnlySpan points) + { + var result = new PointF[points.Length]; + for (var i = 0; i < points.Length; i++) + result[i] = ToGdi(points[i]); + return result; + } + + private Pen Pen(Stroke stroke) + { + if (_pens.TryGetValue(stroke, out var pen)) + return pen; + + pen = new Pen(ToGdi(stroke.Color), (float) stroke.Width) + { + DashStyle = stroke.Dash switch + { + DashStyle.Dashed => GdiDashStyle.Dash, + DashStyle.Dotted => GdiDashStyle.Dot, + _ => GdiDashStyle.Solid + } + }; + _pens.Add(stroke, pen); + return pen; + } + + private SolidBrush Brush(Color color) + { + if (_brushes.TryGetValue(color, out var brush)) + return brush; + + brush = new SolidBrush(ToGdi(color)); + _brushes.Add(color, brush); + return brush; + } + + private Font Font(FontSpec font) + { + if (_fonts.TryGetValue(font, out var gdiFont)) + return gdiFont; + + // GDI+ falls back to a default font if the family is not installed. + gdiFont = new Font(font.Family, (float) font.Size, GraphicsUnit.Pixel); + _fonts.Add(font, gdiFont); + return gdiFont; + } +} diff --git a/src/SharpMath.Canvas.WinForms/SharpMath.Canvas.WinForms.csproj b/src/SharpMath.Canvas.WinForms/SharpMath.Canvas.WinForms.csproj new file mode 100644 index 0000000..f811029 --- /dev/null +++ b/src/SharpMath.Canvas.WinForms/SharpMath.Canvas.WinForms.csproj @@ -0,0 +1,14 @@ + + + + net10.0-windows + true + true + WinForms control and GDI+ renderer for the SharpMath 2D canvas (Windows only). + + + + + + + diff --git a/tests/SharpMath.Canvas.WinForms.Tests/Canvas2DControlTests.cs b/tests/SharpMath.Canvas.WinForms.Tests/Canvas2DControlTests.cs new file mode 100644 index 0000000..6e44c45 --- /dev/null +++ b/tests/SharpMath.Canvas.WinForms.Tests/Canvas2DControlTests.cs @@ -0,0 +1,57 @@ +using SharpMath.Geometry; + +namespace SharpMath.Canvas.WinForms.Tests; + +public class Canvas2DControlTests +{ + private sealed class TestControl : Canvas2DControl + { + public int Invalidations { get; private set; } + + public void Press(int x, int y) => OnMouseDown(new MouseEventArgs(MouseButtons.Left, 1, x, y, 0)); + public void MovePointer(int x, int y) => OnMouseMove(new MouseEventArgs(MouseButtons.None, 0, x, y, 0)); + public void Release(int x, int y) => OnMouseUp(new MouseEventArgs(MouseButtons.Left, 1, x, y, 0)); + public void Scroll(int x, int y, int delta) => OnMouseWheel(new MouseEventArgs(MouseButtons.None, 0, x, y, delta)); + public void LeavePointer() => OnMouseLeave(EventArgs.Empty); + + protected override void OnInvalidated(InvalidateEventArgs e) + { + Invalidations++; + base.OnInvalidated(e); + } + } + + [Fact] + public void Paint_RendersTheCanvas() + { + using var control = new Canvas2DControl { Size = new Size(200, 100) }; + control.Canvas.AddPoint(new Point2D(0, 0), new ItemStyle(new Stroke(Color.Black), Color.Black, 4)); + using var bitmap = new Bitmap(200, 100); + control.DrawToBitmap(bitmap, new Rectangle(0, 0, 200, 100)); + Assert.Equal(System.Drawing.Color.Black.ToArgb(), bitmap.GetPixel(100, 50).ToArgb()); + } + + [Fact] + public void MouseInput_PansZoomsAndInvalidates() + { + using var control = new TestControl { Size = new Size(200, 100), ShowTrackingLines = true }; + + // Invalidate only raises Invalidated once the window handle exists. + _ = control.Handle; + control.Canvas.Viewport.Resize(200, 100); + Assert.True(control.ShowTrackingLines); + control.ShowValueIndicator = true; + Assert.True(control.Canvas.ShowValueIndicator); + + control.Press(10, 10); + control.MovePointer(30, 20); + control.Release(30, 20); + Assert.Equal(new CanvasPoint(120, 60), control.Canvas.Viewport.Origin); + + control.Scroll(120, 60, 120); + Assert.Equal(25, control.Canvas.Viewport.ScaleX, 9); + control.LeavePointer(); + Assert.Null(control.Canvas.PointerPosition); + Assert.True(control.Invalidations > 0); + } +} diff --git a/tests/SharpMath.Canvas.WinForms.Tests/GdiDrawingContextTests.cs b/tests/SharpMath.Canvas.WinForms.Tests/GdiDrawingContextTests.cs new file mode 100644 index 0000000..ac1868c --- /dev/null +++ b/tests/SharpMath.Canvas.WinForms.Tests/GdiDrawingContextTests.cs @@ -0,0 +1,80 @@ +using SharpMath.Geometry; +using CanvasColor = SharpMath.Canvas.Color; +using DrawingColor = System.Drawing.Color; + +namespace SharpMath.Canvas.WinForms.Tests; + +public class GdiDrawingContextTests +{ + private static DrawingColor ToGdi(CanvasColor color) => DrawingColor.FromArgb(color.A, color.R, color.G, color.B); + + private static Bitmap Render(Canvas2D canvas, int width = 400, int height = 300) + { + var bitmap = new Bitmap(width, height); + using var graphics = Graphics.FromImage(bitmap); + using var context = new GdiDrawingContext(graphics); + canvas.Render(context, width, height); + return bitmap; + } + + [Fact] + public void Render_DrawsBackgroundAxesAndGrid() + { + using var bitmap = Render(new Canvas2D()); + Assert.Equal(DrawingColor.White.ToArgb(), bitmap.GetPixel(10, 280).ToArgb()); + Assert.Equal(ToGdi(CanvasStyle.Default.AxisStroke.Color).ToArgb(), bitmap.GetPixel(30, 150).ToArgb()); + Assert.Equal(ToGdi(CanvasStyle.Default.AxisStroke.Color).ToArgb(), bitmap.GetPixel(200, 30).ToArgb()); + Assert.Equal(ToGdi(CanvasStyle.Default.MinorGridStroke.Color).ToArgb(), bitmap.GetPixel(40, 280).ToArgb()); + } + + [Fact] + public void Render_DrawsItems() + { + var canvas = new Canvas2D(); + var red = CanvasColor.FromRgb(255, 0, 0); + canvas.AddPolygon(new Polygon(new Point2D(2, 2), new Point2D(6, 2), new Point2D(6, 6), new Point2D(2, 6)), + new ItemStyle(new Stroke(red, 2), red)); + canvas.AddPoint(new Point2D(-5, -5), new ItemStyle(new Stroke(CanvasColor.Black), CanvasColor.Black, 5)); + canvas.AddFunction("x", new ItemStyle(new Stroke(CanvasColor.Black, 1, DashStyle.Dashed))); + canvas.AddVector(new Vector2(-4, 4)); + canvas.ShowTrackingLines = true; + canvas.ShowValueIndicator = true; + // Away from the probed pixels, so the tracking lines do not cover them. + canvas.PointerMoved(new CanvasPoint(370, 20)); + using var bitmap = Render(canvas); + + Assert.Equal(DrawingColor.Red.ToArgb(), bitmap.GetPixel(285, 75).ToArgb()); + Assert.Equal(DrawingColor.Black.ToArgb(), bitmap.GetPixel(100, 250).ToArgb()); + } + + [Fact] + public void Text_IsMeasured() + { + using var bitmap = new Bitmap(10, 10); + using var graphics = Graphics.FromImage(bitmap); + using var context = new GdiDrawingContext(graphics); + var size = context.MeasureText("1234", new FontSpec("Segoe UI", 20)); + Assert.InRange(size.Width, 10, 100); + Assert.InRange(size.Height, 15, 40); + context.DrawText("x", new CanvasPoint(0, 0), new FontSpec("Font that does not exist", 10), CanvasColor.Black, + TextAlignment.Right); + } + + [Fact] + public void Dispose_RestoresGraphicsSettings() + { + using var bitmap = new Bitmap(10, 10); + using var graphics = Graphics.FromImage(bitmap); + var smoothing = graphics.SmoothingMode; + var context = new GdiDrawingContext(graphics); + context.PushClip(new CanvasRect(0, 0, 5, 5)); + context.Dispose(); + context.Dispose(); + Assert.Equal(smoothing, graphics.SmoothingMode); + using (var clip = graphics.Clip) + Assert.True(clip.IsInfinite(graphics)); + using var other = new GdiDrawingContext(graphics); + Assert.Throws(() => other.PopClip()); + Assert.Throws(() => new GdiDrawingContext(null!)); + } +} diff --git a/tests/SharpMath.Canvas.WinForms.Tests/SharpMath.Canvas.WinForms.Tests.csproj b/tests/SharpMath.Canvas.WinForms.Tests/SharpMath.Canvas.WinForms.Tests.csproj new file mode 100644 index 0000000..5fcb4fd --- /dev/null +++ b/tests/SharpMath.Canvas.WinForms.Tests/SharpMath.Canvas.WinForms.Tests.csproj @@ -0,0 +1,19 @@ + + + + net10.0-windows + true + true + + + + + false + false + + + + + + + From a8c1b00b0568cdd3010b3628ed654372c20d34b2 Mon Sep 17 00:00:00 2001 From: Dominic Beger Date: Sun, 11 Oct 2026 02:24:13 +0200 Subject: [PATCH 08/14] Add Avalonia control for the canvas The view records the canvas on the UI thread into a display list and replays it with the Skia renderer on Avalonia's render thread. --- SharpMath.slnx | 2 + src/SharpMath.Canvas.Avalonia/Canvas2DView.cs | 208 ++++++++++++++++++ src/SharpMath.Canvas.Avalonia/DisplayList.cs | 52 +++++ .../SharpMath.Canvas.Avalonia.csproj | 16 ++ .../SkiaDrawingContext.cs | 35 +-- src/SharpMath.Canvas.Skia/SkiaFonts.cs | 45 ++++ .../Canvas2DViewTests.cs | 106 +++++++++ .../DisplayListTests.cs | 45 ++++ .../SharpMath.Canvas.Avalonia.Tests.csproj | 22 ++ .../TestApplication.cs | 18 ++ 10 files changed, 518 insertions(+), 31 deletions(-) create mode 100644 src/SharpMath.Canvas.Avalonia/Canvas2DView.cs create mode 100644 src/SharpMath.Canvas.Avalonia/DisplayList.cs create mode 100644 src/SharpMath.Canvas.Avalonia/SharpMath.Canvas.Avalonia.csproj create mode 100644 src/SharpMath.Canvas.Skia/SkiaFonts.cs create mode 100644 tests/SharpMath.Canvas.Avalonia.Tests/Canvas2DViewTests.cs create mode 100644 tests/SharpMath.Canvas.Avalonia.Tests/DisplayListTests.cs create mode 100644 tests/SharpMath.Canvas.Avalonia.Tests/SharpMath.Canvas.Avalonia.Tests.csproj create mode 100644 tests/SharpMath.Canvas.Avalonia.Tests/TestApplication.cs diff --git a/SharpMath.slnx b/SharpMath.slnx index d32b017..4f8dced 100644 --- a/SharpMath.slnx +++ b/SharpMath.slnx @@ -1,11 +1,13 @@ + + diff --git a/src/SharpMath.Canvas.Avalonia/Canvas2DView.cs b/src/SharpMath.Canvas.Avalonia/Canvas2DView.cs new file mode 100644 index 0000000..f6ab68d --- /dev/null +++ b/src/SharpMath.Canvas.Avalonia/Canvas2DView.cs @@ -0,0 +1,208 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Rendering.SceneGraph; +using Avalonia.Skia; +using Avalonia.Threading; +using SharpMath.Canvas.Skia; +using SkiaSharp; + +namespace SharpMath.Canvas.Avalonia; + +/// +/// An Avalonia control that displays a . Drag with the left mouse button to pan and use the +/// mouse wheel to zoom. Requires Avalonia's Skia renderer (the default on all desktop platforms). +/// +public class Canvas2DView : Control +{ + /// + /// Defines the property. + /// + public static readonly StyledProperty ShowTrackingLinesProperty = + AvaloniaProperty.Register(nameof(ShowTrackingLines)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ShowValueIndicatorProperty = + AvaloniaProperty.Register(nameof(ShowValueIndicator)); + + // Text is measured with Skia on the UI thread through a context that is kept while the view is attached, so + // fonts are looked up once rather than every frame. + private TextMeasurer? _measurer; + + /// + /// Initializes a new instance of the class. + /// + public Canvas2DView() + { + ClipToBounds = true; + Focusable = true; + Canvas.Changed += OnCanvasChanged; + } + + /// + /// Gets the canvas model: add items, change the style or move the viewport through it. + /// + public Canvas2D Canvas { get; } = new(); + + /// + /// Gets or sets a value indicating whether horizontal and vertical lines are drawn through the pointer. + /// + public bool ShowTrackingLines + { + get => GetValue(ShowTrackingLinesProperty); + set => SetValue(ShowTrackingLinesProperty, value); + } + + /// + /// Gets or sets a value indicating whether the world coordinates of the pointer are shown next to it. + /// + public bool ShowValueIndicator + { + get => GetValue(ShowValueIndicatorProperty); + set => SetValue(ShowValueIndicatorProperty, value); + } + + /// + public override void Render(DrawingContext context) + { + ArgumentNullException.ThrowIfNull(context); + var size = Bounds.Size; + _measurer ??= new TextMeasurer(); + var displayList = new DisplayList(_measurer.Context); + Canvas.Render(displayList, size.Width, size.Height); + context.Custom(new CanvasDrawOperation(new Rect(size), displayList)); + } + + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + _measurer?.Dispose(); + _measurer = null; + } + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + ArgumentNullException.ThrowIfNull(change); + base.OnPropertyChanged(change); + if (change.Property == ShowTrackingLinesProperty) + Canvas.ShowTrackingLines = change.GetNewValue(); + else if (change.Property == ShowValueIndicatorProperty) + Canvas.ShowValueIndicator = change.GetNewValue(); + } + + /// + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + ArgumentNullException.ThrowIfNull(e); + base.OnPointerPressed(e); + var point = e.GetCurrentPoint(this); + if (!point.Properties.IsLeftButtonPressed) + return; + Canvas.PointerPressed(ToCanvas(point.Position)); + e.Pointer.Capture(this); + e.Handled = true; + } + + /// + protected override void OnPointerMoved(PointerEventArgs e) + { + ArgumentNullException.ThrowIfNull(e); + base.OnPointerMoved(e); + Canvas.PointerMoved(ToCanvas(e.GetPosition(this))); + } + + /// + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + ArgumentNullException.ThrowIfNull(e); + base.OnPointerReleased(e); + if (e.InitialPressMouseButton != MouseButton.Left) + return; + Canvas.PointerReleased(ToCanvas(e.GetPosition(this))); + e.Pointer.Capture(null); + e.Handled = true; + } + + /// + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + Canvas.PointerExited(); + } + + /// + protected override void OnPointerWheelChanged(PointerWheelEventArgs e) + { + ArgumentNullException.ThrowIfNull(e); + base.OnPointerWheelChanged(e); + Canvas.Wheel(ToCanvas(e.GetPosition(this)), e.Delta.Y); + e.Handled = true; + } + + private static CanvasPoint ToCanvas(Point point) => new(point.X, point.Y); + + private void OnCanvasChanged(object? sender, EventArgs e) + { + if (Dispatcher.UIThread.CheckAccess()) + InvalidateVisual(); + else + Dispatcher.UIThread.Post(InvalidateVisual); + } + + /// + /// Replays the recorded canvas on Skia's canvas on the render thread. + /// + private sealed class CanvasDrawOperation(Rect bounds, DisplayList displayList) : ICustomDrawOperation + { + public Rect Bounds => bounds; + + public bool HitTest(Point p) => bounds.Contains(p); + + public void Render(ImmediateDrawingContext context) + { + if (context.TryGetFeature(typeof(ISkiaSharpApiLeaseFeature)) is not ISkiaSharpApiLeaseFeature feature) + return; + + using var lease = feature.Lease(); + using var skia = new SkiaDrawingContext(lease.SkCanvas); + skia.PushClip(new CanvasRect(0, 0, bounds.Width, bounds.Height)); + displayList.Replay(skia); + skia.PopClip(); + } + + public bool Equals(ICustomDrawOperation? other) => ReferenceEquals(this, other); + + public void Dispose() + { + } + } + + /// + /// A Skia context on a 1x1 bitmap, used only to measure text. + /// + private sealed class TextMeasurer : IDisposable + { + private readonly SKBitmap _bitmap = new(1, 1); + private readonly SKCanvas _canvas; + + public TextMeasurer() + { + _canvas = new SKCanvas(_bitmap); + Context = new SkiaDrawingContext(_canvas); + } + + public SkiaDrawingContext Context { get; } + + public void Dispose() + { + Context.Dispose(); + _canvas.Dispose(); + _bitmap.Dispose(); + } + } +} diff --git a/src/SharpMath.Canvas.Avalonia/DisplayList.cs b/src/SharpMath.Canvas.Avalonia/DisplayList.cs new file mode 100644 index 0000000..20b1abb --- /dev/null +++ b/src/SharpMath.Canvas.Avalonia/DisplayList.cs @@ -0,0 +1,52 @@ +namespace SharpMath.Canvas.Avalonia; + +/// +/// Records drawing calls so the canvas can be drawn on Avalonia's render thread without touching the model there. +/// Text is measured by , which should use the same engine that later replays the +/// commands. +/// +/// The context used for . +internal sealed class DisplayList(IDrawingContext measurer) : IDrawingContext +{ + private readonly List> _commands = []; + + public int Count => _commands.Count; + + public void Clear(Color color) => _commands.Add(context => context.Clear(color)); + + public void DrawLine(CanvasPoint startPoint, CanvasPoint endPoint, Stroke stroke) => + _commands.Add(context => context.DrawLine(startPoint, endPoint, stroke)); + + public void DrawPolyline(ReadOnlySpan points, Stroke stroke) + { + var copy = points.ToArray(); + _commands.Add(context => context.DrawPolyline(copy, stroke)); + } + + public void DrawPolygon(ReadOnlySpan points, Stroke? stroke, Color? fill) + { + var copy = points.ToArray(); + _commands.Add(context => context.DrawPolygon(copy, stroke, fill)); + } + + public void DrawRectangle(CanvasRect rect, Stroke? stroke, Color? fill) => + _commands.Add(context => context.DrawRectangle(rect, stroke, fill)); + + public void DrawEllipse(CanvasPoint center, double radiusX, double radiusY, Stroke? stroke, Color? fill) => + _commands.Add(context => context.DrawEllipse(center, radiusX, radiusY, stroke, fill)); + + public void DrawText(string text, CanvasPoint position, FontSpec font, Color color, TextAlignment alignment) => + _commands.Add(context => context.DrawText(text, position, font, color, alignment)); + + public TextSize MeasureText(string text, FontSpec font) => measurer.MeasureText(text, font); + + public void PushClip(CanvasRect rect) => _commands.Add(context => context.PushClip(rect)); + + public void PopClip() => _commands.Add(context => context.PopClip()); + + public void Replay(IDrawingContext context) + { + foreach (var command in _commands) + command(context); + } +} diff --git a/src/SharpMath.Canvas.Avalonia/SharpMath.Canvas.Avalonia.csproj b/src/SharpMath.Canvas.Avalonia/SharpMath.Canvas.Avalonia.csproj new file mode 100644 index 0000000..53e18eb --- /dev/null +++ b/src/SharpMath.Canvas.Avalonia/SharpMath.Canvas.Avalonia.csproj @@ -0,0 +1,16 @@ + + + + Avalonia control for the SharpMath 2D canvas: interactive plotting on Windows, Linux and macOS. + + + + + + + + + + + + diff --git a/src/SharpMath.Canvas.Skia/SkiaDrawingContext.cs b/src/SharpMath.Canvas.Skia/SkiaDrawingContext.cs index 1a38a49..5cce437 100644 --- a/src/SharpMath.Canvas.Skia/SkiaDrawingContext.cs +++ b/src/SharpMath.Canvas.Skia/SkiaDrawingContext.cs @@ -13,9 +13,8 @@ public sealed class SkiaDrawingContext : IDrawingContext, IDisposable { private readonly SKCanvas _canvas; private readonly Dictionary _fills = []; - private readonly Dictionary _fonts = []; + private readonly SkiaFonts _fonts = new(); private readonly Dictionary _strokes = []; - private readonly Dictionary _typefaces = []; private readonly SKPath _path = new(); /// @@ -80,7 +79,7 @@ public void DrawEllipse(CanvasPoint center, double radiusX, double radiusY, Stro public void DrawText(string text, CanvasPoint position, FontSpec font, Color color, TextAlignment alignment) { ArgumentNullException.ThrowIfNull(text); - var skFont = Font(font); + var skFont = _fonts.Get(font); var align = alignment switch { TextAlignment.Center => SKTextAlign.Center, @@ -94,12 +93,7 @@ public void DrawText(string text, CanvasPoint position, FontSpec font, Color col } /// - public TextSize MeasureText(string text, FontSpec font) - { - ArgumentNullException.ThrowIfNull(text); - var skFont = Font(font); - return new TextSize(skFont.MeasureText(text), skFont.Spacing); - } + public TextSize MeasureText(string text, FontSpec font) => _fonts.Measure(text, font); /// public void PushClip(CanvasRect rect) @@ -119,15 +113,10 @@ public void Dispose() paint.PathEffect?.Dispose(); paint.Dispose(); } - foreach (var font in _fonts.Values) - font.Dispose(); - foreach (var typeface in _typefaces.Values) - typeface.Dispose(); + _fonts.Dispose(); _path.Dispose(); _strokes.Clear(); _fills.Clear(); - _fonts.Clear(); - _typefaces.Clear(); } private static SKColor ToSkia(Color color) => new(color.R, color.G, color.B, color.A); @@ -174,20 +163,4 @@ private SKPaint FillPaint(Color color) _fills.Add(color, paint); return paint; } - - private SKFont Font(FontSpec font) - { - if (_fonts.TryGetValue(font, out var skFont)) - return skFont; - - if (!_typefaces.TryGetValue(font.Family, out var typeface)) - { - typeface = SKTypeface.FromFamilyName(font.Family) ?? SKTypeface.CreateDefault(); - _typefaces.Add(font.Family, typeface); - } - - skFont = new SKFont(typeface, (float) font.Size) { Subpixel = true }; - _fonts.Add(font, skFont); - return skFont; - } } diff --git a/src/SharpMath.Canvas.Skia/SkiaFonts.cs b/src/SharpMath.Canvas.Skia/SkiaFonts.cs new file mode 100644 index 0000000..901545f --- /dev/null +++ b/src/SharpMath.Canvas.Skia/SkiaFonts.cs @@ -0,0 +1,45 @@ +using SkiaSharp; + +namespace SharpMath.Canvas.Skia; + +/// +/// Creates and caches Skia fonts and measures text. Not thread-safe; use one instance per thread. +/// +internal sealed class SkiaFonts : IDisposable +{ + private readonly Dictionary _fonts = []; + private readonly Dictionary _typefaces = []; + + public SKFont Get(FontSpec font) + { + if (_fonts.TryGetValue(font, out var skFont)) + return skFont; + + if (!_typefaces.TryGetValue(font.Family, out var typeface)) + { + typeface = SKTypeface.FromFamilyName(font.Family) ?? SKTypeface.CreateDefault(); + _typefaces.Add(font.Family, typeface); + } + + skFont = new SKFont(typeface, (float) font.Size) { Subpixel = true }; + _fonts.Add(font, skFont); + return skFont; + } + + public TextSize Measure(string text, FontSpec font) + { + ArgumentNullException.ThrowIfNull(text); + var skFont = Get(font); + return new TextSize(skFont.MeasureText(text), skFont.Spacing); + } + + public void Dispose() + { + foreach (var font in _fonts.Values) + font.Dispose(); + foreach (var typeface in _typefaces.Values) + typeface.Dispose(); + _fonts.Clear(); + _typefaces.Clear(); + } +} diff --git a/tests/SharpMath.Canvas.Avalonia.Tests/Canvas2DViewTests.cs b/tests/SharpMath.Canvas.Avalonia.Tests/Canvas2DViewTests.cs new file mode 100644 index 0000000..fe46656 --- /dev/null +++ b/tests/SharpMath.Canvas.Avalonia.Tests/Canvas2DViewTests.cs @@ -0,0 +1,106 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using System.Runtime.InteropServices; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using SharpMath.Geometry; + +namespace SharpMath.Canvas.Avalonia.Tests; + +public class Canvas2DViewTests +{ + private static (Window Window, Canvas2DView View) Show(int width = 200, int height = 100) + { + var view = new Canvas2DView(); + var window = new Window { Width = width, Height = height, Content = view }; + window.Show(); + return (window, view); + } + + private static Func Pixels(Bitmap frame) + { + var size = frame.PixelSize; + var bytes = new byte[size.Width * size.Height * 4]; + var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned); + try + { + frame.CopyPixels(new PixelRect(size), handle.AddrOfPinnedObject(), bytes.Length, size.Width * 4); + } + finally + { + handle.Free(); + } + + var bgra = frame.Format == PixelFormat.Bgra8888; + return (x, y) => + { + var i = (y * size.Width + x) * 4; + return bgra ? (bytes[i + 2], bytes[i + 1], bytes[i]) : (bytes[i], bytes[i + 1], bytes[i + 2]); + }; + } + + [AvaloniaFact] + public void Render_DrawsTheCanvasWithSkia() + { + var (window, view) = Show(); + view.Canvas.AddPoint(Point2D.Origin, new ItemStyle(new Stroke(Color.Black), Color.Black, 4)); + using var frame = window.CaptureRenderedFrame(); + Assert.NotNull(frame); + var pixel = Pixels(frame); + + Assert.Equal(((byte) 0, (byte) 0, (byte) 0), pixel(100, 50)); + Assert.Equal(((byte) 255, (byte) 255, (byte) 255), pixel(10, 80)); + window.Close(); + } + + [AvaloniaFact] + public void MouseInput_PansAndZooms() + { + var (window, view) = Show(); + window.CaptureRenderedFrame(); + Assert.Equal(new CanvasPoint(100, 50), view.Canvas.Viewport.Origin); + + window.MouseDown(new Point(10, 10), MouseButton.Left); + window.MouseMove(new Point(30, 20)); + window.MouseUp(new Point(30, 20), MouseButton.Left); + Assert.Equal(new CanvasPoint(120, 60), view.Canvas.Viewport.Origin); + + window.MouseWheel(new Point(120, 60), new Vector(0, 1)); + Assert.Equal(25, view.Canvas.Viewport.ScaleX, 9); + Assert.Equal(new CanvasPoint(120, 60), view.Canvas.Viewport.Origin); + + window.MouseDown(new Point(50, 50), MouseButton.Right); + window.MouseMove(new Point(60, 60)); + window.MouseUp(new Point(60, 60), MouseButton.Right); + Assert.Equal(new CanvasPoint(120, 60), view.Canvas.Viewport.Origin); + window.Close(); + } + + [AvaloniaFact] + public void Properties_ForwardToTheCanvas() + { + var view = new Canvas2DView { ShowTrackingLines = true, ShowValueIndicator = true }; + Assert.True(view.Canvas.ShowTrackingLines); + Assert.True(view.Canvas.ShowValueIndicator); + view.ShowTrackingLines = false; + Assert.False(view.Canvas.ShowTrackingLines); + view.ShowValueIndicator = false; + Assert.False(view.Canvas.ShowValueIndicator); + } + + [AvaloniaFact] + public void CanvasChanges_TriggerARedraw() + { + var (window, view) = Show(); + window.CaptureRenderedFrame(); + view.Canvas.AddPolygon(new Polygon(new Point2D(-10, -10), new Point2D(10, -10), new Point2D(10, 10)), + new ItemStyle(new Stroke(Color.Black), Color.FromRgb(255, 0, 0))); + using var frame = window.CaptureRenderedFrame(); + Assert.NotNull(frame); + Assert.Equal(((byte) 255, (byte) 0, (byte) 0), Pixels(frame)(140, 40)); + window.Close(); + } +} diff --git a/tests/SharpMath.Canvas.Avalonia.Tests/DisplayListTests.cs b/tests/SharpMath.Canvas.Avalonia.Tests/DisplayListTests.cs new file mode 100644 index 0000000..6a9ad20 --- /dev/null +++ b/tests/SharpMath.Canvas.Avalonia.Tests/DisplayListTests.cs @@ -0,0 +1,45 @@ +namespace SharpMath.Canvas.Avalonia.Tests; + +public class DisplayListTests +{ + private sealed class CountingContext : IDrawingContext + { + public List Calls { get; } = []; + public void Clear(Color color) => Calls.Add(nameof(Clear)); + public void DrawLine(CanvasPoint startPoint, CanvasPoint endPoint, Stroke stroke) => Calls.Add(nameof(DrawLine)); + public void DrawPolyline(ReadOnlySpan points, Stroke stroke) => Calls.Add(nameof(DrawPolyline)); + public void DrawPolygon(ReadOnlySpan points, Stroke? stroke, Color? fill) => Calls.Add(nameof(DrawPolygon)); + public void DrawRectangle(CanvasRect rect, Stroke? stroke, Color? fill) => Calls.Add(nameof(DrawRectangle)); + public void DrawEllipse(CanvasPoint center, double radiusX, double radiusY, Stroke? stroke, Color? fill) => + Calls.Add(nameof(DrawEllipse)); + public void DrawText(string text, CanvasPoint position, FontSpec font, Color color, TextAlignment alignment) => + Calls.Add(nameof(DrawText)); + public TextSize MeasureText(string text, FontSpec font) => new(text.Length, font.Size); + public void PushClip(CanvasRect rect) => Calls.Add(nameof(PushClip)); + public void PopClip() => Calls.Add(nameof(PopClip)); + } + + [Fact] + public void Replay_ReproducesAllCallsInOrder() + { + var list = new DisplayList(new CountingContext()); + var points = new[] { new CanvasPoint(0, 0), new CanvasPoint(1, 1), new CanvasPoint(2, 0) }; + list.Clear(Color.White); + list.PushClip(new CanvasRect(0, 0, 1, 1)); + list.DrawLine(default, default, new Stroke(Color.Black)); + list.DrawPolyline(points, new Stroke(Color.Black)); + list.DrawPolygon(points, null, Color.Black); + list.DrawRectangle(default, null, Color.Black); + list.DrawEllipse(default, 1, 1, null, Color.Black); + list.DrawText("a", default, new FontSpec("Segoe UI", 10), Color.Black, TextAlignment.Left); + list.PopClip(); + points[0] = new CanvasPoint(99, 99); + + Assert.Equal(9, list.Count); + Assert.Equal(new TextSize(3, 10), list.MeasureText("abc", new FontSpec("Segoe UI", 10))); + var context = new CountingContext(); + list.Replay(context); + Assert.Equal(["Clear", "PushClip", "DrawLine", "DrawPolyline", "DrawPolygon", "DrawRectangle", "DrawEllipse", + "DrawText", "PopClip"], context.Calls); + } +} diff --git a/tests/SharpMath.Canvas.Avalonia.Tests/SharpMath.Canvas.Avalonia.Tests.csproj b/tests/SharpMath.Canvas.Avalonia.Tests/SharpMath.Canvas.Avalonia.Tests.csproj new file mode 100644 index 0000000..8da3a15 --- /dev/null +++ b/tests/SharpMath.Canvas.Avalonia.Tests/SharpMath.Canvas.Avalonia.Tests.csproj @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + diff --git a/tests/SharpMath.Canvas.Avalonia.Tests/TestApplication.cs b/tests/SharpMath.Canvas.Avalonia.Tests/TestApplication.cs new file mode 100644 index 0000000..1c85aa1 --- /dev/null +++ b/tests/SharpMath.Canvas.Avalonia.Tests/TestApplication.cs @@ -0,0 +1,18 @@ +using Avalonia; +using Avalonia.Headless; +using Avalonia.Themes.Fluent; +using SharpMath.Canvas.Avalonia.Tests; + +[assembly: AvaloniaTestApplication(typeof(TestApplication))] + +namespace SharpMath.Canvas.Avalonia.Tests; + +public sealed class TestApplication : Application +{ + public override void Initialize() => Styles.Add(new FluentTheme()); + + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure() + .UseSkia() + .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }); +} From 61bbf1696e8a4e056bcc6575e8d592e78f34c923 Mon Sep 17 00:00:00 2001 From: Dominic Beger Date: Sun, 11 Oct 2026 02:27:22 +0200 Subject: [PATCH 09/14] Add property tests, zero-allocation tests and a coverage gate CsCheck checks algebraic invariants of matrices, geometry and the parser. Allocation tests guard the hot paths; evaluating with a named variable no longer allocates a closure. eng/CheckCoverage.cs enforces line coverage. --- eng/CheckCoverage.cs | 44 ++++ src/SharpMath/Expressions/MathExpression.cs | 13 +- tests/SharpMath.Tests/AllocationTests.cs | 88 ++++++++ .../Properties/ExpressionProperties.cs | 47 +++++ .../Properties/GeometryProperties.cs | 96 +++++++++ .../Properties/MatrixProperties.cs | 193 ++++++++++++++++++ 6 files changed, 475 insertions(+), 6 deletions(-) create mode 100644 eng/CheckCoverage.cs create mode 100644 tests/SharpMath.Tests/AllocationTests.cs create mode 100644 tests/SharpMath.Tests/Properties/ExpressionProperties.cs create mode 100644 tests/SharpMath.Tests/Properties/GeometryProperties.cs create mode 100644 tests/SharpMath.Tests/Properties/MatrixProperties.cs diff --git a/eng/CheckCoverage.cs b/eng/CheckCoverage.cs new file mode 100644 index 0000000..f328cff --- /dev/null +++ b/eng/CheckCoverage.cs @@ -0,0 +1,44 @@ +#:property GenerateDocumentationFile=true +#:property NoWarn=$(NoWarn);CS1591 + +// Fails if the line coverage of an assembly in a Cobertura report is below a threshold. +// Usage: dotnet run eng/CheckCoverage.cs -- = [...] + +using System.Globalization; +using System.Xml.Linq; + +if (args.Length < 2 || !double.TryParse(args[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var threshold)) +{ + Console.Error.WriteLine("Usage: CheckCoverage = [...]"); + return 2; +} + +var failed = false; +foreach (var argument in args[1..]) +{ + var separator = argument.LastIndexOf('='); + if (separator <= 0 || separator == argument.Length - 1) + { + Console.Error.WriteLine($"Expected =, got '{argument}'."); + failed = true; + continue; + } + + var (path, assembly) = (argument[..separator], argument[(separator + 1)..]); + var package = XDocument.Load(path).Descendants("package") + .FirstOrDefault(element => (string?) element.Attribute("name") == assembly); + if (package is null) + { + Console.Error.WriteLine($"{assembly}: not found in {path}"); + failed = true; + continue; + } + + var coverage = 100 * double.Parse((string) package.Attribute("line-rate")!, CultureInfo.InvariantCulture); + var passed = coverage >= threshold; + failed |= !passed; + Console.WriteLine(string.Create(CultureInfo.InvariantCulture, + $"{assembly}: {coverage:F1} % line coverage (threshold {threshold:0.##} %) {(passed ? "OK" : "FAILED")}")); +} + +return failed ? 1 : 0; diff --git a/src/SharpMath/Expressions/MathExpression.cs b/src/SharpMath/Expressions/MathExpression.cs index 5551d1d..16163a4 100644 --- a/src/SharpMath/Expressions/MathExpression.cs +++ b/src/SharpMath/Expressions/MathExpression.cs @@ -82,8 +82,7 @@ public double Evaluate(string name, double value) case 1 when string.Equals(Variables[0], name, StringComparison.OrdinalIgnoreCase): return _root.Evaluate([value]); default: - throw MissingVariables(Variables.Where(variable => - !string.Equals(variable, name, StringComparison.OrdinalIgnoreCase))); + throw MissingVariablesExcept(name); } } @@ -121,10 +120,8 @@ public double Evaluate(IReadOnlyDictionary variables) public Func ToFunc(string variable) { ArgumentNullException.ThrowIfNull(variable); - var others = Variables.Where(name => !string.Equals(name, variable, StringComparison.OrdinalIgnoreCase)) - .ToList(); - if (others.Count > 0) - throw MissingVariables(others); + if (Variables.Any(name => !string.Equals(name, variable, StringComparison.OrdinalIgnoreCase))) + throw MissingVariablesExcept(variable); var root = _root; if (Variables.Count == 0) @@ -150,6 +147,10 @@ private static bool TryFind(IReadOnlyDictionary variables, strin return false; } + // A separate method keeps the closure over the name off the allocation-free success path. + private ArgumentException MissingVariablesExcept(string name) => + MissingVariables(Variables.Where(variable => !string.Equals(variable, name, StringComparison.OrdinalIgnoreCase))); + private static ArgumentException MissingVariables(IEnumerable names) => new($"No value was given for the variable(s): {string.Join(", ", names)}."); } diff --git a/tests/SharpMath.Tests/AllocationTests.cs b/tests/SharpMath.Tests/AllocationTests.cs new file mode 100644 index 0000000..3c72f30 --- /dev/null +++ b/tests/SharpMath.Tests/AllocationTests.cs @@ -0,0 +1,88 @@ +using SharpMath.Expressions; +using SharpMath.Geometry; + +namespace SharpMath.Tests; + +/// +/// Guards the hot paths against regressions that reintroduce heap allocations (e.g. boxing, iterators or LINQ). +/// +public class AllocationTests +{ + private static double _sink; + + private static long AllocatedBytes(Action action) + { + // Warm up so that JIT compilation and static initialization are not counted. + for (var i = 0; i < 3; i++) + action(); + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 100; i++) + action(); + return GC.GetAllocatedBytesForCurrentThread() - before; + } + + [Fact] + public void VectorOperations_DoNotAllocate() + { + var a = new Vector3(1, 2, 3); + var b = new Vector3(-2, 0.5, 4); + Assert.Equal(0, AllocatedBytes(() => + { + var c = a + b * 2 - a.Cross(b) / 3; + _sink = c.Dot(a) + c.Normalize().Length + (a == b ? 1 : 0) + a.GetHashCode() + (a.IsZero ? 1 : 0) + + a.Angle(b) + (a.IsParallelTo(b) ? 1 : 0) + (a.ApproximatelyEquals(b) ? 1 : 0); + })); + var p = new Vector4(1, 2, 3, 4); + Assert.Equal(0, AllocatedBytes(() => _sink = (p * 2 + p).Normalize().Length + (p.IsParallelTo(p) ? 1 : 0))); + } + + [Fact] + public void MatrixOperations_DoNotAllocate() + { + var m = Matrix4x4.Rotation(0.3, 0.5, 0.7) * Matrix4x4.Translation(1, 2, 3) * Matrix4x4.Scaling(2); + var v = new Vector3(1, 2, 3); + Assert.Equal(0, AllocatedBytes(() => + { + var product = m * m.Inverse() * m.Transpose(); + _sink = product.Determinant + (m * v).X + (m == product ? 1 : 0) + m.GetHashCode() + + (m.IsOrthogonal ? 1 : 0) + (m.IsSingular ? 1 : 0) + m.Adjugate().Trace + + (m.ApproximatelyEquals(product) ? 1 : 0) + (m.IsDiagonal ? 1 : 0) + (m.IsSymmetric ? 1 : 0); + })); + + var small = new Matrix3x3(2, 5, 2, 3, -3, 1, 1, 4, -4); + Assert.Equal(0, AllocatedBytes(() => _sink = small.Inverse().Determinant + (small * Vector2.One).X + + small.Cofactors().Trace + (small.IsTriangular ? 1 : 0))); + var tiny = new Matrix2x2(8, 3, 4, 2); + Assert.Equal(0, AllocatedBytes(() => _sink = tiny.Inverse().Determinant + (tiny * tiny).Trace)); + } + + [Fact] + public void GeometryQueries_DoNotAllocate() + { + var polygon = new Polygon(new Point2D(0, 0), new Point2D(4, 0), new Point2D(4, 4), new Point2D(2, 1), + new Point2D(0, 4)); + var line = Line2D.FromPoints(new Point2D(0, 1), new Point2D(3, 2)); + var other = Line2D.FromSlopeIntercept(-1, 5); + var segment = new LineSegment2D(new Point2D(0, 0), new Point2D(5, 5)); + var line3 = Line3D.FromPointAndDirection(new Point3D(1, 2, 3), new Vector3(1, 1, 0)); + var skew = Line3D.FromPointAndDirection(new Point3D(0, 0, 5), new Vector3(0, 1, 1)); + Assert.Equal(0, AllocatedBytes(() => + _sink = (polygon.Contains(new Point2D(1, 1)) ? 1 : 0) + polygon.Area + polygon.Perimeter + + polygon.Centroid.X + polygon.BoundingBox.Width + line.Intersect(other).Point!.Value.X + + segment.Intersect(new LineSegment2D(new Point2D(0, 5), new Point2D(5, 0))).Point!.Value.Y + + line3.DistanceTo(skew) + (line3.Intersect(skew).Kind == IntersectionKind.Skew ? 1 : 0))); + } + + [Fact] + public void EvaluatingParsedExpressions_DoesNotAllocate() + { + var expression = MathExpression.Parse("2x^2 - 3x + sin(x) * max(x, 1, 2) + 5!"); + var function = expression.ToFunc("x"); + var x = 0.0; + Assert.Equal(0, AllocatedBytes(() => _sink = function(x += 0.1))); + Assert.Equal(0, AllocatedBytes(() => _sink = expression.Evaluate("x", x += 0.1))); + var constant = MathExpression.Parse("2 + 3 * 4"); + Assert.Equal(0, AllocatedBytes(() => _sink = constant.Evaluate())); + } +} diff --git a/tests/SharpMath.Tests/Properties/ExpressionProperties.cs b/tests/SharpMath.Tests/Properties/ExpressionProperties.cs new file mode 100644 index 0000000..d8ebb44 --- /dev/null +++ b/tests/SharpMath.Tests/Properties/ExpressionProperties.cs @@ -0,0 +1,47 @@ +using System.Globalization; +using CsCheck; +using SharpMath.Expressions; + +namespace SharpMath.Tests.Properties; + +public class ExpressionProperties +{ + private sealed record Expr(string Text, Func Evaluate); + + private static readonly Gen Leaf = Gen.OneOf( + Gen.Int[0, 50].Select(n => new Expr(n.ToString(CultureInfo.InvariantCulture), _ => n)), + Gen.Const(new Expr("x", x => x)), + Gen.Const(new Expr("pi", _ => Math.PI))); + + private static Gen Expressions(int depth) => + depth == 0 + ? Leaf + : Gen.Frequency( + (2, Leaf), + (6, Gen.Select(Expressions(depth - 1), Expressions(depth - 1), Gen.Int[0, 3], Combine)), + (1, Expressions(depth - 1).Select(e => new Expr($"-({e.Text})", x => -e.Evaluate(x)))), + (1, Expressions(depth - 1).Select(e => new Expr($"sin({e.Text})", x => Math.Sin(e.Evaluate(x))))), + (1, Expressions(depth - 1).Select(e => new Expr($"({e.Text})^2", x => Math.Pow(e.Evaluate(x), 2))))); + + private static Expr Combine(Expr left, Expr right, int op) => op switch + { + 0 => new Expr($"({left.Text}) + ({right.Text})", x => left.Evaluate(x) + right.Evaluate(x)), + 1 => new Expr($"({left.Text}) - ({right.Text})", x => left.Evaluate(x) - right.Evaluate(x)), + 2 => new Expr($"({left.Text})({right.Text})", x => left.Evaluate(x) * right.Evaluate(x)), + _ => new Expr($"({left.Text}) / ({right.Text})", x => left.Evaluate(x) / right.Evaluate(x)) + }; + + [Fact] + public void ParsedExpressions_MatchTheEquivalentCSharp() + { + Gen.Select(Expressions(4), Gen.Double[-10, 10]).Sample((expression, x) => + { + var expected = expression.Evaluate(x); + var actual = MathExpression.Parse(expression.Text).ToFunc("x")(x); + if (double.IsNaN(expected)) + Assert.True(double.IsNaN(actual)); + else + Assert.True(Tolerance.AreEqual(expected, actual, 1e-9, 1e-9), $"{expression.Text} at x = {x}: {expected} vs {actual}"); + }); + } +} diff --git a/tests/SharpMath.Tests/Properties/GeometryProperties.cs b/tests/SharpMath.Tests/Properties/GeometryProperties.cs new file mode 100644 index 0000000..aa6b481 --- /dev/null +++ b/tests/SharpMath.Tests/Properties/GeometryProperties.cs @@ -0,0 +1,96 @@ +using CsCheck; +using SharpMath.Geometry; + +namespace SharpMath.Tests.Properties; + +public class GeometryProperties +{ + private static readonly Gen Coordinate = Gen.Double[-100, 100]; + private static readonly Gen Points = Gen.Select(Coordinate, Coordinate, (x, y) => new Point2D(x, y)); + private static readonly Gen Vectors3 = Gen.Select(Coordinate, Coordinate, Coordinate, (x, y, z) => new Vector3(x, y, z)); + + [Fact] + public void VectorProducts() + { + Gen.Select(Vectors3, Vectors3).Sample((a, b) => + { + var cross = a.Cross(b); + var scale = a.Length * b.Length; + Assert.True(Tolerance.IsNegligible(cross.Dot(a), scale * a.Length)); + Assert.True(Tolerance.IsNegligible(cross.Dot(b), scale * b.Length)); + Assert.Equal(a.Dot(b), b.Dot(a)); + Assert.True(Tolerance.AreEqual(cross.LengthSquared + a.Dot(b) * a.Dot(b), scale * scale, 1e-6, 1e-9)); + if (a.LengthSquared > 0) + Assert.True(a.Normalize().IsNormalized); + if (a.LengthSquared > 0 && b.LengthSquared > 0) + Assert.InRange(a.Angle(b), 0, Math.PI); + }); + } + + [Fact] + public void DirectionPredicates_DoNotDependOnTheScale() + { + Gen.Select(Vectors3, Vectors3, Gen.Int[-150, 150]).Sample((a, b, exponent) => + { + var scale = Math.Pow(10, exponent); + Assert.Equal(a.IsParallelTo(b), (a * scale).IsParallelTo(b * scale)); + Assert.Equal(a.IsOrthogonalTo(b), (a * scale).IsOrthogonalTo(b * scale)); + if (a.LengthSquared > 0) + Assert.True((a * scale).IsParallelTo(a * -scale * 3)); + }); + } + + [Fact] + public void PolygonArea_IsInvariantUnderTranslationAndVertexRotation() + { + Gen.Select(Points.Array[3, 12], Coordinate, Coordinate, Gen.Int[0, 11]).Sample((points, dx, dy, shift) => + { + var polygon = new Polygon(points); + var translated = new Polygon(points.Select(p => p + new Vector2(dx, dy))); + var rotated = new Polygon(points.Skip(shift % points.Length).Concat(points.Take(shift % points.Length))); + Assert.True(Tolerance.AreEqual(polygon.SignedArea, translated.SignedArea, 1e-6, 1e-9)); + Assert.True(Tolerance.AreEqual(polygon.SignedArea, rotated.SignedArea, 1e-6, 1e-9)); + Assert.True(Tolerance.AreEqual(polygon.Perimeter, rotated.Perimeter, 1e-6, 1e-9)); + var box = polygon.BoundingBox; + Assert.All(points, p => Assert.True(box.Contains(p))); + }); + } + + [Fact] + public void LineIntersections_LieOnBothLines() + { + Gen.Select(Points, Points, Points, Points).Sample((a, b, c, d) => + { + if (a.Distance(b) < 1e-3 || c.Distance(d) < 1e-3) + return; + var first = Line2D.FromPoints(a, b); + var second = Line2D.FromPoints(c, d); + Assert.True(first.Contains(a) && first.Contains(b)); + var intersection = first.Intersect(second); + if (intersection.Kind != IntersectionKind.Point) + return; + var point = intersection.Point!.Value; + var scale = Math.Max(1, Math.Max(Math.Abs(point.X), Math.Abs(point.Y))); + Assert.True(first.DistanceTo(point) < 1e-6 * scale); + Assert.True(second.DistanceTo(point) < 1e-6 * scale); + }); + } + + [Fact] + public void ClosestPointsOnSkewLines_AreConnectedByACommonPerpendicular() + { + Gen.Select(Vectors3, Vectors3, Vectors3, Vectors3).Sample((p, d, q, e) => + { + if (d.Length < 1e-3 || e.Length < 1e-3 || d.Normalize().Cross(e.Normalize()).Length < 1e-3) + return; + var first = Line3D.FromPointAndDirection(p.ToPoint(), d); + var second = Line3D.FromPointAndDirection(q.ToPoint(), e); + var (onFirst, onSecond) = first.ClosestPoints(second); + var connection = onSecond - onFirst; + Assert.True(Tolerance.IsNegligible(connection.Dot(first.Direction), 1e3)); + Assert.True(Tolerance.IsNegligible(connection.Dot(second.Direction), 1e3)); + Assert.True(first.Contains(onFirst)); + Assert.True(second.Contains(onSecond)); + }); + } +} diff --git a/tests/SharpMath.Tests/Properties/MatrixProperties.cs b/tests/SharpMath.Tests/Properties/MatrixProperties.cs new file mode 100644 index 0000000..d9cadfb --- /dev/null +++ b/tests/SharpMath.Tests/Properties/MatrixProperties.cs @@ -0,0 +1,193 @@ +using CsCheck; +using SharpMath.Geometry; + +namespace SharpMath.Tests.Properties; + +public class MatrixProperties +{ + private static readonly Gen Entry = Gen.Double[-10, 10]; + + private static readonly Gen Matrices2 = Entry.Array[4].Select(v => new Matrix2x2(v[0], v[1], v[2], v[3])); + + private static readonly Gen Matrices3 = + Entry.Array[9].Select(v => new Matrix3x3(v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8])); + + private static readonly Gen Matrices4 = Entry.Array[16].Select(v => new Matrix4x4(v[0], v[1], v[2], v[3], + v[4], v[5], v[6], v[7], v[8], v[9], v[10], v[11], v[12], v[13], v[14], v[15])); + + // Inverses of nearly singular matrices are legitimately inaccurate, so only well-conditioned ones are checked. The + // Hadamard ratio |det| / Π‖rowᵢ‖ lies in [0, 1] and does not depend on the scale of the rows. Rows of wildly + // different magnitude can still be numerically singular relative to the largest entry, so TryInvert decides. + private static bool WellConditioned(IMatrix matrix, double determinant) + { + var product = 1.0; + for (var row = 0; row < matrix.RowCount; row++) + { + var normSquared = 0.0; + for (var column = 0; column < matrix.ColumnCount; column++) + normSquared += matrix[row, column] * matrix[row, column]; + product *= Math.Sqrt(normSquared); + } + + return Math.Abs(determinant) > 1e-3 * product; + } + + // The rounding error of M · M⁻¹ is proportional to ‖M‖ · ‖M⁻¹‖ (here with max norms), not absolute. + private static void AssertNearIdentity(IMatrix product, IMatrix matrix, IMatrix inverse) + { + var bound = 1e-12 * product.RowCount * MaxAbs(matrix) * MaxAbs(inverse); + for (var row = 0; row < product.RowCount; row++) + for (var column = 0; column < product.ColumnCount; column++) + Assert.True(Math.Abs(product[row, column] - (row == column ? 1 : 0)) <= bound, + $"Entry ({row}, {column}) of {product} exceeds {bound}."); + } + + private static double MaxAbs(IMatrix matrix) + { + var max = 0.0; + for (var row = 0; row < matrix.RowCount; row++) + for (var column = 0; column < matrix.ColumnCount; column++) + max = Math.Max(max, Math.Abs(matrix[row, column])); + return max; + } + + [Fact] + public void Inverse_TimesMatrix_IsIdentity() + { + Matrices2.Sample(m => + { + if (WellConditioned(m, m.Determinant) && m.TryInvert(out var inverse)) + AssertNearIdentity(m * inverse, m, inverse); + }); + Matrices3.Sample(m => + { + if (WellConditioned(m, m.Determinant) && m.TryInvert(out var inverse)) + AssertNearIdentity(m * inverse, m, inverse); + }); + Matrices4.Sample(m => + { + if (WellConditioned(m, m.Determinant) && m.TryInvert(out var inverse)) + AssertNearIdentity(m * inverse, m, inverse); + }); + } + + [Fact] + public void AffineTransformations_AreInvertibleAtAnyScale() + { + Gen.Select(Gen.Double[-1e6, 1e6].Array[3], Gen.Double[-Math.PI, Math.PI].Array[3], Gen.Double[-3, 3].Array[3]) + .Sample((translation, angles, exponents) => + { + var transformation = Matrix4x4.Translation(translation[0], translation[1], translation[2]) * + Matrix4x4.Rotation(angles[0], angles[1], angles[2]) * + Matrix4x4.Scaling(Math.Pow(10, exponents[0]), Math.Pow(10, exponents[1]), + Math.Pow(10, exponents[2])); + Assert.False(transformation.IsSingular); + var point = new Vector3(1, 2, 3); + var roundTrip = transformation.Inverse() * (transformation * point); + Assert.True(roundTrip.ApproximatelyEquals(point, 1e-6, 1e-6), $"{roundTrip} for {transformation}"); + }); + } + + [Fact] + public void Determinant_IsMultiplicative() + { + Gen.Select(Matrices3, Matrices3).Sample((a, b) => + { + var expected = a.Determinant * b.Determinant; + Assert.True(Tolerance.AreEqual((a * b).Determinant, expected, 1e-6, 1e-9)); + }); + Gen.Select(Matrices4, Matrices4).Sample((a, b) => + { + var expected = a.Determinant * b.Determinant; + Assert.True(Tolerance.AreEqual((a * b).Determinant, expected, 1e-4, 1e-8)); + }); + } + + [Fact] + public void Adjugate_TimesMatrix_IsDeterminantTimesIdentity() + { + Matrices3.Sample(m => + Assert.True((m * m.Adjugate()).ApproximatelyEquals(Matrix3x3.Identity * m.Determinant, 1e-9, 1e-9))); + Matrices4.Sample(m => + Assert.True((m.Adjugate() * m).ApproximatelyEquals(Matrix4x4.Identity * m.Determinant, 1e-7, 1e-9))); + } + + [Fact] + public void Determinants_MatchTheLuDecomposition() + { + Matrices4.Sample(m => + Assert.True(Tolerance.AreEqual(m.Determinant, MatrixMxN.FromMatrix(m).Determinant(), 1e-8, 1e-9))); + } + + [Fact] + public void Transpose_ReversesProducts() + { + Gen.Select(Matrices4, Matrices4).Sample((a, b) => + Assert.True((a * b).Transpose().ApproximatelyEquals(b.Transpose() * a.Transpose()))); + } + + [Fact] + public void Rotations_PreserveLengthsAndAreOrthogonal() + { + Gen.Select(Gen.Double[-10, 10], Gen.Double[-10, 10], Gen.Double[-10, 10], Entry, Entry, Entry) + .Sample((ax, ay, az, x, y, z) => + { + var rotation = Matrix4x4.Rotation(ax, ay, az); + var vector = new Vector3(x, y, z); + Assert.True(Tolerance.AreEqual((rotation * vector).Length, vector.Length, 1e-9, 1e-9)); + Assert.True(rotation.IsOrthogonal); + Assert.True(Tolerance.AreEqual(rotation.Determinant, 1)); + var planar = Matrix2x2.Rotation(ax) * new Vector2(x, y); + Assert.True(Tolerance.AreEqual(planar.Length, new Vector2(x, y).Length, 1e-9, 1e-9)); + }); + } + + [Fact] + public void LookAtAndWorld_UseOrthonormalBases() + { + Gen.Select(Entry.Array[3], Entry.Array[3], Entry.Array[3]).Sample((e, t, u) => + { + var eye = new Vector3(e[0], e[1], e[2]); + var target = new Vector3(t[0], t[1], t[2]); + var up = new Vector3(u[0], u[1], u[2]); + var direction = target - eye; + if (direction.Length < 1e-3 || up.Length < 1e-3 || up.Cross(direction).Length < 1e-3 * up.Length * direction.Length) + return; + + var view = Matrix4x4.LookAt(eye, target, up); + Assert.True(RotationPart(view).IsOrthogonal); + Assert.True((view * eye).ApproximatelyEquals(Vector3.Zero, 1e-9, 1e-9)); + var forward = view * target; + Assert.True(forward.Z < 0); + + var world = Matrix4x4.World(eye, direction, up); + Assert.True(RotationPart(world).IsOrthogonal); + Assert.True((world * Vector3.Zero).ApproximatelyEquals(eye)); + Assert.True((view * world).ApproximatelyEquals(Matrix4x4.Identity, 1e-9, 1e-9)); + }); + } + + [Fact] + public void MatrixMxN_SolveHasSmallResiduals() + { + Gen.Int[1, 8].SelectMany(n => Gen.Double[-1, 1].Array[n * n].Select(Gen.Double[-10, 10].Array[n], (a, b) => (n, a, b))) + .Sample(sample => + { + var (n, values, rightHandSide) = sample; + + // Diagonal dominance guarantees a regular, well-conditioned matrix. + for (var i = 0; i < n; i++) + values[i * n + i] += n + 1; + var matrix = new MatrixMxN(n, n, values); + var solution = matrix.Solve(rightHandSide); + var residual = matrix * new MatrixMxN(n, 1, solution) - new MatrixMxN(n, 1, rightHandSide); + for (var i = 0; i < n; i++) + Assert.True(Math.Abs(residual[i, 0]) < 1e-9); + Assert.True((matrix * matrix.Inverse()).ApproximatelyEquals(MatrixMxN.Identity(n), 1e-9, 1e-9)); + Assert.Equal(n, matrix.Rank()); + }); + } + + private static Matrix3x3 RotationPart(Matrix4x4 m) => + new(m.M11, m.M12, m.M13, m.M21, m.M22, m.M23, m.M31, m.M32, m.M33); +} From 2d0ca11dbc084490d83c04e43f0fe6b5b7cb58bf Mon Sep 17 00:00:00 2001 From: Dominic Beger Date: Sun, 11 Oct 2026 02:29:28 +0200 Subject: [PATCH 10/14] Port the demo applications to .NET 10 and add Avalonia and export samples The perspective demo now uses the column-vector convention, LookAt and the right-handed projection. The export sample renders the README images. --- SharpMath.3DTest/App.config | 7 - SharpMath.3DTest/PerspectiveForm.Designer.cs | 211 ------------------ SharpMath.3DTest/PerspectiveForm.cs | 158 ------------- SharpMath.3DTest/PerspectiveForm.resx | 123 ---------- SharpMath.3DTest/PerspectivePanel.cs | 18 -- SharpMath.3DTest/Program.cs | 22 -- SharpMath.3DTest/Properties/AssemblyInfo.cs | 42 ---- .../Properties/Resources.Designer.cs | 63 ------ SharpMath.3DTest/Properties/Resources.resx | 117 ---------- .../Properties/Settings.Designer.cs | 26 --- SharpMath.3DTest/Properties/Settings.settings | 8 - SharpMath.3DTest/SharpMath.3DTest.csproj | 100 --------- SharpMath.CanvasTest/App.config | 7 - SharpMath.CanvasTest/Canvas2DTest.Designer.cs | 64 ------ SharpMath.CanvasTest/Canvas2DTest.cs | 25 --- SharpMath.CanvasTest/Canvas2DTest.resx | 127 ----------- SharpMath.CanvasTest/Program.cs | 22 -- .../Properties/AssemblyInfo.cs | 38 ---- .../Properties/Resources.Designer.cs | 63 ------ .../Properties/Resources.resx | 117 ---------- .../Properties/Settings.Designer.cs | 26 --- .../Properties/Settings.settings | 8 - .../SharpMath.Canvas2DTest.csproj | 125 ----------- SharpMath.PolygonTest/App.config | 7 - .../ContainsTestForm.Designer.cs | 49 ---- SharpMath.PolygonTest/ContainsTestForm.cs | 51 ----- SharpMath.PolygonTest/ContainsTestForm.resx | 120 ---------- SharpMath.PolygonTest/Program.cs | 22 -- .../Properties/AssemblyInfo.cs | 42 ---- .../Properties/Resources.Designer.cs | 63 ------ .../Properties/Resources.resx | 117 ---------- .../Properties/Settings.Designer.cs | 26 --- .../Properties/Settings.settings | 8 - .../SharpMath.PolygonTest.csproj | 98 -------- SharpMath.slnx | 7 + doc/images/canvas-functions.png | Bin 0 -> 36960 bytes doc/images/canvas-functions.svg | 90 ++++++++ doc/images/canvas-geometry.png | Bin 0 -> 10884 bytes doc/images/canvas-geometry.svg | 67 ++++++ samples/Directory.Build.props | 9 + .../MainWindow.cs | 72 ++++++ .../Program.cs | 25 +++ .../SharpMath.Samples.Canvas.Avalonia.csproj | 16 ++ .../CanvasForm.cs | 55 +++++ .../Program.cs | 11 + .../SharpMath.Samples.Canvas.WinForms.csproj | 14 ++ samples/SharpMath.Samples.Export/Program.cs | 38 ++++ .../SharpMath.Samples.Export.csproj | 11 + .../PerspectiveForm.cs | 110 +++++++++ .../Program.cs | 11 + ...rpMath.Samples.Perspective.WinForms.csproj | 14 ++ .../ContainsForm.cs | 42 ++++ .../Program.cs | 11 + .../SharpMath.Samples.Polygon.WinForms.csproj | 14 ++ 54 files changed, 617 insertions(+), 2120 deletions(-) delete mode 100644 SharpMath.3DTest/App.config delete mode 100644 SharpMath.3DTest/PerspectiveForm.Designer.cs delete mode 100644 SharpMath.3DTest/PerspectiveForm.cs delete mode 100644 SharpMath.3DTest/PerspectiveForm.resx delete mode 100644 SharpMath.3DTest/PerspectivePanel.cs delete mode 100644 SharpMath.3DTest/Program.cs delete mode 100644 SharpMath.3DTest/Properties/AssemblyInfo.cs delete mode 100644 SharpMath.3DTest/Properties/Resources.Designer.cs delete mode 100644 SharpMath.3DTest/Properties/Resources.resx delete mode 100644 SharpMath.3DTest/Properties/Settings.Designer.cs delete mode 100644 SharpMath.3DTest/Properties/Settings.settings delete mode 100644 SharpMath.3DTest/SharpMath.3DTest.csproj delete mode 100644 SharpMath.CanvasTest/App.config delete mode 100644 SharpMath.CanvasTest/Canvas2DTest.Designer.cs delete mode 100644 SharpMath.CanvasTest/Canvas2DTest.cs delete mode 100644 SharpMath.CanvasTest/Canvas2DTest.resx delete mode 100644 SharpMath.CanvasTest/Program.cs delete mode 100644 SharpMath.CanvasTest/Properties/AssemblyInfo.cs delete mode 100644 SharpMath.CanvasTest/Properties/Resources.Designer.cs delete mode 100644 SharpMath.CanvasTest/Properties/Resources.resx delete mode 100644 SharpMath.CanvasTest/Properties/Settings.Designer.cs delete mode 100644 SharpMath.CanvasTest/Properties/Settings.settings delete mode 100644 SharpMath.CanvasTest/SharpMath.Canvas2DTest.csproj delete mode 100644 SharpMath.PolygonTest/App.config delete mode 100644 SharpMath.PolygonTest/ContainsTestForm.Designer.cs delete mode 100644 SharpMath.PolygonTest/ContainsTestForm.cs delete mode 100644 SharpMath.PolygonTest/ContainsTestForm.resx delete mode 100644 SharpMath.PolygonTest/Program.cs delete mode 100644 SharpMath.PolygonTest/Properties/AssemblyInfo.cs delete mode 100644 SharpMath.PolygonTest/Properties/Resources.Designer.cs delete mode 100644 SharpMath.PolygonTest/Properties/Resources.resx delete mode 100644 SharpMath.PolygonTest/Properties/Settings.Designer.cs delete mode 100644 SharpMath.PolygonTest/Properties/Settings.settings delete mode 100644 SharpMath.PolygonTest/SharpMath.PolygonTest.csproj create mode 100644 doc/images/canvas-functions.png create mode 100644 doc/images/canvas-functions.svg create mode 100644 doc/images/canvas-geometry.png create mode 100644 doc/images/canvas-geometry.svg create mode 100644 samples/Directory.Build.props create mode 100644 samples/SharpMath.Samples.Canvas.Avalonia/MainWindow.cs create mode 100644 samples/SharpMath.Samples.Canvas.Avalonia/Program.cs create mode 100644 samples/SharpMath.Samples.Canvas.Avalonia/SharpMath.Samples.Canvas.Avalonia.csproj create mode 100644 samples/SharpMath.Samples.Canvas.WinForms/CanvasForm.cs create mode 100644 samples/SharpMath.Samples.Canvas.WinForms/Program.cs create mode 100644 samples/SharpMath.Samples.Canvas.WinForms/SharpMath.Samples.Canvas.WinForms.csproj create mode 100644 samples/SharpMath.Samples.Export/Program.cs create mode 100644 samples/SharpMath.Samples.Export/SharpMath.Samples.Export.csproj create mode 100644 samples/SharpMath.Samples.Perspective.WinForms/PerspectiveForm.cs create mode 100644 samples/SharpMath.Samples.Perspective.WinForms/Program.cs create mode 100644 samples/SharpMath.Samples.Perspective.WinForms/SharpMath.Samples.Perspective.WinForms.csproj create mode 100644 samples/SharpMath.Samples.Polygon.WinForms/ContainsForm.cs create mode 100644 samples/SharpMath.Samples.Polygon.WinForms/Program.cs create mode 100644 samples/SharpMath.Samples.Polygon.WinForms/SharpMath.Samples.Polygon.WinForms.csproj diff --git a/SharpMath.3DTest/App.config b/SharpMath.3DTest/App.config deleted file mode 100644 index 0ac2de8..0000000 --- a/SharpMath.3DTest/App.config +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/SharpMath.3DTest/PerspectiveForm.Designer.cs b/SharpMath.3DTest/PerspectiveForm.Designer.cs deleted file mode 100644 index abd35c5..0000000 --- a/SharpMath.3DTest/PerspectiveForm.Designer.cs +++ /dev/null @@ -1,211 +0,0 @@ -namespace SharpMath._3DTest -{ - partial class PerspectiveForm - { - /// - /// Required designer variable. - /// - private System.ComponentModel.IContainer components = null; - - /// - /// Clean up any resources being used. - /// - /// true if managed resources should be disposed; otherwise, false. - protected override void Dispose(bool disposing) - { - if (disposing && (components != null)) - { - components.Dispose(); - } - base.Dispose(disposing); - } - - #region Windows Form Designer generated code - - /// - /// Required method for Designer support - do not modify - /// the contents of this method with the code editor. - /// - private void InitializeComponent() - { - this.components = new System.ComponentModel.Container(); - this.trackBar1 = new System.Windows.Forms.TrackBar(); - this.trackBar2 = new System.Windows.Forms.TrackBar(); - this.trackBar3 = new System.Windows.Forms.TrackBar(); - this.rotationTimer = new System.Windows.Forms.Timer(this.components); - this.autoRotateCheckBox = new System.Windows.Forms.CheckBox(); - this.label1 = new System.Windows.Forms.Label(); - this.label2 = new System.Windows.Forms.Label(); - this.label3 = new System.Windows.Forms.Label(); - this.label4 = new System.Windows.Forms.Label(); - this.scalationTrackBar = new System.Windows.Forms.TrackBar(); - this.perspectivePanel = new SharpMath._3DTest.PerspectivePanel(); - ((System.ComponentModel.ISupportInitialize)(this.trackBar1)).BeginInit(); - ((System.ComponentModel.ISupportInitialize)(this.trackBar2)).BeginInit(); - ((System.ComponentModel.ISupportInitialize)(this.trackBar3)).BeginInit(); - ((System.ComponentModel.ISupportInitialize)(this.scalationTrackBar)).BeginInit(); - this.SuspendLayout(); - // - // trackBar1 - // - this.trackBar1.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); - this.trackBar1.AutoSize = false; - this.trackBar1.Location = new System.Drawing.Point(118, 672); - this.trackBar1.Maximum = 360; - this.trackBar1.Name = "trackBar1"; - this.trackBar1.Size = new System.Drawing.Size(241, 37); - this.trackBar1.TabIndex = 1; - this.trackBar1.TickStyle = System.Windows.Forms.TickStyle.None; - this.trackBar1.Scroll += new System.EventHandler(this.trackBar1_Scroll); - // - // trackBar2 - // - this.trackBar2.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); - this.trackBar2.AutoSize = false; - this.trackBar2.Location = new System.Drawing.Point(118, 707); - this.trackBar2.Maximum = 360; - this.trackBar2.Name = "trackBar2"; - this.trackBar2.Size = new System.Drawing.Size(241, 42); - this.trackBar2.TabIndex = 2; - this.trackBar2.TickStyle = System.Windows.Forms.TickStyle.None; - this.trackBar2.Scroll += new System.EventHandler(this.trackBar2_Scroll); - // - // trackBar3 - // - this.trackBar3.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); - this.trackBar3.AutoSize = false; - this.trackBar3.Location = new System.Drawing.Point(118, 746); - this.trackBar3.Maximum = 360; - this.trackBar3.Name = "trackBar3"; - this.trackBar3.Size = new System.Drawing.Size(241, 37); - this.trackBar3.TabIndex = 3; - this.trackBar3.TickStyle = System.Windows.Forms.TickStyle.None; - this.trackBar3.Scroll += new System.EventHandler(this.trackBar3_Scroll); - // - // rotationTimer - // - this.rotationTimer.Interval = 40; - this.rotationTimer.Tick += new System.EventHandler(this.rotationTimer_Tick); - // - // autoRotateCheckBox - // - this.autoRotateCheckBox.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); - this.autoRotateCheckBox.Appearance = System.Windows.Forms.Appearance.Button; - this.autoRotateCheckBox.AutoSize = true; - this.autoRotateCheckBox.Location = new System.Drawing.Point(365, 673); - this.autoRotateCheckBox.Name = "autoRotateCheckBox"; - this.autoRotateCheckBox.Size = new System.Drawing.Size(118, 30); - this.autoRotateCheckBox.TabIndex = 5; - this.autoRotateCheckBox.Text = "Auto Rotation"; - this.autoRotateCheckBox.UseVisualStyleBackColor = true; - this.autoRotateCheckBox.CheckedChanged += new System.EventHandler(this.autoRotateCheckBox_CheckedChanged); - // - // label1 - // - this.label1.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); - this.label1.AutoSize = true; - this.label1.Location = new System.Drawing.Point(27, 678); - this.label1.Name = "label1"; - this.label1.Size = new System.Drawing.Size(85, 20); - this.label1.TabIndex = 6; - this.label1.Text = "RotationX:"; - // - // label2 - // - this.label2.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); - this.label2.AutoSize = true; - this.label2.Location = new System.Drawing.Point(27, 712); - this.label2.Name = "label2"; - this.label2.Size = new System.Drawing.Size(85, 20); - this.label2.TabIndex = 7; - this.label2.Text = "RotationY:"; - // - // label3 - // - this.label3.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); - this.label3.AutoSize = true; - this.label3.Location = new System.Drawing.Point(27, 750); - this.label3.Name = "label3"; - this.label3.Size = new System.Drawing.Size(84, 20); - this.label3.TabIndex = 8; - this.label3.Text = "RotationZ:"; - // - // label4 - // - this.label4.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); - this.label4.AutoSize = true; - this.label4.Location = new System.Drawing.Point(541, 676); - this.label4.Name = "label4"; - this.label4.Size = new System.Drawing.Size(79, 20); - this.label4.TabIndex = 10; - this.label4.Text = "Scalation:"; - // - // scalationTrackBar - // - this.scalationTrackBar.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); - this.scalationTrackBar.AutoSize = false; - this.scalationTrackBar.Location = new System.Drawing.Point(632, 672); - this.scalationTrackBar.Maximum = 360; - this.scalationTrackBar.Name = "scalationTrackBar"; - this.scalationTrackBar.Size = new System.Drawing.Size(241, 37); - this.scalationTrackBar.TabIndex = 9; - this.scalationTrackBar.TickStyle = System.Windows.Forms.TickStyle.None; - this.scalationTrackBar.Value = 360; - this.scalationTrackBar.Scroll += new System.EventHandler(this.scalationTrackBar_Scroll); - // - // perspectivePanel - // - this.perspectivePanel.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) - | System.Windows.Forms.AnchorStyles.Left) - | System.Windows.Forms.AnchorStyles.Right))); - this.perspectivePanel.BackColor = System.Drawing.Color.CornflowerBlue; - this.perspectivePanel.Location = new System.Drawing.Point(0, 0); - this.perspectivePanel.Name = "perspectivePanel"; - this.perspectivePanel.Size = new System.Drawing.Size(1067, 659); - this.perspectivePanel.TabIndex = 0; - this.perspectivePanel.Paint += new System.Windows.Forms.PaintEventHandler(this.perspectivePanel_Paint); - // - // PerspectiveForm - // - this.AutoScaleDimensions = new System.Drawing.SizeF(9F, 20F); - this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.BackColor = System.Drawing.SystemColors.Window; - this.ClientSize = new System.Drawing.Size(1067, 790); - this.Controls.Add(this.label4); - this.Controls.Add(this.scalationTrackBar); - this.Controls.Add(this.label3); - this.Controls.Add(this.label2); - this.Controls.Add(this.label1); - this.Controls.Add(this.autoRotateCheckBox); - this.Controls.Add(this.trackBar3); - this.Controls.Add(this.trackBar2); - this.Controls.Add(this.trackBar1); - this.Controls.Add(this.perspectivePanel); - this.Name = "PerspectiveForm"; - this.Text = "SharpMath.3DTest"; - this.Load += new System.EventHandler(this.PerspectiveForm_Load); - ((System.ComponentModel.ISupportInitialize)(this.trackBar1)).EndInit(); - ((System.ComponentModel.ISupportInitialize)(this.trackBar2)).EndInit(); - ((System.ComponentModel.ISupportInitialize)(this.trackBar3)).EndInit(); - ((System.ComponentModel.ISupportInitialize)(this.scalationTrackBar)).EndInit(); - this.ResumeLayout(false); - this.PerformLayout(); - - } - - #endregion - - private PerspectivePanel perspectivePanel; - private System.Windows.Forms.TrackBar trackBar1; - private System.Windows.Forms.TrackBar trackBar2; - private System.Windows.Forms.TrackBar trackBar3; - private System.Windows.Forms.Timer rotationTimer; - private System.Windows.Forms.CheckBox autoRotateCheckBox; - private System.Windows.Forms.Label label1; - private System.Windows.Forms.Label label2; - private System.Windows.Forms.Label label3; - private System.Windows.Forms.Label label4; - private System.Windows.Forms.TrackBar scalationTrackBar; - } -} - diff --git a/SharpMath.3DTest/PerspectiveForm.cs b/SharpMath.3DTest/PerspectiveForm.cs deleted file mode 100644 index f2ca787..0000000 --- a/SharpMath.3DTest/PerspectiveForm.cs +++ /dev/null @@ -1,158 +0,0 @@ -// PerspectiveForm.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; -using System.Drawing; -using System.Drawing.Drawing2D; -using System.Windows.Forms; -using SharpMath.Geometry; - -namespace SharpMath._3DTest -{ - public partial class PerspectiveForm : Form - { - private readonly Vector4[] _vertices = - { - new Vector4(-0.5, 0.5, 1.5, 1), new Vector4(0.5, 0.5, 1.5, 1), new Vector4(0.5, -0.5, 1.5, 1), - new Vector4(-0.5, -0.5, 1.5, 1), - new Vector4(-0.5, 0.5, 2.5, 1), new Vector4(0.5, 0.5, 2.5, 1), new Vector4(0.5, -0.5, 2.5, 1), - new Vector4(-0.5, -0.5, 2.5, 1) - }; - - private Color _color = Color.White; - private Matrix4x4 _rotationX = Matrix4x4.Identity; - private Matrix4x4 _rotationY = Matrix4x4.Identity; - private Matrix4x4 _rotationZ = Matrix4x4.Identity; - private Matrix4x4 _scalation = Matrix4x4.Scalation(0.5f); - private Matrix4x4 _view; - private Matrix4x4 _world; - private float angle; - - private float delta = 1f; - //private Matrix4x4 _projection; - - public PerspectiveForm() - { - InitializeComponent(); - } - - private void autoRotateCheckBox_CheckedChanged(object sender, EventArgs e) - { - rotationTimer.Enabled = !rotationTimer.Enabled; - trackBar1.Enabled = !trackBar1.Enabled; - trackBar2.Enabled = !trackBar2.Enabled; - trackBar3.Enabled = !trackBar3.Enabled; - if (!rotationTimer.Enabled) - _color = Color.White; - } - - private void PerspectiveForm_Load(object sender, EventArgs e) - { - // Bottom front left, Bottom front right, Bottom back right, Bottom back left, Top front left, Top front right, Top back right, Top back left - //_vertices = new[] { new Vector4(-1.5, 0, 0, 1), new Vector4(1.5, 0, 0, 1), new Vector4(1.5, 0, -1.5, 1), new Vector4(-1.5, 0, -1.5, 1), new Vector4(-1.5, 1.5, 0, 1), new Vector4(1.5, 1.5, 0, 1), new Vector4(1.5, 1.5, -1.5, 1), new Vector4(-1.5, 1.5, -1.5, 1) }; - UpdateMatrices(); - } - - private void perspectivePanel_Paint(object sender, PaintEventArgs e) - { - var transformationMatrix = Matrix4x4.Translation(0, 0, 2) * _view * _world * _rotationX * _rotationY * - _rotationZ * - _scalation * Matrix4x4.Translation(0, 0, -2); - var projectionData = new ProjectionData(perspectivePanel.Size, 16f / 9f, (float) Math.PI / 3f, 0.1f, 100f); - - Func projectPerspective = vector => - { - var perspectiveVector = vector * Matrix4x4.PerspectiveFieldOfView(projectionData); - perspectiveVector.X /= perspectiveVector.W; - perspectiveVector.Y /= perspectiveVector.W; - perspectiveVector.Z /= perspectiveVector.W; - - var deviceVector = perspectiveVector * - Matrix4x4.Scalation(projectionData.CanvasSize.Width / 2.0, - projectionData.CanvasSize.Height / 2.0, 1); - return new Vector2(deviceVector.X + projectionData.CanvasSize.Width / 2, - projectionData.CanvasSize.Height / 2 - deviceVector.Y); - }; - - var displayCoordinates = new PointF[_vertices.Length]; - for (var i = 0; i < _vertices.Length; i++) - { - var displayVector = projectPerspective(_vertices[i] * transformationMatrix); - displayCoordinates[i] = new PointF((float) displayVector.X, (float) displayVector.Y); - } - - var g = e.Graphics; - g.SmoothingMode = SmoothingMode.AntiAlias; - g.PixelOffsetMode = PixelOffsetMode.HighQuality; - using (var p = new Pen(_color, 3)) - { - for (int i = 0, j = 3; i < 4; j = i, i++) - g.DrawLine(p, displayCoordinates[i], displayCoordinates[j]); - for (int i = 4, j = 7; i < 8; j = i, i++) - g.DrawLine(p, displayCoordinates[i], displayCoordinates[j]); - for (int i = 0, j = 4; i < 4; i++, j++) - g.DrawLine(p, displayCoordinates[i], displayCoordinates[j]); - } - } - - private void rotationTimer_Tick(object sender, EventArgs e) - { - if (angle <= 0) - delta = 1f; - else if (angle >= 360) - delta = -1f; - angle += delta; - - _rotationX = Matrix4x4.RotationX(MathHelper.DegreesToRadians(angle)); - _rotationY = Matrix4x4.RotationY(MathHelper.DegreesToRadians(angle)); - _rotationZ = Matrix4x4.RotationZ(MathHelper.DegreesToRadians(angle)); - - trackBar1.Value = (int) angle; - trackBar2.Value = (int) angle; - trackBar3.Value = (int) angle; - - // Causes headaches - //Random randomGen = new Random(); - //KnownColor[] names = (KnownColor[])Enum.GetValues(typeof(KnownColor)); - //KnownColor randomColorName = names[randomGen.Next(names.Length)]; - //_color = Color.FromKnownColor(randomColorName); - - UpdateMatrices(); - } - - private void scalationTrackBar_Scroll(object sender, EventArgs e) - { - _scalation = Matrix4x4.Scalation(scalationTrackBar.Value / 720f); - UpdateMatrices(); - } - - private void trackBar1_Scroll(object sender, EventArgs e) - { - _rotationX = Matrix4x4.RotationX(MathHelper.DegreesToRadians(trackBar1.Value)); - UpdateMatrices(); - } - - private void trackBar2_Scroll(object sender, EventArgs e) - { - _rotationY = Matrix4x4.RotationY(MathHelper.DegreesToRadians(trackBar2.Value)); - UpdateMatrices(); - } - - private void trackBar3_Scroll(object sender, EventArgs e) - { - _rotationZ = Matrix4x4.RotationZ(MathHelper.DegreesToRadians(trackBar3.Value)); - UpdateMatrices(); - } - - private void UpdateMatrices() - { - var cameraPosition = Vector3.Zero; - var cameraTarget = Vector3.Back; - _view = Matrix4x4.View(cameraPosition, cameraTarget, Vector3.Up); - _world = Matrix4x4.World(cameraPosition, Vector3.Back, Vector3.Up); - //_projection = Matrix4x4.PerspectiveFieldOfView(projectionData); - - perspectivePanel.Invalidate(); - } - } -} \ No newline at end of file diff --git a/SharpMath.3DTest/PerspectiveForm.resx b/SharpMath.3DTest/PerspectiveForm.resx deleted file mode 100644 index b2e20b4..0000000 --- a/SharpMath.3DTest/PerspectiveForm.resx +++ /dev/null @@ -1,123 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - 17, 17 - - \ No newline at end of file diff --git a/SharpMath.3DTest/PerspectivePanel.cs b/SharpMath.3DTest/PerspectivePanel.cs deleted file mode 100644 index 8a9ad49..0000000 --- a/SharpMath.3DTest/PerspectivePanel.cs +++ /dev/null @@ -1,18 +0,0 @@ -// PerspectivePanel.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System.Windows.Forms; - -namespace SharpMath._3DTest -{ - public class PerspectivePanel : Panel - { - public PerspectivePanel() - { - SetStyle( - ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw | - ControlStyles.SupportsTransparentBackColor, true); - UpdateStyles(); - } - } -} \ No newline at end of file diff --git a/SharpMath.3DTest/Program.cs b/SharpMath.3DTest/Program.cs deleted file mode 100644 index a6b7af2..0000000 --- a/SharpMath.3DTest/Program.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Program.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; -using System.Windows.Forms; - -namespace SharpMath._3DTest -{ - internal static class Program - { - /// - /// The main entry point for the application. - /// - [STAThread] - private static void Main() - { - Application.EnableVisualStyles(); - Application.SetCompatibleTextRenderingDefault(false); - Application.Run(new PerspectiveForm()); - } - } -} \ No newline at end of file diff --git a/SharpMath.3DTest/Properties/AssemblyInfo.cs b/SharpMath.3DTest/Properties/AssemblyInfo.cs deleted file mode 100644 index 2862195..0000000 --- a/SharpMath.3DTest/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,42 +0,0 @@ -// AssemblyInfo.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. - -[assembly: AssemblyTitle("SharpMath.3DTest")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("SharpMath.3DTest")] -[assembly: AssemblyCopyright("Copyright © 2016")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. - -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM - -[assembly: Guid("15428131-2ac9-4af4-8966-664ff1974ce8")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] - -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] \ No newline at end of file diff --git a/SharpMath.3DTest/Properties/Resources.Designer.cs b/SharpMath.3DTest/Properties/Resources.Designer.cs deleted file mode 100644 index abdb4ff..0000000 --- a/SharpMath.3DTest/Properties/Resources.Designer.cs +++ /dev/null @@ -1,63 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace SharpMath._3DTest.Properties { - using System; - - - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - internal class Resources { - - private static global::System.Resources.ResourceManager resourceMan; - - private static global::System.Globalization.CultureInfo resourceCulture; - - [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal Resources() { - } - - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Resources.ResourceManager ResourceManager { - get { - if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("SharpMath._3DTest.Properties.Resources", typeof(Resources).Assembly); - resourceMan = temp; - } - return resourceMan; - } - } - - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Globalization.CultureInfo Culture { - get { - return resourceCulture; - } - set { - resourceCulture = value; - } - } - } -} diff --git a/SharpMath.3DTest/Properties/Resources.resx b/SharpMath.3DTest/Properties/Resources.resx deleted file mode 100644 index af7dbeb..0000000 --- a/SharpMath.3DTest/Properties/Resources.resx +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - \ No newline at end of file diff --git a/SharpMath.3DTest/Properties/Settings.Designer.cs b/SharpMath.3DTest/Properties/Settings.Designer.cs deleted file mode 100644 index 5b7c4cb..0000000 --- a/SharpMath.3DTest/Properties/Settings.Designer.cs +++ /dev/null @@ -1,26 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace SharpMath._3DTest.Properties { - - - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "14.0.0.0")] - internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { - - private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); - - public static Settings Default { - get { - return defaultInstance; - } - } - } -} diff --git a/SharpMath.3DTest/Properties/Settings.settings b/SharpMath.3DTest/Properties/Settings.settings deleted file mode 100644 index e04fc63..0000000 --- a/SharpMath.3DTest/Properties/Settings.settings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/SharpMath.3DTest/SharpMath.3DTest.csproj b/SharpMath.3DTest/SharpMath.3DTest.csproj deleted file mode 100644 index ec2f198..0000000 --- a/SharpMath.3DTest/SharpMath.3DTest.csproj +++ /dev/null @@ -1,100 +0,0 @@ - - - - - Debug - AnyCPU - {15428131-2AC9-4AF4-8966-664FF1974CE8} - WinExe - Properties - SharpMath._3DTest - SharpMath.3DTest - v4.0 - 512 - true - - - - AnyCPU - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - AnyCPU - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - - - - - - - - - - - - - Form - - - PerspectiveForm.cs - - - Component - - - - - PerspectiveForm.cs - - - ResXFileCodeGenerator - Resources.Designer.cs - Designer - - - True - Resources.resx - True - - - SettingsSingleFileGenerator - Settings.Designer.cs - - - True - Settings.settings - True - - - - - - - - {4706ae0f-30c8-460b-bbbf-5f189be4eb76} - SharpMath - - - - - \ No newline at end of file diff --git a/SharpMath.CanvasTest/App.config b/SharpMath.CanvasTest/App.config deleted file mode 100644 index 0ac2de8..0000000 --- a/SharpMath.CanvasTest/App.config +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/SharpMath.CanvasTest/Canvas2DTest.Designer.cs b/SharpMath.CanvasTest/Canvas2DTest.Designer.cs deleted file mode 100644 index 890c3bb..0000000 --- a/SharpMath.CanvasTest/Canvas2DTest.Designer.cs +++ /dev/null @@ -1,64 +0,0 @@ -namespace SharpMath.Canvas2DTest { - partial class Canvas2DTest { - /// - /// Erforderliche Designervariable. - /// - private System.ComponentModel.IContainer components = null; - - /// - /// Verwendete Ressourcen bereinigen. - /// - /// True, wenn verwaltete Ressourcen gelöscht werden sollen; andernfalls False. - protected override void Dispose (bool disposing) { - if (disposing && (components != null)) { - components.Dispose (); - } - base.Dispose (disposing); - } - - #region Vom Windows Form-Designer generierter Code - - /// - /// Erforderliche Methode für die Designerunterstützung. - /// Der Inhalt der Methode darf nicht mit dem Code-Editor geändert werden. - /// - private void InitializeComponent () { - System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager (typeof (Canvas2DTest)); - this.testCanvas = new SharpMath.Presentation.Canvas2D (); - this.SuspendLayout (); - // - // testCanvas - // - this.testCanvas.Dock = System.Windows.Forms.DockStyle.Fill; - this.testCanvas.Font = new System.Drawing.Font ("Segoe UI", 8F); - this.testCanvas.ForeColor = System.Drawing.Color.Gray; - this.testCanvas.GridOrigin = ((System.Drawing.PointF)(resources.GetObject ("testCanvas.GridOrigin"))); - this.testCanvas.GridScale = new System.Drawing.SizeF (20F, 10F); - this.testCanvas.GridSize = new System.Drawing.SizeF (20F, 20F); - this.testCanvas.Location = new System.Drawing.Point (0, 0); - this.testCanvas.Name = "testCanvas"; - this.testCanvas.Size = new System.Drawing.Size (825, 469); - this.testCanvas.TabIndex = 0; - this.testCanvas.Text = "testCanvas"; - this.testCanvas.TrackingLines = false; - this.testCanvas.ValueIndicator = true; - // - // Canvas2DTest - // - this.AutoScaleDimensions = new System.Drawing.SizeF (6F, 13F); - this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.ClientSize = new System.Drawing.Size (825, 469); - this.Controls.Add (this.testCanvas); - this.Name = "Canvas2DTest"; - this.Text = "Canvas2DTest"; - this.Load += new System.EventHandler (this.Canvas2DTest_Load); - this.ResumeLayout (false); - - } - - #endregion - - private SharpMath.Presentation.Canvas2D testCanvas; - } -} - diff --git a/SharpMath.CanvasTest/Canvas2DTest.cs b/SharpMath.CanvasTest/Canvas2DTest.cs deleted file mode 100644 index 2854c0a..0000000 --- a/SharpMath.CanvasTest/Canvas2DTest.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Canvas2DTest.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; -using System.Windows.Forms; -using SharpMath.Geometry; -using SharpMath.Presentation; - -namespace SharpMath.Canvas2DTest -{ - public partial class Canvas2DTest : Form - { - public Canvas2DTest() - { - InitializeComponent(); - } - - private void Canvas2DTest_Load(object sender, EventArgs e) - { - testCanvas.Functions.Add(new FunctionWrapper(x => Math.Pow(x, 3))); - testCanvas.Functions.Add(new FunctionWrapper(x => 1.5 * Math.Sin(x))); - testCanvas.Vectors.Add(new Vector2(4, 8)); - } - } -} \ No newline at end of file diff --git a/SharpMath.CanvasTest/Canvas2DTest.resx b/SharpMath.CanvasTest/Canvas2DTest.resx deleted file mode 100644 index 08e6fc5..0000000 --- a/SharpMath.CanvasTest/Canvas2DTest.resx +++ /dev/null @@ -1,127 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - - AAEAAAD/////AQAAAAAAAAAMAgAAAFFTeXN0ZW0uRHJhd2luZywgVmVyc2lvbj00LjAuMC4wLCBDdWx0 - dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWIwM2Y1ZjdmMTFkNTBhM2EFAQAAABVTeXN0ZW0uRHJh - d2luZy5Qb2ludEYCAAAAAXgBeQAACwsCAAAAAADOQwAAakML - - - \ No newline at end of file diff --git a/SharpMath.CanvasTest/Program.cs b/SharpMath.CanvasTest/Program.cs deleted file mode 100644 index bc88b7f..0000000 --- a/SharpMath.CanvasTest/Program.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Program.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; -using System.Windows.Forms; - -namespace SharpMath.Canvas2DTest -{ - internal static class Program - { - /// - /// Der Haupteinstiegspunkt für die Anwendung. - /// - [STAThread] - private static void Main() - { - Application.EnableVisualStyles(); - Application.SetCompatibleTextRenderingDefault(false); - Application.Run(new Canvas2DTest()); - } - } -} \ No newline at end of file diff --git a/SharpMath.CanvasTest/Properties/AssemblyInfo.cs b/SharpMath.CanvasTest/Properties/AssemblyInfo.cs deleted file mode 100644 index 410cd1f..0000000 --- a/SharpMath.CanvasTest/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,38 +0,0 @@ -// AssemblyInfo.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System.Reflection; -using System.Runtime.InteropServices; - -// Allgemeine Informationen über eine Assembly werden über die folgenden -// Attribute gesteuert. Ändern Sie diese Attributwerte, um die Informationen zu ändern, -// die einer Assembly zugeordnet sind. -[assembly: AssemblyTitle("CanvasTest")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("CanvasTest")] -[assembly: AssemblyCopyright("Copyright © 2016")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Durch Festlegen von ComVisible auf "false" werden die Typen in dieser Assembly unsichtbar -// für COM-Komponenten. Wenn Sie auf einen Typ in dieser Assembly von -// COM aus zugreifen müssen, sollten Sie das ComVisible-Attribut für diesen Typ auf "True" festlegen. -[assembly: ComVisible(false)] - -// Die folgende GUID bestimmt die ID der Typbibliothek, wenn dieses Projekt für COM verfügbar gemacht wird -[assembly: Guid("e8982e21-deed-40ea-8df9-0bd161ad1490")] - -// Versionsinformationen für eine Assembly bestehen aus den folgenden vier Werten: -// -// Hauptversion -// Nebenversion -// Buildnummer -// Revision -// -// Sie können alle Werte angeben oder die standardmäßigen Build- und Revisionsnummern -// übernehmen, indem Sie "*" eingeben: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] \ No newline at end of file diff --git a/SharpMath.CanvasTest/Properties/Resources.Designer.cs b/SharpMath.CanvasTest/Properties/Resources.Designer.cs deleted file mode 100644 index 3c13750..0000000 --- a/SharpMath.CanvasTest/Properties/Resources.Designer.cs +++ /dev/null @@ -1,63 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace SharpMath.Canvas2DTest.Properties { - using System; - - - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - internal class Resources { - - private static global::System.Resources.ResourceManager resourceMan; - - private static global::System.Globalization.CultureInfo resourceCulture; - - [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal Resources() { - } - - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Resources.ResourceManager ResourceManager { - get { - if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("SharpMath.Canvas2DTest.Properties.Resources", typeof(Resources).Assembly); - resourceMan = temp; - } - return resourceMan; - } - } - - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Globalization.CultureInfo Culture { - get { - return resourceCulture; - } - set { - resourceCulture = value; - } - } - } -} diff --git a/SharpMath.CanvasTest/Properties/Resources.resx b/SharpMath.CanvasTest/Properties/Resources.resx deleted file mode 100644 index af7dbeb..0000000 --- a/SharpMath.CanvasTest/Properties/Resources.resx +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - \ No newline at end of file diff --git a/SharpMath.CanvasTest/Properties/Settings.Designer.cs b/SharpMath.CanvasTest/Properties/Settings.Designer.cs deleted file mode 100644 index 82581d2..0000000 --- a/SharpMath.CanvasTest/Properties/Settings.Designer.cs +++ /dev/null @@ -1,26 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace SharpMath.Canvas2DTest.Properties { - - - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "14.0.0.0")] - internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { - - private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); - - public static Settings Default { - get { - return defaultInstance; - } - } - } -} diff --git a/SharpMath.CanvasTest/Properties/Settings.settings b/SharpMath.CanvasTest/Properties/Settings.settings deleted file mode 100644 index e04fc63..0000000 --- a/SharpMath.CanvasTest/Properties/Settings.settings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/SharpMath.CanvasTest/SharpMath.Canvas2DTest.csproj b/SharpMath.CanvasTest/SharpMath.Canvas2DTest.csproj deleted file mode 100644 index bd8f91c..0000000 --- a/SharpMath.CanvasTest/SharpMath.Canvas2DTest.csproj +++ /dev/null @@ -1,125 +0,0 @@ - - - - - Debug - AnyCPU - {E8982E21-DEED-40EA-8DF9-0BD161AD1490} - WinExe - Properties - SharpMath.Canvas2DTest - SharpMath.CanvasTest - v4.0 - 512 - true - publish\ - true - Disk - false - Foreground - 7 - Days - false - false - true - 0 - 1.0.0.%2a - false - false - true - - - - AnyCPU - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - AnyCPU - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - - - - - - - - - - - - - - Form - - - Canvas2DTest.cs - - - - - Canvas2DTest.cs - - - ResXFileCodeGenerator - Resources.Designer.cs - Designer - - - True - Resources.resx - True - - - SettingsSingleFileGenerator - Settings.Designer.cs - - - True - Settings.settings - True - - - - - - - - {4706AE0F-30C8-460B-BBBF-5F189BE4EB76} - SharpMath - - - - - False - Microsoft .NET Framework 4.5.2 %28x86 and x64%29 - true - - - False - .NET Framework 3.5 SP1 - false - - - - - \ No newline at end of file diff --git a/SharpMath.PolygonTest/App.config b/SharpMath.PolygonTest/App.config deleted file mode 100644 index 0ac2de8..0000000 --- a/SharpMath.PolygonTest/App.config +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/SharpMath.PolygonTest/ContainsTestForm.Designer.cs b/SharpMath.PolygonTest/ContainsTestForm.Designer.cs deleted file mode 100644 index c5042b1..0000000 --- a/SharpMath.PolygonTest/ContainsTestForm.Designer.cs +++ /dev/null @@ -1,49 +0,0 @@ -namespace SharpMath.PolygonTest -{ - partial class ContainsTestForm - { - /// - /// Required designer variable. - /// - private System.ComponentModel.IContainer components = null; - - /// - /// Clean up any resources being used. - /// - /// true if managed resources should be disposed; otherwise, false. - protected override void Dispose(bool disposing) - { - if (disposing && (components != null)) - { - components.Dispose(); - } - base.Dispose(disposing); - } - - #region Windows Form Designer generated code - - /// - /// Required method for Designer support - do not modify - /// the contents of this method with the code editor. - /// - private void InitializeComponent() - { - this.SuspendLayout(); - // - // ContainsTestForm - // - this.AutoScaleDimensions = new System.Drawing.SizeF(9F, 20F); - this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.ClientSize = new System.Drawing.Size(543, 405); - this.Name = "ContainsTestForm"; - this.Text = "ContainsTestForm"; - this.Paint += new System.Windows.Forms.PaintEventHandler(this.ContainsTestForm_Paint); - this.MouseMove += new System.Windows.Forms.MouseEventHandler(this.ContainsTestForm_MouseMove); - this.ResumeLayout(false); - - } - - #endregion - } -} - diff --git a/SharpMath.PolygonTest/ContainsTestForm.cs b/SharpMath.PolygonTest/ContainsTestForm.cs deleted file mode 100644 index 94c2d2f..0000000 --- a/SharpMath.PolygonTest/ContainsTestForm.cs +++ /dev/null @@ -1,51 +0,0 @@ -// ContainsTestForm.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System.Collections.Generic; -using System.Drawing; -using System.Windows.Forms; -using SharpMath.Geometry; - -namespace SharpMath.PolygonTest -{ - public partial class ContainsTestForm : Form - { - private readonly PointF[] _points = - { - new PointF(0, 0), new PointF(200, 80), new PointF(400, 200), - new PointF(100, 60) - }; - - private readonly Polygon _polygon; - private bool _containsPoint; - - public ContainsTestForm() - { - InitializeComponent(); - var polygonPoints = new List(); - foreach (var point in _points) - polygonPoints.Add(FromPointF(point)); - _polygon = new Polygon(polygonPoints.ToArray()); - } - - private void ContainsTestForm_MouseMove(object sender, MouseEventArgs e) - { - var containsPointOld = _containsPoint; - _containsPoint = _polygon.ContainsPoint(new Point2D(e.X, e.Y)); - if (_containsPoint != containsPointOld) - Invalidate(); - Text = e.Location.ToString(); - } - - private void ContainsTestForm_Paint(object sender, PaintEventArgs e) - { - e.Graphics.FillRectangle(Brushes.Black, ClientRectangle); - e.Graphics.FillPolygon(_containsPoint ? Brushes.AliceBlue : Brushes.Red, _points); - } - - private Point2D FromPointF(PointF point) - { - return new Point2D(point.X, point.Y); - } - } -} \ No newline at end of file diff --git a/SharpMath.PolygonTest/ContainsTestForm.resx b/SharpMath.PolygonTest/ContainsTestForm.resx deleted file mode 100644 index 1af7de1..0000000 --- a/SharpMath.PolygonTest/ContainsTestForm.resx +++ /dev/null @@ -1,120 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - \ No newline at end of file diff --git a/SharpMath.PolygonTest/Program.cs b/SharpMath.PolygonTest/Program.cs deleted file mode 100644 index 681f752..0000000 --- a/SharpMath.PolygonTest/Program.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Program.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System; -using System.Windows.Forms; - -namespace SharpMath.PolygonTest -{ - internal static class Program - { - /// - /// The main entry point for the application. - /// - [STAThread] - private static void Main() - { - Application.EnableVisualStyles(); - Application.SetCompatibleTextRenderingDefault(false); - Application.Run(new ContainsTestForm()); - } - } -} \ No newline at end of file diff --git a/SharpMath.PolygonTest/Properties/AssemblyInfo.cs b/SharpMath.PolygonTest/Properties/AssemblyInfo.cs deleted file mode 100644 index 3e1928a..0000000 --- a/SharpMath.PolygonTest/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,42 +0,0 @@ -// AssemblyInfo.cs, 07.11.2019 -// Copyright (C) Dominic Beger 07.11.2019 - -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. - -[assembly: AssemblyTitle("SharpMath.PolygonTest")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("SharpMath.PolygonTest")] -[assembly: AssemblyCopyright("Copyright © 2016")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. - -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM - -[assembly: Guid("1d3dd70b-e845-4993-a73e-7ebd14b9e005")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] - -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] \ No newline at end of file diff --git a/SharpMath.PolygonTest/Properties/Resources.Designer.cs b/SharpMath.PolygonTest/Properties/Resources.Designer.cs deleted file mode 100644 index 125e299..0000000 --- a/SharpMath.PolygonTest/Properties/Resources.Designer.cs +++ /dev/null @@ -1,63 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace SharpMath.PolygonTest.Properties { - using System; - - - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - internal class Resources { - - private static global::System.Resources.ResourceManager resourceMan; - - private static global::System.Globalization.CultureInfo resourceCulture; - - [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal Resources() { - } - - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Resources.ResourceManager ResourceManager { - get { - if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("SharpMath.PolygonTest.Properties.Resources", typeof(Resources).Assembly); - resourceMan = temp; - } - return resourceMan; - } - } - - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Globalization.CultureInfo Culture { - get { - return resourceCulture; - } - set { - resourceCulture = value; - } - } - } -} diff --git a/SharpMath.PolygonTest/Properties/Resources.resx b/SharpMath.PolygonTest/Properties/Resources.resx deleted file mode 100644 index af7dbeb..0000000 --- a/SharpMath.PolygonTest/Properties/Resources.resx +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - \ No newline at end of file diff --git a/SharpMath.PolygonTest/Properties/Settings.Designer.cs b/SharpMath.PolygonTest/Properties/Settings.Designer.cs deleted file mode 100644 index c5980a2..0000000 --- a/SharpMath.PolygonTest/Properties/Settings.Designer.cs +++ /dev/null @@ -1,26 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace SharpMath.PolygonTest.Properties { - - - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "14.0.0.0")] - internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { - - private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); - - public static Settings Default { - get { - return defaultInstance; - } - } - } -} diff --git a/SharpMath.PolygonTest/Properties/Settings.settings b/SharpMath.PolygonTest/Properties/Settings.settings deleted file mode 100644 index e04fc63..0000000 --- a/SharpMath.PolygonTest/Properties/Settings.settings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/SharpMath.PolygonTest/SharpMath.PolygonTest.csproj b/SharpMath.PolygonTest/SharpMath.PolygonTest.csproj deleted file mode 100644 index 4291ccb..0000000 --- a/SharpMath.PolygonTest/SharpMath.PolygonTest.csproj +++ /dev/null @@ -1,98 +0,0 @@ - - - - - Debug - AnyCPU - {1D3DD70B-E845-4993-A73E-7EBD14B9E005} - WinExe - Properties - SharpMath.PolygonTest - SharpMath.PolygonTest - v4.0 - 512 - true - - - - AnyCPU - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - AnyCPU - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - - - - - - - - - - - - - - Form - - - ContainsTestForm.cs - - - - - ContainsTestForm.cs - - - ResXFileCodeGenerator - Resources.Designer.cs - Designer - - - True - Resources.resx - True - - - SettingsSingleFileGenerator - Settings.Designer.cs - - - True - Settings.settings - True - - - - - - - - {4706ae0f-30c8-460b-bbbf-5f189be4eb76} - SharpMath - - - - - \ No newline at end of file diff --git a/SharpMath.slnx b/SharpMath.slnx index 4f8dced..37f5af5 100644 --- a/SharpMath.slnx +++ b/SharpMath.slnx @@ -1,4 +1,11 @@ + + + + + + + diff --git a/doc/images/canvas-functions.png b/doc/images/canvas-functions.png new file mode 100644 index 0000000000000000000000000000000000000000..0bb9a6d060b867764204720a208eae369897d137 GIT binary patch literal 36960 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +-8 +-6 +-4 +-2 +2 +4 +6 +8 +-4 +-2 +0 +2 +4 + + + + + + + + + + + + + diff --git a/doc/images/canvas-geometry.png b/doc/images/canvas-geometry.png new file mode 100644 index 0000000000000000000000000000000000000000..4c52c46b937d34998e74424dde929a15447733f5 GIT binary patch literal 10884 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 literal 0 HcmV?d00001 diff --git a/doc/images/canvas-geometry.svg b/doc/images/canvas-geometry.svg new file mode 100644 index 0000000..d8af0ce --- /dev/null +++ b/doc/images/canvas-geometry.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +-10 +-5 +5 +10 +-5 +0 +5 + + + + + + + + + + + + + +6.67 | 2 + + diff --git a/samples/Directory.Build.props b/samples/Directory.Build.props new file mode 100644 index 0000000..06810b5 --- /dev/null +++ b/samples/Directory.Build.props @@ -0,0 +1,9 @@ + + + + + + true + $(NoWarn);CS1591 + + diff --git a/samples/SharpMath.Samples.Canvas.Avalonia/MainWindow.cs b/samples/SharpMath.Samples.Canvas.Avalonia/MainWindow.cs new file mode 100644 index 0000000..ee74fc3 --- /dev/null +++ b/samples/SharpMath.Samples.Canvas.Avalonia/MainWindow.cs @@ -0,0 +1,72 @@ +using Avalonia.Controls; +using Avalonia.Layout; +using SharpMath.Canvas; +using SharpMath.Canvas.Avalonia; +using SharpMath.Expressions; +using SharpMath.Geometry; + +namespace SharpMath.Samples.Canvas.Avalonia; + +/// +/// Shows the canvas on Windows, Linux and macOS. Drag to pan, use the wheel to zoom, and enter expressions such as +/// 2x^2 - 3x to plot them. +/// +internal sealed class MainWindow : Window +{ + private readonly TextBox _expression = new() { Text = "x^2 / 4 - 3", Width = 220 }; + private readonly TextBlock _status = new() { VerticalAlignment = VerticalAlignment.Center }; + private readonly Canvas2DView _view = new(); + + public MainWindow() + { + Title = "SharpMath Canvas (Avalonia, Skia)"; + Width = 900; + Height = 560; + + var plot = new Button { Content = "Plot", IsDefault = true }; + plot.Click += (_, _) => PlotExpression(); + var clear = new Button { Content = "Clear" }; + clear.Click += (_, _) => _view.Canvas.Items.Clear(); + var tracking = new CheckBox { Content = "Tracking lines" }; + tracking.IsCheckedChanged += (_, _) => _view.ShowTrackingLines = tracking.IsChecked == true; + var indicator = new CheckBox { Content = "Value indicator" }; + indicator.IsCheckedChanged += (_, _) => _view.ShowValueIndicator = indicator.IsChecked == true; + + var toolbar = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + Margin = new global::Avalonia.Thickness(8), + Children = + { + new TextBlock { Text = "f(x) =", VerticalAlignment = VerticalAlignment.Center }, + _expression, plot, clear, tracking, indicator, _status + } + }; + + var root = new DockPanel(); + DockPanel.SetDock(toolbar, Dock.Top); + root.Children.Add(toolbar); + root.Children.Add(_view); + Content = root; + + _view.Canvas.AddFunction(x => Math.Pow(x, 3)); + _view.Canvas.AddFunction(x => 1.5 * Math.Sin(x), new ItemStyle(new Stroke(Color.FromRgb(200, 0, 0), 2))); + _view.Canvas.AddVector(new Vector2(4, 8)); + _view.Canvas.AddPolygon(new Polygon(new Point2D(2, -2), new Point2D(6, -2), new Point2D(4, -5))); + } + + private void PlotExpression() + { + try + { + _view.Canvas.AddFunction(_expression.Text ?? string.Empty, + new ItemStyle(new Stroke(Color.FromRgb(0, 90, 200), 2))); + _status.Text = string.Empty; + } + catch (Exception exception) when (exception is ParserException or ArgumentException) + { + _status.Text = exception.Message; + } + } +} diff --git a/samples/SharpMath.Samples.Canvas.Avalonia/Program.cs b/samples/SharpMath.Samples.Canvas.Avalonia/Program.cs new file mode 100644 index 0000000..ffd47fd --- /dev/null +++ b/samples/SharpMath.Samples.Canvas.Avalonia/Program.cs @@ -0,0 +1,25 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Themes.Fluent; + +namespace SharpMath.Samples.Canvas.Avalonia; + +internal static class Program +{ + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + + public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure().UsePlatformDetect(); +} + +internal sealed class App : Application +{ + public override void Initialize() => Styles.Add(new FluentTheme()); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + desktop.MainWindow = new MainWindow(); + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/samples/SharpMath.Samples.Canvas.Avalonia/SharpMath.Samples.Canvas.Avalonia.csproj b/samples/SharpMath.Samples.Canvas.Avalonia/SharpMath.Samples.Canvas.Avalonia.csproj new file mode 100644 index 0000000..e0d1d7e --- /dev/null +++ b/samples/SharpMath.Samples.Canvas.Avalonia/SharpMath.Samples.Canvas.Avalonia.csproj @@ -0,0 +1,16 @@ + + + + WinExe + + + + + + + + + + + + diff --git a/samples/SharpMath.Samples.Canvas.WinForms/CanvasForm.cs b/samples/SharpMath.Samples.Canvas.WinForms/CanvasForm.cs new file mode 100644 index 0000000..b13a67d --- /dev/null +++ b/samples/SharpMath.Samples.Canvas.WinForms/CanvasForm.cs @@ -0,0 +1,55 @@ +using SharpMath.Canvas; +using SharpMath.Canvas.WinForms; +using SharpMath.Expressions; +using SharpMath.Geometry; + +namespace SharpMath.Samples.Canvas.WinForms; + +/// +/// Shows the canvas with a few functions and a vector. Drag to pan, use the wheel to zoom, and enter expressions +/// such as 2x^2 - 3x to plot them. +/// +internal sealed class CanvasForm : Form +{ + private readonly Canvas2DControl _canvas = new() { Dock = DockStyle.Fill }; + private readonly TextBox _expression = new() { Width = 220, Text = "x^2 / 4 - 3" }; + + public CanvasForm() + { + Text = "SharpMath Canvas (WinForms, GDI+)"; + ClientSize = new Size(900, 560); + + var plot = new Button { Text = "Plot", AutoSize = true }; + plot.Click += (_, _) => PlotExpression(); + var clear = new Button { Text = "Clear", AutoSize = true }; + clear.Click += (_, _) => _canvas.Canvas.Items.Clear(); + var tracking = new CheckBox { Text = "Tracking lines", AutoSize = true }; + tracking.CheckedChanged += (_, _) => _canvas.ShowTrackingLines = tracking.Checked; + var indicator = new CheckBox { Text = "Value indicator", AutoSize = true }; + indicator.CheckedChanged += (_, _) => _canvas.ShowValueIndicator = indicator.Checked; + + var toolbar = new FlowLayoutPanel { Dock = DockStyle.Top, AutoSize = true, Padding = new Padding(4) }; + toolbar.Controls.AddRange([new Label { Text = "f(x) =", AutoSize = true, Margin = new Padding(3, 7, 0, 0) }, + _expression, plot, clear, tracking, indicator]); + + Controls.Add(_canvas); + Controls.Add(toolbar); + AcceptButton = plot; + + _canvas.Canvas.AddFunction(x => Math.Pow(x, 3)); + _canvas.Canvas.AddFunction(x => 1.5 * Math.Sin(x), new ItemStyle(new Stroke(SharpMath.Canvas.Color.FromRgb(200, 0, 0), 2))); + _canvas.Canvas.AddVector(new Vector2(4, 8)); + } + + private void PlotExpression() + { + try + { + _canvas.Canvas.AddFunction(_expression.Text, new ItemStyle(new Stroke(SharpMath.Canvas.Color.FromRgb(0, 90, 200), 2))); + } + catch (Exception exception) when (exception is ParserException or ArgumentException) + { + MessageBox.Show(this, exception.Message, "Invalid expression", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } +} diff --git a/samples/SharpMath.Samples.Canvas.WinForms/Program.cs b/samples/SharpMath.Samples.Canvas.WinForms/Program.cs new file mode 100644 index 0000000..d50bf65 --- /dev/null +++ b/samples/SharpMath.Samples.Canvas.WinForms/Program.cs @@ -0,0 +1,11 @@ +namespace SharpMath.Samples.Canvas.WinForms; + +internal static class Program +{ + [STAThread] + private static void Main() + { + ApplicationConfiguration.Initialize(); + Application.Run(new CanvasForm()); + } +} diff --git a/samples/SharpMath.Samples.Canvas.WinForms/SharpMath.Samples.Canvas.WinForms.csproj b/samples/SharpMath.Samples.Canvas.WinForms/SharpMath.Samples.Canvas.WinForms.csproj new file mode 100644 index 0000000..a7fc645 --- /dev/null +++ b/samples/SharpMath.Samples.Canvas.WinForms/SharpMath.Samples.Canvas.WinForms.csproj @@ -0,0 +1,14 @@ + + + + WinExe + net10.0-windows + true + true + + + + + + + diff --git a/samples/SharpMath.Samples.Export/Program.cs b/samples/SharpMath.Samples.Export/Program.cs new file mode 100644 index 0000000..6cd1be7 --- /dev/null +++ b/samples/SharpMath.Samples.Export/Program.cs @@ -0,0 +1,38 @@ +// Renders two example plots to PNG (SkiaSharp) and SVG (no dependencies) without any UI. +// Usage: dotnet run --project samples/SharpMath.Samples.Export -- [output directory] +// The images in doc/images are generated with: dotnet run --project samples/SharpMath.Samples.Export -- doc/images + +using SharpMath.Canvas; +using SharpMath.Canvas.Skia; +using SharpMath.Geometry; + +var output = args.Length > 0 ? args[0] : "output"; +Directory.CreateDirectory(output); + +var functions = new Canvas2D(); +functions.Viewport.SetScale(40, 40); +functions.AddFunction("x^3 / 10 - x"); +functions.AddFunction(x => 1.5 * Math.Sin(2 * x), new ItemStyle(new Stroke(Color.FromRgb(200, 0, 0), 2))); +functions.AddFunction("tan(x)", new ItemStyle(new Stroke(Color.FromRgb(0, 90, 200), 2, DashStyle.Dashed))); +Export(functions, "canvas-functions"); + +var geometry = new Canvas2D { ShowTrackingLines = true, ShowValueIndicator = true }; +geometry.Viewport.SetScale(30, 30); +geometry.AddPolygon(new Polygon(new Point2D(1, 1), new Point2D(5, 1), new Point2D(6, 4), new Point2D(2, 5))); +geometry.AddLine(Line2D.FromSlopeIntercept(-0.5, -1)); +geometry.AddSegment(new LineSegment2D(new Point2D(-7, -2), new Point2D(-2, -4))); +geometry.AddVector(new Vector2(-4, 3)); +geometry.AddVector(new Vector2(3, -2), new Point2D(-4, 3)); +geometry.AddPoint(new Point2D(-1, 3)); +geometry.PointerMoved(new CanvasPoint(560, 140)); +Export(geometry, "canvas-geometry"); + +void Export(Canvas2D canvas, string name) +{ + const int width = 720; + const int height = 400; + using (var png = File.Create(Path.Combine(output, name + ".png"))) + canvas.RenderPng(png, width, height); + File.WriteAllText(Path.Combine(output, name + ".svg"), canvas.RenderSvg(width, height)); + Console.WriteLine($"Wrote {Path.Combine(output, name)}.png and .svg"); +} diff --git a/samples/SharpMath.Samples.Export/SharpMath.Samples.Export.csproj b/samples/SharpMath.Samples.Export/SharpMath.Samples.Export.csproj new file mode 100644 index 0000000..32bb99a --- /dev/null +++ b/samples/SharpMath.Samples.Export/SharpMath.Samples.Export.csproj @@ -0,0 +1,11 @@ + + + + Exe + + + + + + + diff --git a/samples/SharpMath.Samples.Perspective.WinForms/PerspectiveForm.cs b/samples/SharpMath.Samples.Perspective.WinForms/PerspectiveForm.cs new file mode 100644 index 0000000..8c2e0c2 --- /dev/null +++ b/samples/SharpMath.Samples.Perspective.WinForms/PerspectiveForm.cs @@ -0,0 +1,110 @@ +using SharpMath.Geometry; + +namespace SharpMath.Samples.Perspective.WinForms; + +/// +/// Projects a rotating wireframe cube with : world transformations, +/// and a right-handed perspective projection, composed as projection · view · model (column vectors). +/// +internal sealed class PerspectiveForm : Form +{ + private static readonly Vector3[] Vertices = + [ + new(-0.5, -0.5, 0.5), new(0.5, -0.5, 0.5), new(0.5, 0.5, 0.5), new(-0.5, 0.5, 0.5), + new(-0.5, -0.5, -0.5), new(0.5, -0.5, -0.5), new(0.5, 0.5, -0.5), new(-0.5, 0.5, -0.5) + ]; + + private static readonly (int From, int To)[] Edges = + [ + (0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7) + ]; + + private readonly CheckBox _autoRotate = new() { Text = "Auto rotation", AutoSize = true }; + private readonly DoubleBufferedPanel _panel = new() { Dock = DockStyle.Fill, BackColor = System.Drawing.Color.Black }; + private readonly TrackBar _rotationX = CreateTrackBar(30); + private readonly TrackBar _rotationY = CreateTrackBar(40); + private readonly TrackBar _rotationZ = CreateTrackBar(0); + private readonly TrackBar _scaling = CreateTrackBar(360); + private readonly System.Windows.Forms.Timer _timer = new() { Interval = 40 }; + + public PerspectiveForm() + { + Text = "SharpMath Perspective"; + ClientSize = new Size(900, 700); + + var controls = new TableLayoutPanel { Dock = DockStyle.Bottom, AutoSize = true, ColumnCount = 5 }; + AddRow(controls, "Rotation X:", _rotationX); + AddRow(controls, "Rotation Y:", _rotationY); + AddRow(controls, "Rotation Z:", _rotationZ); + AddRow(controls, "Scaling:", _scaling); + controls.Controls.Add(_autoRotate, 2, 0); + + Controls.Add(_panel); + Controls.Add(controls); + + foreach (var trackBar in new[] { _rotationX, _rotationY, _rotationZ, _scaling }) + trackBar.Scroll += (_, _) => _panel.Invalidate(); + _panel.Paint += (_, e) => DrawCube(e.Graphics); + _autoRotate.CheckedChanged += (_, _) => _timer.Enabled = _autoRotate.Checked; + _timer.Tick += (_, _) => + { + foreach (var trackBar in new[] { _rotationX, _rotationY, _rotationZ }) + trackBar.Value = (trackBar.Value + 1) % (trackBar.Maximum + 1); + _panel.Invalidate(); + }; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + _timer.Dispose(); + base.Dispose(disposing); + } + + private static TrackBar CreateTrackBar(int value) => + new() { Maximum = 360, Value = value, TickFrequency = 30, Width = 260 }; + + private static void AddRow(TableLayoutPanel table, string text, TrackBar trackBar) + { + var row = table.RowCount++; + table.Controls.Add(new Label { Text = text, AutoSize = true, Anchor = AnchorStyles.Left }, 0, row); + table.Controls.Add(trackBar, 1, row); + } + + private void DrawCube(Graphics graphics) + { + var width = _panel.ClientSize.Width; + var height = _panel.ClientSize.Height; + if (width == 0 || height == 0) + return; + + // Transformations compose from right to left: scale, then rotate, then view and project. + var model = Matrix4x4.Rotation(double.DegreesToRadians(_rotationX.Value), + double.DegreesToRadians(_rotationY.Value), double.DegreesToRadians(_rotationZ.Value)) * + Matrix4x4.Scaling(Math.Max(0.05, _scaling.Value / 360.0)); + var view = Matrix4x4.LookAt(new Vector3(0, 0, 3), Vector3.Zero, Vector3.Up); + var projection = Matrix4x4.PerspectiveFieldOfView(Math.PI / 3, (double) width / height, 0.1, 100); + var transformation = projection * view * model; + + // Matrix4x4 * Vector3 performs the perspective divide; map normalized device coordinates to pixels. + var screen = Vertices.Select(vertex => + { + var ndc = transformation * vertex; + return new PointF((float) ((ndc.X + 1) / 2 * width), (float) ((1 - ndc.Y) / 2 * height)); + }).ToArray(); + + graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + using var pen = new Pen(System.Drawing.Color.White, 3); + foreach (var (from, to) in Edges) + graphics.DrawLine(pen, screen[from], screen[to]); + } + + private sealed class DoubleBufferedPanel : Panel + { + public DoubleBufferedPanel() + { + SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, + true); + } + } +} diff --git a/samples/SharpMath.Samples.Perspective.WinForms/Program.cs b/samples/SharpMath.Samples.Perspective.WinForms/Program.cs new file mode 100644 index 0000000..7302288 --- /dev/null +++ b/samples/SharpMath.Samples.Perspective.WinForms/Program.cs @@ -0,0 +1,11 @@ +namespace SharpMath.Samples.Perspective.WinForms; + +internal static class Program +{ + [STAThread] + private static void Main() + { + ApplicationConfiguration.Initialize(); + Application.Run(new PerspectiveForm()); + } +} diff --git a/samples/SharpMath.Samples.Perspective.WinForms/SharpMath.Samples.Perspective.WinForms.csproj b/samples/SharpMath.Samples.Perspective.WinForms/SharpMath.Samples.Perspective.WinForms.csproj new file mode 100644 index 0000000..bf490f6 --- /dev/null +++ b/samples/SharpMath.Samples.Perspective.WinForms/SharpMath.Samples.Perspective.WinForms.csproj @@ -0,0 +1,14 @@ + + + + WinExe + net10.0-windows + true + true + + + + + + + diff --git a/samples/SharpMath.Samples.Polygon.WinForms/ContainsForm.cs b/samples/SharpMath.Samples.Polygon.WinForms/ContainsForm.cs new file mode 100644 index 0000000..1ebad72 --- /dev/null +++ b/samples/SharpMath.Samples.Polygon.WinForms/ContainsForm.cs @@ -0,0 +1,42 @@ +using SharpMath.Geometry; + +namespace SharpMath.Samples.Polygon.WinForms; + +/// +/// Colors a polygon depending on whether the mouse pointer is inside it (). +/// +internal sealed class ContainsForm : Form +{ + private static readonly PointF[] Points = [new(20, 20), new(220, 100), new(420, 220), new(120, 80), new(60, 300)]; + + private readonly Geometry.Polygon _polygon = new(Points.Select(point => new Point2D(point.X, point.Y))); + private bool _containsPointer; + + public ContainsForm() + { + Text = "SharpMath Polygon.Contains"; + ClientSize = new Size(560, 420); + DoubleBuffered = true; + BackColor = System.Drawing.Color.Black; + } + + protected override void OnMouseMove(MouseEventArgs e) + { + ArgumentNullException.ThrowIfNull(e); + base.OnMouseMove(e); + var contains = _polygon.Contains(new Point2D(e.X, e.Y)); + Text = $"SharpMath Polygon.Contains: {e.Location} is {(contains ? "inside" : "outside")}"; + if (contains == _containsPointer) + return; + _containsPointer = contains; + Invalidate(); + } + + protected override void OnPaint(PaintEventArgs e) + { + ArgumentNullException.ThrowIfNull(e); + base.OnPaint(e); + e.Graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + e.Graphics.FillPolygon(_containsPointer ? Brushes.AliceBlue : Brushes.Red, Points); + } +} diff --git a/samples/SharpMath.Samples.Polygon.WinForms/Program.cs b/samples/SharpMath.Samples.Polygon.WinForms/Program.cs new file mode 100644 index 0000000..98c3f16 --- /dev/null +++ b/samples/SharpMath.Samples.Polygon.WinForms/Program.cs @@ -0,0 +1,11 @@ +namespace SharpMath.Samples.Polygon.WinForms; + +internal static class Program +{ + [STAThread] + private static void Main() + { + ApplicationConfiguration.Initialize(); + Application.Run(new ContainsForm()); + } +} diff --git a/samples/SharpMath.Samples.Polygon.WinForms/SharpMath.Samples.Polygon.WinForms.csproj b/samples/SharpMath.Samples.Polygon.WinForms/SharpMath.Samples.Polygon.WinForms.csproj new file mode 100644 index 0000000..bf490f6 --- /dev/null +++ b/samples/SharpMath.Samples.Polygon.WinForms/SharpMath.Samples.Polygon.WinForms.csproj @@ -0,0 +1,14 @@ + + + + WinExe + net10.0-windows + true + true + + + + + + + From 7417916214b394c7d555b1f4eca40a2bb1ec3bab Mon Sep 17 00:00:00 2001 From: Dominic Beger Date: Sun, 11 Oct 2026 02:42:42 +0200 Subject: [PATCH 11/14] Speed up Polygon.Contains and canvas sampling, avoid a copy when solving Polygon.Contains caches the largest coordinate of the polygon and computes the border tolerance once per call instead of once per edge; the function sampler reuses its buffers, so rendering no longer allocates per frame. --- src/SharpMath.Canvas/FunctionSampler.cs | 78 ++++++++++++------- src/SharpMath.Canvas/ItemRenderer.cs | 6 +- .../Equations/LinearEquationSystem.cs | 2 +- src/SharpMath/Geometry/MatrixMxN.cs | 6 ++ src/SharpMath/Geometry/Polygon.cs | 8 +- .../FunctionSamplerTests.cs | 39 ++++++++-- 6 files changed, 101 insertions(+), 38 deletions(-) diff --git a/src/SharpMath.Canvas/FunctionSampler.cs b/src/SharpMath.Canvas/FunctionSampler.cs index 076e861..8b45b66 100644 --- a/src/SharpMath.Canvas/FunctionSampler.cs +++ b/src/SharpMath.Canvas/FunctionSampler.cs @@ -1,23 +1,45 @@ +using System.Runtime.InteropServices; + namespace SharpMath.Canvas; /// -/// Samples a function across the visible area and splits the graph into polylines at gaps and poles. +/// Samples a function across the visible area and splits the graph into polylines at gaps and poles. The buffers are +/// reused between calls, so sampling does not allocate once they have grown to size. /// -internal static class FunctionSampler +internal sealed class FunctionSampler { private const int MaxRefinementDepth = 6; private const double RefinementPixels = 2; - public static List Sample(Func function, Viewport viewport, CanvasRect guardBand) + private readonly List _points = []; + private readonly List<(int Start, int Length)> _polylines = []; + private int _polylineStart; + + /// + /// Gets the number of polylines produced by the last call to . + /// + public int PolylineCount => _polylines.Count; + + /// + /// Gets a polyline produced by the last call to . The span is valid until the next call. + /// + public ReadOnlySpan GetPolyline(int index) { - var polylines = new List(); - var current = new List(); + var (start, length) = _polylines[index]; + return CollectionsMarshal.AsSpan(_points).Slice(start, length); + } + + public void Sample(Func function, Viewport viewport, CanvasRect guardBand) + { + _points.Clear(); + _polylines.Clear(); + _polylineStart = 0; var columns = (int) Math.Ceiling(viewport.Width); var previousX = viewport.ScreenToWorld(new CanvasPoint(0, 0)).X; var previousY = Evaluate(function, previousX); if (double.IsFinite(previousY)) - current.Add(ToScreen(viewport, guardBand, previousX, previousY)); + _points.Add(ToScreen(viewport, guardBand, previousX, previousY)); for (var column = 1; column <= columns; column++) { @@ -25,33 +47,30 @@ public static List Sample(Func function, Viewport var y = Evaluate(function, x); if (!double.IsFinite(y)) { - Flush(polylines, current); + Flush(); previousX = x; previousY = y; continue; } - if (double.IsFinite(previousY) && !Refine(function, viewport, guardBand, previousX, previousY, x, y, - MaxRefinementDepth, current)) - { - Flush(polylines, current); - } + if (double.IsFinite(previousY) && + !Refine(function, viewport, guardBand, previousX, previousY, x, y, MaxRefinementDepth)) + Flush(); - current.Add(ToScreen(viewport, guardBand, x, y)); + _points.Add(ToScreen(viewport, guardBand, x, y)); previousX = x; previousY = y; } - Flush(polylines, current); - return polylines; + Flush(); } /// /// Adds intermediate samples where the graph is steep. Returns if there is a gap or a pole /// between the two samples; the points before the break have been added in that case. /// - private static bool Refine(Func function, Viewport viewport, CanvasRect guardBand, double x0, - double y0, double x1, double y1, int depth, List output) + private bool Refine(Func function, Viewport viewport, CanvasRect guardBand, double x0, double y0, + double x1, double y1, int depth) { var pixelDistance = Math.Abs(y1 - y0) * viewport.ScaleY; if (pixelDistance <= RefinementPixels) @@ -65,16 +84,17 @@ private static bool Refine(Func function, Viewport viewport, Can // A pole: the jump is larger than the visible area and the midpoint does not lie between the end points. var lower = Math.Min(y0, y1); var upper = Math.Max(y0, y1); - if (pixelDistance > viewport.Height && (ym < lower || ym > upper)) + var midpointBetween = ym >= lower && ym <= upper; + if (pixelDistance > viewport.Height && !midpointBetween) return false; if (depth == 0) - return pixelDistance <= viewport.Height || (ym >= lower && ym <= upper); + return true; - if (!Refine(function, viewport, guardBand, x0, y0, xm, ym, depth - 1, output)) + if (!Refine(function, viewport, guardBand, x0, y0, xm, ym, depth - 1)) return false; - output.Add(ToScreen(viewport, guardBand, xm, ym)); - return Refine(function, viewport, guardBand, xm, ym, x1, y1, depth - 1, output); + _points.Add(ToScreen(viewport, guardBand, xm, ym)); + return Refine(function, viewport, guardBand, xm, ym, x1, y1, depth - 1); } private static double Evaluate(Func function, double x) @@ -95,10 +115,16 @@ private static CanvasPoint ToScreen(Viewport viewport, CanvasRect guardBand, dou return new CanvasPoint(point.X, Math.Clamp(point.Y, guardBand.Y, guardBand.Bottom)); } - private static void Flush(List polylines, List current) + /// + /// Ends the current polyline; single points are dropped. + /// + private void Flush() { - if (current.Count >= 2) - polylines.Add([.. current]); - current.Clear(); + var length = _points.Count - _polylineStart; + if (length >= 2) + _polylines.Add((_polylineStart, length)); + else + _points.RemoveRange(_polylineStart, length); + _polylineStart = _points.Count; } } diff --git a/src/SharpMath.Canvas/ItemRenderer.cs b/src/SharpMath.Canvas/ItemRenderer.cs index 2475286..220f8be 100644 --- a/src/SharpMath.Canvas/ItemRenderer.cs +++ b/src/SharpMath.Canvas/ItemRenderer.cs @@ -9,6 +9,7 @@ internal sealed class ItemRenderer private const double ArrowHalfWidth = 4; private readonly List _polygon = []; + private readonly FunctionSampler _sampler = new(); public void Draw(IDrawingContext context, CanvasItem item, Viewport viewport, CanvasStyle style, CanvasRect guardBand) @@ -17,8 +18,9 @@ public void Draw(IDrawingContext context, CanvasItem item, Viewport viewport, Ca { case FunctionItem function: var stroke = (function.Style ?? style.FunctionStyle).Stroke; - foreach (var polyline in FunctionSampler.Sample(function.Function, viewport, guardBand)) - context.DrawPolyline(polyline, stroke); + _sampler.Sample(function.Function, viewport, guardBand); + for (var i = 0; i < _sampler.PolylineCount; i++) + context.DrawPolyline(_sampler.GetPolyline(i), stroke); break; case VectorItem vector: DrawVector(context, vector, viewport, vector.Style ?? style.VectorStyle, guardBand); diff --git a/src/SharpMath/Equations/LinearEquationSystem.cs b/src/SharpMath/Equations/LinearEquationSystem.cs index dcfe5a7..624fff6 100644 --- a/src/SharpMath/Equations/LinearEquationSystem.cs +++ b/src/SharpMath/Equations/LinearEquationSystem.cs @@ -50,7 +50,7 @@ public double[] Solve() results[row] = Equations[row].Result; } - var matrix = new MatrixMxN(count, count, coefficients); + var matrix = MatrixMxN.WrapRowMajor(count, count, coefficients); if (!matrix.TrySolve(results, out var solution)) throw new EquationNotSolvableException("The linear equation system has no unique solution."); return solution; diff --git a/src/SharpMath/Geometry/MatrixMxN.cs b/src/SharpMath/Geometry/MatrixMxN.cs index 970999a..aeac111 100644 --- a/src/SharpMath/Geometry/MatrixMxN.cs +++ b/src/SharpMath/Geometry/MatrixMxN.cs @@ -458,6 +458,12 @@ public override int GetHashCode() /// if the matrices differ; otherwise, . public static bool operator !=(MatrixMxN? left, MatrixMxN? right) => !(left == right); + /// + /// Creates a matrix that takes ownership of a row-major array without copying it. + /// + internal static MatrixMxN WrapRowMajor(int rowCount, int columnCount, double[] values) => + new(rowCount, columnCount, values); + internal bool TrySolve(ReadOnlySpan rightHandSide, [NotNullWhen(true)] out double[]? solution) { diff --git a/src/SharpMath/Geometry/Polygon.cs b/src/SharpMath/Geometry/Polygon.cs index d039732..739632a 100644 --- a/src/SharpMath/Geometry/Polygon.cs +++ b/src/SharpMath/Geometry/Polygon.cs @@ -15,6 +15,9 @@ public sealed class Polygon : IEquatable { private readonly Point2D[] _points; + // The largest absolute coordinate, used to scale the tolerance of boundary checks. + private readonly double _scale; + /// /// Initializes a new instance of the class. /// @@ -26,6 +29,8 @@ public Polygon(params ReadOnlySpan points) throw new ArgumentException("A polygon needs at least three points.", nameof(points)); _points = points.ToArray(); Points = Array.AsReadOnly(_points); + foreach (var point in _points) + _scale = Math.Max(_scale, Math.Max(Math.Abs(point.X), Math.Abs(point.Y))); } /// @@ -120,11 +125,12 @@ public Point2D Centroid /// if the polygon contains the point; otherwise, . public bool Contains(Point2D point) { + var tolerance = SegmentMath.DistanceTolerance(Math.Max(_scale, SegmentMath.CoordinateScale(point))); var inside = false; for (int i = 0, j = _points.Length - 1; i < _points.Length; j = i++) { Point2D a = _points[j], b = _points[i]; - if (new LineSegment2D(a, b).Contains(point)) + if (SegmentMath.IsOnSegment(point, a, b, tolerance)) return true; // Crossing number test with a horizontal ray to the right. diff --git a/tests/SharpMath.Canvas.Tests/FunctionSamplerTests.cs b/tests/SharpMath.Canvas.Tests/FunctionSamplerTests.cs index 31ad248..95312db 100644 --- a/tests/SharpMath.Canvas.Tests/FunctionSamplerTests.cs +++ b/tests/SharpMath.Canvas.Tests/FunctionSamplerTests.cs @@ -12,11 +12,18 @@ private static Viewport CreateViewport() private static CanvasRect GuardBand(Viewport viewport) => new(-10 * viewport.Width, -10 * viewport.Height, 21 * viewport.Width, 21 * viewport.Height); + private static List Sample(Func function, Viewport viewport) + { + var sampler = new FunctionSampler(); + sampler.Sample(function, viewport, GuardBand(viewport)); + return Enumerable.Range(0, sampler.PolylineCount).Select(i => sampler.GetPolyline(i).ToArray()).ToList(); + } + [Fact] public void ContinuousFunction_IsOnePolyline() { var viewport = CreateViewport(); - var polylines = FunctionSampler.Sample(x => x * x * x, viewport, GuardBand(viewport)); + var polylines = Sample(x => x * x * x, viewport); Assert.Single(polylines); Assert.True(polylines[0].Length >= 401); Assert.All(polylines[0], point => Assert.InRange(point.Y, -3000, 3300)); @@ -27,7 +34,7 @@ public void Poles_BreakTheGraph() { // Regression: the graph used to be connected across asymptotes. var viewport = CreateViewport(); - var polylines = FunctionSampler.Sample(Math.Tan, viewport, GuardBand(viewport)); + var polylines = Sample(Math.Tan, viewport); // tan has poles at ±π/2, ±3π/2, ±5π/2 within x ∈ [−10, 10]. Assert.Equal(7, polylines.Count); @@ -40,24 +47,40 @@ public void Poles_BreakTheGraph() public void NonFiniteValuesAndArithmeticExceptions_LeaveGaps() { var viewport = CreateViewport(); - var sqrt = FunctionSampler.Sample(Math.Sqrt, viewport, GuardBand(viewport)); + var sqrt = Sample(Math.Sqrt, viewport); Assert.Single(sqrt); Assert.All(sqrt[0], point => Assert.True(point.X >= 199)); - var throwing = FunctionSampler.Sample(x => x < 0 ? throw new OverflowException() : 1, viewport, - GuardBand(viewport)); + var throwing = Sample(x => x < 0 ? throw new OverflowException() : 1, viewport); Assert.Single(throwing); - Assert.Empty(FunctionSampler.Sample(_ => double.NaN, viewport, GuardBand(viewport))); - var reciprocal = FunctionSampler.Sample(x => 1 / x, viewport, GuardBand(viewport)); + Assert.Empty(Sample(_ => double.NaN, viewport)); + var reciprocal = Sample(x => 1 / x, viewport); Assert.Equal(2, reciprocal.Count); } + [Fact] + public void Buffers_AreReusedBetweenCalls() + { + var viewport = CreateViewport(); + var sampler = new FunctionSampler(); + sampler.Sample(Math.Tan, viewport, GuardBand(viewport)); + Assert.Equal(7, sampler.PolylineCount); + sampler.Sample(x => x, viewport, GuardBand(viewport)); + Assert.Equal(1, sampler.PolylineCount); + Assert.Equal(401, sampler.GetPolyline(0).Length); + + var before = GC.GetAllocatedBytesForCurrentThread(); + sampler.Sample(Math.Tan, viewport, GuardBand(viewport)); + sampler.Sample(x => x, viewport, GuardBand(viewport)); + Assert.True(GC.GetAllocatedBytesForCurrentThread() - before < 1024); + } + [Fact] public void SteepParts_AreRefined() { var viewport = CreateViewport(); - var polylines = FunctionSampler.Sample(x => Math.Exp(3 * x), viewport, GuardBand(viewport)); + var polylines = Sample(x => Math.Exp(3 * x), viewport); Assert.True(polylines.Sum(polyline => polyline.Length) > 401); } } From 5c9a41f55c5376786c569fa4a71010d683d11220 Mon Sep 17 00:00:00 2001 From: Dominic Beger Date: Sun, 11 Oct 2026 02:42:42 +0200 Subject: [PATCH 12/14] Add BenchmarkDotNet benchmarks and results --- SharpMath.slnx | 3 + benchmarks/Directory.Build.props | 9 ++ .../SharpMath.Benchmarks/CanvasBenchmarks.cs | 39 +++++++ .../ExpressionBenchmarks.cs | 21 ++++ .../GeometryBenchmarks.cs | 106 ++++++++++++++++++ benchmarks/SharpMath.Benchmarks/Program.cs | 6 + .../SharpMath.Benchmarks.csproj | 15 +++ .../SharpMath.Benchmarks/SolverBenchmarks.cs | 33 ++++++ doc/benchmarks.md | 99 ++++++++++++++++ 9 files changed, 331 insertions(+) create mode 100644 benchmarks/Directory.Build.props create mode 100644 benchmarks/SharpMath.Benchmarks/CanvasBenchmarks.cs create mode 100644 benchmarks/SharpMath.Benchmarks/ExpressionBenchmarks.cs create mode 100644 benchmarks/SharpMath.Benchmarks/GeometryBenchmarks.cs create mode 100644 benchmarks/SharpMath.Benchmarks/Program.cs create mode 100644 benchmarks/SharpMath.Benchmarks/SharpMath.Benchmarks.csproj create mode 100644 benchmarks/SharpMath.Benchmarks/SolverBenchmarks.cs create mode 100644 doc/benchmarks.md diff --git a/SharpMath.slnx b/SharpMath.slnx index 37f5af5..7955acf 100644 --- a/SharpMath.slnx +++ b/SharpMath.slnx @@ -1,4 +1,7 @@ + + + diff --git a/benchmarks/Directory.Build.props b/benchmarks/Directory.Build.props new file mode 100644 index 0000000..f3304d8 --- /dev/null +++ b/benchmarks/Directory.Build.props @@ -0,0 +1,9 @@ + + + + + + true + $(NoWarn);CS1591;CA1822;CA1515 + + diff --git a/benchmarks/SharpMath.Benchmarks/CanvasBenchmarks.cs b/benchmarks/SharpMath.Benchmarks/CanvasBenchmarks.cs new file mode 100644 index 0000000..c4aa3d5 --- /dev/null +++ b/benchmarks/SharpMath.Benchmarks/CanvasBenchmarks.cs @@ -0,0 +1,39 @@ +using BenchmarkDotNet.Attributes; +using SharpMath.Canvas; +using SharpMath.Geometry; + +namespace SharpMath.Benchmarks; + +[MemoryDiagnoser] +public class CanvasBenchmarks +{ + private readonly Canvas2D _canvas = new(); + private readonly NullDrawingContext _context = new(); + + [GlobalSetup] + public void Setup() + { + _canvas.AddFunction(x => x * x * x / 10); + _canvas.AddFunction("1.5sin(x)"); + _canvas.AddFunction(Math.Tan); + _canvas.AddVector(new Vector2(4, 3)); + _canvas.AddPolygon(new Polygon(new Point2D(2, -2), new Point2D(6, -2), new Point2D(4, -5))); + } + + [Benchmark] + public void Render800x600() => _canvas.Render(_context, 800, 600); + + private sealed class NullDrawingContext : IDrawingContext + { + public void Clear(Color color) { } + public void DrawLine(CanvasPoint startPoint, CanvasPoint endPoint, Stroke stroke) { } + public void DrawPolyline(ReadOnlySpan points, Stroke stroke) { } + public void DrawPolygon(ReadOnlySpan points, Stroke? stroke, Color? fill) { } + public void DrawRectangle(CanvasRect rect, Stroke? stroke, Color? fill) { } + public void DrawEllipse(CanvasPoint center, double radiusX, double radiusY, Stroke? stroke, Color? fill) { } + public void DrawText(string text, CanvasPoint position, FontSpec font, Color color, TextAlignment alignment) { } + public TextSize MeasureText(string text, FontSpec font) => new(text.Length * 6, 12); + public void PushClip(CanvasRect rect) { } + public void PopClip() { } + } +} diff --git a/benchmarks/SharpMath.Benchmarks/ExpressionBenchmarks.cs b/benchmarks/SharpMath.Benchmarks/ExpressionBenchmarks.cs new file mode 100644 index 0000000..f7c9a21 --- /dev/null +++ b/benchmarks/SharpMath.Benchmarks/ExpressionBenchmarks.cs @@ -0,0 +1,21 @@ +using BenchmarkDotNet.Attributes; +using SharpMath.Expressions; + +namespace SharpMath.Benchmarks; + +[MemoryDiagnoser] +public class ExpressionBenchmarks +{ + private const string Text = "3*4^2+sin(1)/2-7*(2+cos(0))"; + private readonly MathExpression _parsed = MathExpression.Parse("3*x^2+sin(x)/2-7*(2+cos(x))"); + private readonly Func _function = MathExpression.Parse("3*x^2+sin(x)/2-7*(2+cos(x))").ToFunc("x"); + + [Benchmark] + public double ParseAndEvaluate() => MathExpression.Parse(Text).Evaluate(); + + [Benchmark] + public double EvaluateParsed() => _parsed.Evaluate("x", 1.5); + + [Benchmark] + public double EvaluateFunc() => _function(1.5); +} diff --git a/benchmarks/SharpMath.Benchmarks/GeometryBenchmarks.cs b/benchmarks/SharpMath.Benchmarks/GeometryBenchmarks.cs new file mode 100644 index 0000000..c7539c2 --- /dev/null +++ b/benchmarks/SharpMath.Benchmarks/GeometryBenchmarks.cs @@ -0,0 +1,106 @@ +using BenchmarkDotNet.Attributes; +using SharpMath.Geometry; + +namespace SharpMath.Benchmarks; + +[MemoryDiagnoser] +public class VectorBenchmarks +{ + private readonly Vector3 _a = new(1, 2, 3); + private readonly Vector3 _b = new(-2, 0.5, 4); + + [Benchmark] + public Vector3 AddAndScale() => _a + _b * 2; + + [Benchmark] + public Vector3 Normalize() => _a.Normalize(); + + [Benchmark] + public double Length() => _a.Length; + + [Benchmark] + public Vector3 Cross() => _a.Cross(_b); + + [Benchmark] + public bool Equality() => _a == _b; + + [Benchmark] + public bool IsZero() => _a.IsZero; + + [Benchmark] + public int HashCode() => _a.GetHashCode(); + + [Benchmark] + public bool IsParallelTo() => _a.IsParallelTo(_b); +} + +[MemoryDiagnoser] +public class MatrixBenchmarks +{ + private readonly Matrix2x2 _m2 = new(8, 3, 4, 2); + private readonly Matrix3x3 _m3 = new(2, 5, 2, 3, -3, 1, 1, 4, -4); + private readonly Matrix4x4 _m4 = new(1, 0, 0, 1, 1, 2, 3, 2, 2, 3, 4, 0, 1, 2, -1, -2); + private readonly Matrix4x4 _rotation = Matrix4x4.Rotation(0.3, 0.5, 0.7); + private readonly Vector3 _vector = new(1, 2, 3); + + [Benchmark] + public double Determinant2x2() => _m2.Determinant; + + [Benchmark] + public double Determinant3x3() => _m3.Determinant; + + [Benchmark] + public double Determinant4x4() => _m4.Determinant; + + [Benchmark] + public Matrix2x2 Adjugate2x2() => _m2.Adjugate(); + + [Benchmark] + public Matrix3x3 Adjugate3x3() => _m3.Adjugate(); + + [Benchmark] + public Matrix4x4 Adjugate4x4() => _m4.Adjugate(); + + [Benchmark] + public Matrix2x2 Inverse2x2() => _m2.Inverse(); + + [Benchmark] + public Matrix3x3 Inverse3x3() => _m3.Inverse(); + + [Benchmark] + public Matrix4x4 Inverse4x4() => _m4.Inverse(); + + [Benchmark] + public bool IsSingular4x4() => _m4.IsSingular; + + [Benchmark] + public Matrix3x3 Multiply3x3() => _m3 * _m3; + + [Benchmark] + public Matrix4x4 Multiply4x4() => _m4 * _rotation; + + [Benchmark] + public Vector3 TransformVector() => _rotation * _vector; + + [Benchmark] + public bool Equality4x4() => _m4 == _rotation; + + [Benchmark] + public int HashCode4x4() => _m4.GetHashCode(); + + [Benchmark] + public bool IsOrthogonal4x4() => _rotation.IsOrthogonal; +} + +[MemoryDiagnoser] +public class PolygonBenchmarks +{ + private readonly Polygon _polygon = new(Enumerable.Range(0, 64) + .Select(i => new Point2D(Math.Cos(i * Math.Tau / 64) * (i % 2 == 0 ? 10 : 7), Math.Sin(i * Math.Tau / 64) * (i % 2 == 0 ? 10 : 7)))); + + [Benchmark] + public bool Contains() => _polygon.Contains(new Point2D(1, 2)); + + [Benchmark] + public double Area() => _polygon.Area; +} diff --git a/benchmarks/SharpMath.Benchmarks/Program.cs b/benchmarks/SharpMath.Benchmarks/Program.cs new file mode 100644 index 0000000..f9d6cf3 --- /dev/null +++ b/benchmarks/SharpMath.Benchmarks/Program.cs @@ -0,0 +1,6 @@ +// Run all benchmarks: dotnet run -c Release --project benchmarks/SharpMath.Benchmarks -- --filter "*" +// See doc/benchmarks.md for results and the comparison with SharpMath 1.x. + +using BenchmarkDotNet.Running; + +BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); diff --git a/benchmarks/SharpMath.Benchmarks/SharpMath.Benchmarks.csproj b/benchmarks/SharpMath.Benchmarks/SharpMath.Benchmarks.csproj new file mode 100644 index 0000000..d0915cc --- /dev/null +++ b/benchmarks/SharpMath.Benchmarks/SharpMath.Benchmarks.csproj @@ -0,0 +1,15 @@ + + + + Exe + + + + + + + + + + + diff --git a/benchmarks/SharpMath.Benchmarks/SolverBenchmarks.cs b/benchmarks/SharpMath.Benchmarks/SolverBenchmarks.cs new file mode 100644 index 0000000..941f619 --- /dev/null +++ b/benchmarks/SharpMath.Benchmarks/SolverBenchmarks.cs @@ -0,0 +1,33 @@ +using BenchmarkDotNet.Attributes; +using SharpMath.Equations; +using SharpMath.Geometry; + +namespace SharpMath.Benchmarks; + +[MemoryDiagnoser] +public class LinearSystemBenchmarks +{ + private MatrixMxN _matrix = null!; + private double[] _rightHandSide = null!; + private LinearEquationSystem _system = null!; + + [Params(3, 10, 50)] + public int Size { get; set; } + + [GlobalSetup] + public void Setup() + { + var random = new Random(42); + _system = new LinearEquationSystem(Enumerable.Range(0, Size).Select(row => + new LinearEquation(Enumerable.Range(0, Size).Select(column => + column == row ? Size + random.NextDouble() : random.NextDouble()), random.NextDouble()))); + _matrix = new MatrixMxN(Size, Size, _system.Equations.SelectMany(equation => equation.Coefficients).ToArray()); + _rightHandSide = _system.Equations.Select(equation => equation.Result).ToArray(); + } + + [Benchmark] + public double[] Solve() => _system.Solve(); + + [Benchmark] + public double[] MatrixSolve() => _matrix.Solve(_rightHandSide); +} diff --git a/doc/benchmarks.md b/doc/benchmarks.md new file mode 100644 index 0000000..e16f913 --- /dev/null +++ b/doc/benchmarks.md @@ -0,0 +1,99 @@ +# Benchmarks + +Measured with [BenchmarkDotNet](https://benchmarkdotnet.org/) 0.15.8 on 2026-10-11: + +``` +Linux Ubuntu 22.04.5 LTS, 13th Gen Intel Core i7-13700H (laptop), 20 logical cores +.NET SDK 10.0.401, .NET 10.0.12, X64 RyuJIT x86-64-v3 +``` + +**1.x** is the last 1.x commit (`66339fd`) compiled unchanged for `net10.0`, measured with equivalent benchmarks. The 1.x +sources are not part of the repository, so that column cannot be reproduced from this branch. **2.0** is this branch: + +```shell +dotnet run -c Release --project benchmarks/SharpMath.Benchmarks -- --filter "*" +``` + +> **How to read the numbers.** The machine is a laptop with aggressive power management. Separate runs of the same +> benchmark varied by up to ±30 %, and one run that coincided with throttling was three times slower. The 10× to +> 1000× ratios below are robust. Where 1.x and 2.0 are close (polygon, parser), the numbers come from the default job, +> with 1.x and 2.0 run alternately twice; the averages are shown. The other rows come from `--job short` runs. +> Results below about 1 ns mean the JIT inlined the operation completely; they cannot be resolved more precisely. + +## Matrices + +| Benchmark | 1.x mean | 1.x allocated | 2.0 mean | 2.0 allocated | +|---|---:|---:|---:|---:| +| `Matrix2x2.Determinant` | 4.8 ns | 48 B | 0.3 ns | – | +| `Matrix3x3.Determinant` | 336 ns | 232 B | 0.3 ns | – | +| `Matrix4x4.Determinant` | 1,648 ns | 1,072 B | 2.7 ns | – | +| `Matrix2x2.Adjugate` | 111 ns | 288 B | 0.3 ns | – | +| `Matrix3x3.Adjugate` | 455 ns | 1,224 B | 1.6 ns | – | +| `Matrix4x4.Adjugate` | 5,344 ns | 6,016 B | 22 ns | – | +| `Matrix2x2.Inverse` ¹ | 140 ns | 384 B | 17 ns | – | +| `Matrix3x3.Inverse` ¹ | 493 ns | 1,584 B | 43 ns | – | +| `Matrix4x4.Inverse` ¹ | 946 ns | 3,744 B | 121 ns | – | +| `Matrix4x4.IsSingular` ² | 1,566 ns | 1,072 B | 87 ns | – | +| `Matrix3x3 * Matrix3x3` | 253 ns | 176 B | 4.1 ns | – | +| `Matrix4x4 * Matrix4x4` | 535 ns | 288 B | 23 ns | – | +| `Matrix4x4 * Vector3` | 177 ns | 288 B | 2.4 ns | – | +| `Matrix4x4 ==` | 42 ns | 608 B | 3.2 ns | – | +| `Matrix4x4.GetHashCode` | 47 ns | – | 40 ns | – | +| `Matrix4x4.IsOrthogonal` | 668 ns | 896 B | 57 ns | – | + +¹ In 1.x, `Inverse` returned the identity matrix instead of the inverse (see the CHANGELOG), so those numbers measure +a wrong result. In 2.0, `Inverse` also checks whether the matrix is singular. + +² 1.x compared the determinant with an absolute epsilon. 2.0 runs a pivoted LU decomposition, which also gives +correct results for badly scaled matrices. + +## Vectors + +| Benchmark | 1.x mean | 1.x allocated | 2.0 mean | 2.0 allocated | +|---|---:|---:|---:|---:| +| `a + b * 2` (`Vector3`) | 20 ns | – | 0.3 ns | – | +| `Vector3.Normalize` | 101 ns | – | 6.8 ns | – | +| `Vector3.Length` (`Magnitude`) | 31 ns | – | 0.3 ns | – | +| `Vector3 ==` | 24 ns | 192 B | 0.3 ns | – | +| `Vector3.IsZero` | 14 ns | 96 B | < 0.1 ns | – | +| `Vector3.GetHashCode` | 1.2 ns | – | 2.3 ns | – | +| `Vector3.IsParallelTo` (`CheckForParallelism`) ³ | 30 ns | 192 B | 18 ns | – | + +³ 1.x divided the components and was wrong for vectors with zero components. 2.0 normalizes both vectors, which +makes the result independent of their lengths. + +## Geometry and equations + +| Benchmark | 1.x mean | 1.x allocated | 2.0 mean | 2.0 allocated | +|---|---:|---:|---:|---:| +| `Polygon.Contains` (64 vertices) | 416 ns | 3,152 B | 322 ns | – | +| `Polygon.Area` (64 vertices) | 84 ns | – | 89 ns | – | +| `LinearEquationSystem.Solve`, n = 3 | 174 ns | 360 B | 139 ns | 432 B | +| `LinearEquationSystem.Solve`, n = 10 | 1,916 ns | 1,200 B | 945 ns | 2,080 B | +| `LinearEquationSystem.Solve`, n = 50 | 183,208 ns | 21,040 B | 50,521 ns | 41,600 B | +| `MatrixMxN.Solve`, n = 50 | n/a | | 47,256 ns | 21,152 B | + +In 2.0, `Polygon.Contains` also treats points on the border consistently as inside, and `Solve` uses partial +pivoting for numerical stability. `LinearEquationSystem.Solve` copies the coefficients into a matrix first, which +accounts for the extra allocation compared with `MatrixMxN.Solve`. + +## Expressions + +| Benchmark | 1.x mean | 1.x allocated | 2.0 mean | 2.0 allocated | +|---|---:|---:|---:|---:| +| Parse and evaluate `3*4^2+sin(1)/2-7*(2+cos(0))` | 2,013 ns | 3,272 B | 1,301 ns | 3,104 B | +| Evaluate a parsed expression with a variable | n/a ⁴ | | 51 ns | – | +| Call `ToFunc("x")` delegate | n/a ⁴ | | 51 ns | – | + +⁴ 1.x had no variables and no way to evaluate a parsed expression again; plotting `f(x)` from a string meant parsing +it once per point. + +## Canvas + +| Benchmark | 2.0 mean | 2.0 allocated | +|---|---:|---:| +| `Canvas2D.Render` into a no-op renderer, 800×600, three functions (including `tan`), a vector and a polygon | 375 µs | 2.2 KB | + +The remaining allocations are mostly the label strings and the clipped polygon. The 1.x canvas drew directly with +GDI+ inside a WinForms control and cannot be measured without a window. Its function sampling cached every sampled +value forever and allocated new GDI objects on every paint. From c423e353bebe96e2af0423ce18f9027b825d3c47 Mon Sep 17 00:00:00 2001 From: Dominic Beger Date: Sun, 11 Oct 2026 02:43:33 +0200 Subject: [PATCH 13/14] Support with-expressions on vectors, points, matrices and segments --- src/SharpMath/Geometry/LineSegment2D.cs | 4 +-- src/SharpMath/Geometry/Matrix2x2.cs | 8 ++--- src/SharpMath/Geometry/Matrix3x3.cs | 18 +++++------ src/SharpMath/Geometry/Matrix4x4.cs | 32 +++++++++---------- src/SharpMath/Geometry/Point2D.cs | 4 +-- src/SharpMath/Geometry/Point3D.cs | 6 ++-- src/SharpMath/Geometry/Vector2.cs | 4 +-- src/SharpMath/Geometry/Vector3.cs | 6 ++-- src/SharpMath/Geometry/Vector4.cs | 8 ++--- .../Geometry/ReviewRegressionTests.cs | 6 ++-- .../SharpMath.Tests/Geometry/Vector3Tests.cs | 17 ++++++++++ 11 files changed, 65 insertions(+), 48 deletions(-) diff --git a/src/SharpMath/Geometry/LineSegment2D.cs b/src/SharpMath/Geometry/LineSegment2D.cs index 11e039d..376f6eb 100644 --- a/src/SharpMath/Geometry/LineSegment2D.cs +++ b/src/SharpMath/Geometry/LineSegment2D.cs @@ -25,12 +25,12 @@ public LineSegment2D(Point2D start, Point2D end) /// /// Gets the start point. /// - public Point2D Start { get; } + public Point2D Start { get; init; } /// /// Gets the end point. /// - public Point2D End { get; } + public Point2D End { get; init; } /// /// Gets the vector from to . diff --git a/src/SharpMath/Geometry/Matrix2x2.cs b/src/SharpMath/Geometry/Matrix2x2.cs index 04be2c5..04875fa 100644 --- a/src/SharpMath/Geometry/Matrix2x2.cs +++ b/src/SharpMath/Geometry/Matrix2x2.cs @@ -36,22 +36,22 @@ public Matrix2x2(Vector2 row1, Vector2 row2) : this(row1.X, row1.Y, row2.X, row2 /// /// Gets the entry in row 1, column 1. /// - public double M11 { get; } + public double M11 { get; init; } /// /// Gets the entry in row 1, column 2. /// - public double M12 { get; } + public double M12 { get; init; } /// /// Gets the entry in row 2, column 1. /// - public double M21 { get; } + public double M21 { get; init; } /// /// Gets the entry in row 2, column 2. /// - public double M22 { get; } + public double M22 { get; init; } /// /// Gets the zero matrix. diff --git a/src/SharpMath/Geometry/Matrix3x3.cs b/src/SharpMath/Geometry/Matrix3x3.cs index 1e20c94..3ad1d81 100644 --- a/src/SharpMath/Geometry/Matrix3x3.cs +++ b/src/SharpMath/Geometry/Matrix3x3.cs @@ -49,47 +49,47 @@ public Matrix3x3(Vector3 row1, Vector3 row2, Vector3 row3) /// /// Gets the entry in row 1, column 1. /// - public double M11 { get; } + public double M11 { get; init; } /// /// Gets the entry in row 1, column 2. /// - public double M12 { get; } + public double M12 { get; init; } /// /// Gets the entry in row 1, column 3. /// - public double M13 { get; } + public double M13 { get; init; } /// /// Gets the entry in row 2, column 1. /// - public double M21 { get; } + public double M21 { get; init; } /// /// Gets the entry in row 2, column 2. /// - public double M22 { get; } + public double M22 { get; init; } /// /// Gets the entry in row 2, column 3. /// - public double M23 { get; } + public double M23 { get; init; } /// /// Gets the entry in row 3, column 1. /// - public double M31 { get; } + public double M31 { get; init; } /// /// Gets the entry in row 3, column 2. /// - public double M32 { get; } + public double M32 { get; init; } /// /// Gets the entry in row 3, column 3. /// - public double M33 { get; } + public double M33 { get; init; } /// /// Gets the zero matrix. diff --git a/src/SharpMath/Geometry/Matrix4x4.cs b/src/SharpMath/Geometry/Matrix4x4.cs index 1e53d91..68f7f82 100644 --- a/src/SharpMath/Geometry/Matrix4x4.cs +++ b/src/SharpMath/Geometry/Matrix4x4.cs @@ -64,82 +64,82 @@ public Matrix4x4(Vector4 row1, Vector4 row2, Vector4 row3, Vector4 row4) /// /// Gets the entry in row 1, column 1. /// - public double M11 { get; } + public double M11 { get; init; } /// /// Gets the entry in row 1, column 2. /// - public double M12 { get; } + public double M12 { get; init; } /// /// Gets the entry in row 1, column 3. /// - public double M13 { get; } + public double M13 { get; init; } /// /// Gets the entry in row 1, column 4. /// - public double M14 { get; } + public double M14 { get; init; } /// /// Gets the entry in row 2, column 1. /// - public double M21 { get; } + public double M21 { get; init; } /// /// Gets the entry in row 2, column 2. /// - public double M22 { get; } + public double M22 { get; init; } /// /// Gets the entry in row 2, column 3. /// - public double M23 { get; } + public double M23 { get; init; } /// /// Gets the entry in row 2, column 4. /// - public double M24 { get; } + public double M24 { get; init; } /// /// Gets the entry in row 3, column 1. /// - public double M31 { get; } + public double M31 { get; init; } /// /// Gets the entry in row 3, column 2. /// - public double M32 { get; } + public double M32 { get; init; } /// /// Gets the entry in row 3, column 3. /// - public double M33 { get; } + public double M33 { get; init; } /// /// Gets the entry in row 3, column 4. /// - public double M34 { get; } + public double M34 { get; init; } /// /// Gets the entry in row 4, column 1. /// - public double M41 { get; } + public double M41 { get; init; } /// /// Gets the entry in row 4, column 2. /// - public double M42 { get; } + public double M42 { get; init; } /// /// Gets the entry in row 4, column 3. /// - public double M43 { get; } + public double M43 { get; init; } /// /// Gets the entry in row 4, column 4. /// - public double M44 { get; } + public double M44 { get; init; } /// /// Gets the zero matrix. diff --git a/src/SharpMath/Geometry/Point2D.cs b/src/SharpMath/Geometry/Point2D.cs index 1b1fc34..ae248ab 100644 --- a/src/SharpMath/Geometry/Point2D.cs +++ b/src/SharpMath/Geometry/Point2D.cs @@ -25,12 +25,12 @@ public Point2D(double x, double y) /// /// Gets the X coordinate. /// - public double X { get; } + public double X { get; init; } /// /// Gets the Y coordinate. /// - public double Y { get; } + public double Y { get; init; } /// /// Gets the origin (0, 0). diff --git a/src/SharpMath/Geometry/Point3D.cs b/src/SharpMath/Geometry/Point3D.cs index 99e3818..4e95907 100644 --- a/src/SharpMath/Geometry/Point3D.cs +++ b/src/SharpMath/Geometry/Point3D.cs @@ -27,17 +27,17 @@ public Point3D(double x, double y, double z) /// /// Gets the X coordinate. /// - public double X { get; } + public double X { get; init; } /// /// Gets the Y coordinate. /// - public double Y { get; } + public double Y { get; init; } /// /// Gets the Z coordinate. /// - public double Z { get; } + public double Z { get; init; } /// /// Gets the origin (0, 0, 0). diff --git a/src/SharpMath/Geometry/Vector2.cs b/src/SharpMath/Geometry/Vector2.cs index e5dac9f..26a2533 100644 --- a/src/SharpMath/Geometry/Vector2.cs +++ b/src/SharpMath/Geometry/Vector2.cs @@ -33,12 +33,12 @@ public Vector2(Point2D point) : this(point.X, point.Y) /// /// Gets the X component. /// - public double X { get; } + public double X { get; init; } /// /// Gets the Y component. /// - public double Y { get; } + public double Y { get; init; } /// /// Gets the vector (0, 0). diff --git a/src/SharpMath/Geometry/Vector3.cs b/src/SharpMath/Geometry/Vector3.cs index 2c8deaf..6bafe7a 100644 --- a/src/SharpMath/Geometry/Vector3.cs +++ b/src/SharpMath/Geometry/Vector3.cs @@ -45,17 +45,17 @@ public Vector3(Point3D point) : this(point.X, point.Y, point.Z) /// /// Gets the X component. /// - public double X { get; } + public double X { get; init; } /// /// Gets the Y component. /// - public double Y { get; } + public double Y { get; init; } /// /// Gets the Z component. /// - public double Z { get; } + public double Z { get; init; } /// /// Gets the vector (0, 0, 0). diff --git a/src/SharpMath/Geometry/Vector4.cs b/src/SharpMath/Geometry/Vector4.cs index 5935dd4..55cab43 100644 --- a/src/SharpMath/Geometry/Vector4.cs +++ b/src/SharpMath/Geometry/Vector4.cs @@ -38,22 +38,22 @@ public Vector4(Vector3 xyz, double w) : this(xyz.X, xyz.Y, xyz.Z, w) /// /// Gets the X component. /// - public double X { get; } + public double X { get; init; } /// /// Gets the Y component. /// - public double Y { get; } + public double Y { get; init; } /// /// Gets the Z component. /// - public double Z { get; } + public double Z { get; init; } /// /// Gets the W component. /// - public double W { get; } + public double W { get; init; } /// /// Gets the vector (0, 0, 0, 0). diff --git a/tests/SharpMath.Tests/Geometry/ReviewRegressionTests.cs b/tests/SharpMath.Tests/Geometry/ReviewRegressionTests.cs index c65333d..235edf3 100644 --- a/tests/SharpMath.Tests/Geometry/ReviewRegressionTests.cs +++ b/tests/SharpMath.Tests/Geometry/ReviewRegressionTests.cs @@ -38,7 +38,7 @@ public void SingularMatrices_AreStillDetected() { Assert.True(new Matrix4x4(1, 2, 3, 4, 2, 4, 6, 8, 1, 1, 1, 1, 0, 1, 0, 1).IsSingular); Assert.True(new Matrix3x3(1, 2, 3, 4, 5, 6, 7, 8, 9).IsSingular); - Assert.True(new Matrix4x4(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0).IsSingular); + Assert.True((Matrix4x4.Identity with { M44 = 0 }).IsSingular); Assert.True(new Matrix2x2(double.NaN, 0, 0, 1).IsSingular); } @@ -137,8 +137,8 @@ public void ApproximateEqualityOverloads() Assert.True(Line3D.FromPointAndDirection(Point3D.Origin, Vector3.UnitX) .ApproximatelyEquals(Line3D.FromPointAndDirection(new Point3D(0, 0.001, 0), Vector3.UnitX), 0.01, 0)); var segment = new LineSegment2D(Point2D.Origin, new Point2D(1, 1)); - Assert.True(segment.ApproximatelyEquals(new LineSegment2D(Point2D.Origin, new Point2D(1.001, 1)), 0.01, 0)); - Assert.False(segment.ApproximatelyEquals(new LineSegment2D(Point2D.Origin, new Point2D(1.001, 1)))); + Assert.True(segment.ApproximatelyEquals(segment with { End = new Point2D(1.001, 1) }, 0.01, 0)); + Assert.False(segment.ApproximatelyEquals(segment with { End = new Point2D(1.001, 1) })); var box = new BoundingBox2D(0, 0, 1, 1); Assert.True(box.ApproximatelyEquals(new BoundingBox2D(0, 0, 1 + 1e-12, 1))); Assert.True(box.ApproximatelyEquals(new BoundingBox2D(0, 0, 1.05, 1), 0.1, 0)); diff --git a/tests/SharpMath.Tests/Geometry/Vector3Tests.cs b/tests/SharpMath.Tests/Geometry/Vector3Tests.cs index e0688e6..a6f719d 100644 --- a/tests/SharpMath.Tests/Geometry/Vector3Tests.cs +++ b/tests/SharpMath.Tests/Geometry/Vector3Tests.cs @@ -105,6 +105,23 @@ public void Transform_AppliesMatricesAndSinglePerspectiveDivide() Assert.Equal(new Vector3(1, 1, 1), Vector3.Transform(new Vector3(2, 2, 2), halve)); } + [Fact] + public void With_CreatesModifiedCopies() + { + var vector = new Vector3(1, 2, 3); + Assert.Equal(new Vector3(1, 2, 0), vector with { Z = 0 }); + Assert.Equal(new Vector3(1, 2, 3), vector); + Assert.Equal(new Point3D(0, 2, 3), new Point3D(1, 2, 3) with { X = 0 }); + Assert.Equal(new Vector4(1, 2, 3, 1), new Vector4(1, 2, 3, 4) with { W = 1 }); + Assert.Equal(new Matrix4x4(1, 0, 0, 5, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1), Matrix4x4.Identity with { M14 = 5 }); + Assert.Equal(new Matrix3x3(9, 0, 0, 0, 1, 0, 0, 0, 1), Matrix3x3.Identity with { M11 = 9 }); + Assert.Equal(new Matrix2x2(1, 2, 0, 1), Matrix2x2.Identity with { M12 = 2 }); + Assert.Equal(new Vector2(0, 1), Vector2.One with { X = 0 }); + Assert.Equal(new Point2D(1, 0), new Point2D(1, 1) with { Y = 0 }); + var segment = new LineSegment2D(Point2D.Origin, new Point2D(1, 1)) with { End = new Point2D(2, 2) }; + Assert.Equal(new Point2D(2, 2), segment.End); + } + [Fact] public void FromHomogeneous_DividesByW() { From 9a2a68dc15661e1a97492108b4319feaa1b62ced Mon Sep 17 00:00:00 2001 From: Dominic Beger Date: Sun, 11 Oct 2026 02:43:33 +0200 Subject: [PATCH 14/14] Rewrite README, add CHANGELOG with migration guide and CI on Windows, Linux and macOS The outdated 1.x screenshots in the repository root are replaced by the images the export sample generates in doc/images. --- .github/workflows/ci.yml | 57 +++++++++ .gitignore | 5 + CHANGELOG.md | 255 +++++++++++++++++++++++++++++++++++++++ Canvas2D.png | Bin 24965 -> 0 bytes Canvas2DFunctions.png | Bin 8852 -> 0 bytes README.md | 232 ++++++++++++++++++++++++++++++++--- SharpMath.png | Bin 531216 -> 0 bytes 7 files changed, 533 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CHANGELOG.md delete mode 100644 Canvas2D.png delete mode 100644 Canvas2DFunctions.png delete mode 100644 SharpMath.png diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4fee963 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + push: + branches: [master, "refactor/**"] + pull_request: + +permissions: + contents: read + +jobs: + build: + name: Build and test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + + - name: Restore + run: dotnet restore SharpMath.slnx + + # Builds everything, including the WinForms projects (EnableWindowsTargeting), samples and benchmarks. + - name: Build + run: dotnet build SharpMath.slnx --configuration Release --no-restore + + # The WinForms tests only run on Windows; elsewhere their project is not a test project. + - name: Test + run: dotnet test --solution SharpMath.slnx --configuration Release --no-build + + - name: Collect coverage + if: runner.os == 'Linux' + run: | + dotnet test --project tests/SharpMath.Tests --configuration Release --no-build --coverage --coverage-output-format cobertura --coverage-output core.cobertura.xml --results-directory coverage + dotnet test --project tests/SharpMath.Canvas.Tests --configuration Release --no-build --coverage --coverage-output-format cobertura --coverage-output canvas.cobertura.xml --results-directory coverage + + - name: Check coverage (at least 90 % line coverage) + if: runner.os == 'Linux' + run: dotnet run eng/CheckCoverage.cs -- 90 coverage/core.cobertura.xml=SharpMath coverage/canvas.cobertura.xml=SharpMath.Canvas + + - name: Upload coverage + if: runner.os == 'Linux' && always() + uses: actions/upload-artifact@v7 + with: + name: coverage + path: coverage/*.cobertura.xml + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 0808c4a..b854af4 100644 --- a/.gitignore +++ b/.gitignore @@ -480,3 +480,8 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp + +# SharpMath +/coverage/ +/output/ +/samples/SharpMath.Samples.Export/output/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cc03814 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,255 @@ +# Changelog + +All notable changes to SharpMath are documented in this file. The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/). + +## [2.0.0] - Unreleased + +SharpMath 2.0 is a rewrite for .NET 10. Almost every public type changed. The sections below double as a migration +guide from 1.x. + +### Breaking changes + +#### Platform and packaging + +- Targets `net10.0` (C# 14) instead of .NET Framework 4.0. The WinForms parts target `net10.0-windows`. +- The library is split into five assemblies: `SharpMath`, `SharpMath.Canvas`, `SharpMath.Canvas.Skia`, + `SharpMath.Canvas.WinForms` and `SharpMath.Canvas.Avalonia`. The core no longer references WinForms or System.Drawing. +- Removed `[Serializable]`, `[assembly: ComVisible(true)]` and the GUID. `BinaryFormatter` no longer exists in .NET. +- Namespaces: + - `SharpMath.Presentation` → `SharpMath.Canvas` + - `SharpMath.Equations.Exceptions` → `SharpMath.Equations` + - `SharpMath.Expressions.Exceptions` → `SharpMath.Expressions` + +#### Value types are immutable + +- `Vector2/3/4`, `Point2D/3D`, `Matrix2x2/3x3/4x4`, `Line2D`, `Line3D`, `LineSegment2D`, `BoundingBox2D` and + `ProjectionData` are `readonly struct`s. Settable properties and indexers are gone. Use the constructors or factory + methods instead of `new Matrix2x2 { [0, 0] = 8, … }`. Vectors, points, matrices and segments support `with` + (`v with { X = 1 }`, `m with { M14 = 5 }`); lines, bounding boxes and projection data validate or normalize their + values and are created through their constructors or factories only. +- Indexers take `int` instead of `uint` and are read-only. They throw `ArgumentOutOfRangeException` (was + `IndexOutOfRangeException`), and the 2D indexers of matrices now check row and column separately. +- Vectors, points and matrices no longer implement `IEnumerable`. Use the indexers or the properties. +- `Polygon` is immutable and no longer implements `IEnumerable`. `Points` is a `ReadOnlyCollection`. +- `LinearEquation` and `LinearEquationSystem` are immutable. `LinearEquation` has a single constructor, + `new LinearEquation([a₁, a₂, …], result)`. The parameterless constructors and `LinearEquation(double result, params + double[] coefficients)` were removed. +- `Polygon`, `LinearEquation` and `LinearEquationSystem` are `sealed`. +- `Dimension`, `RowCount` and `ColumnCount` are `int`. +- `Equals(object)` of a vector no longer accepts other `IVector` types. + +#### Renamed and reshaped members + +| 1.x | 2.0 | +|---|---| +| `Magnitude`, `SquareMagnitude` | `Length`, `LengthSquared` | +| `DotProduct` | `Dot` | +| `VectorProduct` | `Cross` (`Vector3` → `Vector3`, `Vector2` → `double`) | +| `CheckForOrthogonality`, `CheckForOrthonormality`, `CheckForParallelism` | `IsOrthogonalTo`, `IsOrthonormalTo`, `IsParallelTo` | +| `Vector2.AreOrthogonal/AreOrthonormal/AreParallel(a, b)` | `a.IsOrthogonalTo(b)` etc. | +| `Vector2.Area`, `Vector3.Area` | `ParallelogramArea` | +| `Vector2(Point2D bottom, Point2D tip)` | `tip - bottom` | +| `Point2D.PositionVector`, `new Point2D(Vector2)` | `point.ToVector()`, `vector.ToPoint()` | +| `Vector2.Divide(vector, scalar)` | `vector / scalar` | +| `vector.Transform(matrix)`, `Vector2.Transform(matrix, vector)` | `matrix * vector` or `Vector2.Transform(vector, matrix)` | +| `Vector3.FromVector(IVector)`, `Convert()` | constructors, e.g. `new Vector3(v2, z)`, `new Vector4(v3, w)`, `Vector3.FromHomogeneous(v4)` | +| `vector.Negate()` | `-vector` | +| `Point2D + Point2D` | `Point2D + Vector2` (point − point is a vector) | +| `Vector2 * Vector2` (dot product) | `Dot` | +| `Matrix3x3/4x4.Scalation` | `Scaling` | +| `Inverse`, `Transpose`, `Adjugate`, `CofactorMatrix` (properties) | `Inverse()`, `Transpose()`, `Adjugate()`, `Cofactors()`; plus `TryInvert(out m)` | +| `Negate` (matrix property) | unary `-` | +| `IsTriangle` | `IsTriangular` | +| `IsAntiSymmetric` | `IsSkewSymmetric` (they were identical) | +| `Matrix4x4.View` | `Matrix4x4.LookAt` | +| `vector * matrix` | `matrix * vector` (column vectors, see below) | +| `new Line2D(slope, offset)`, `Offset` | `Line2D.FromSlopeIntercept(slope, yIntercept)`, `YIntercept` | +| `new Line2D(pointA, pointB)` | `Line2D.FromPoints(pointA, pointB)` | +| `Line2D.GetPoint(x)` | `SolveForY(x)` | +| `IsPointOnLine` | `Contains` | +| `IntersectsWith`, `AreIntersecting`, `GetIntersectionPoint` | `Intersect` returning `LineIntersection2D`/`LineIntersection3D` with a `Kind` | +| `Line2D.AreParallel(a, b)` | `a.IsParallelTo(b)` | +| `new Line3D(point, direction)` | `Line3D.FromPointAndDirection(point, direction)` | +| `Line3D.GetPoint(t)` | `PointAt(distance)` (the direction is now a unit vector) | +| `Polygon.ContainsPoint` | `Polygon.Contains` | +| `Polygon.Center` | `Polygon.Centroid` | +| `Polygon.BoundingBox` (`RectangleF`) | `BoundingBox2D` | +| `ProjectionData(Size, float aspect, float fov, float near, float far)` | `ProjectionData(double width, double height, double aspectRatio, double fieldOfView, double nearPlane, double farPlane)` | +| `new Parser(text).Evaluate()` | `MathExpression.Parse(text).Evaluate()` | +| `FloatingNumber`, `FloatingNumberExtensions` | `Tolerance` | +| `MathHelper.Pi`, `E`, `TwoPi`, `DegreesToRadians`, … | `Math.PI`, `Math.E`, `Math.Tau`, `double.DegreesToRadians`, `double.RadiansToDegrees` | +| `EquationNotSolvableException` for singular `Inverse` | `InvalidOperationException` | +| `DimensionException` | `ArgumentException` | + +#### Removed + +- The unfinished `SharpMath.Analysis` namespace (it was never compiled), all commented-out code, and unused helpers. +- `Matrix1x1`, `Matrix1x3`, `Matrix3x1`, `Matrix1x4`, `Matrix4x1`, `ISquareMatrix`, `MatrixUtils`, `VectorUtils`, + `MatrixEnumerator`, `AsHorizontalMatrix`, `AsVerticalMatrix`. `IVector`, `IMatrix` and `ISquareMatrix` remain as + read-only interfaces. +- `Algorithms` (shunting yard), `Token`, `Token`, `TokenType`, `TokenEx`, `CharEx` and the `protected static` + operator dictionaries. Extend the parser with an `ExpressionContext` instead. +- `MathHelper`. + +#### Canvas + +- The canvas is no longer a WinForms control. `SharpMath.Canvas.Canvas2D` is a platform-neutral model, and + `Canvas2DControl` (WinForms) and `Canvas2DView` (Avalonia) display it through their `Canvas` property. +- `Functions` (`BindingList`) and `Vectors` (`BindingList`) became `Items` + (`ObservableCollection`) with `AddFunction`, `AddVector`, `AddPoint`, `AddLine`, `AddSegment` and + `AddPolygon`. `FunctionWrapper` was removed. +- `GridOrigin`, `GridSize` and `GridScale` became `Viewport` (`Origin`, `ScaleX`, `ScaleY`, `SetOrigin`, `SetScale`, + `Pan`, `ZoomAt`). +- `TrackingLines` and `ValueIndicator` became `ShowTrackingLines` and `ShowValueIndicator`. +- Colors, fonts and line widths are no longer hard-coded but configured through `CanvasStyle` and `ItemStyle`. + +### Behaviour changes + +These follow the rule "mathematical definition first, then `System.Numerics`, then least surprise". + +- **Matrix convention:** column vectors everywhere (`v' = M · v`). `Matrix4x4.LookAt`, `World` and + `PerspectiveFieldOfView` used a row-vector (Direct3D) layout and are now transposed. The `Matrix3x3`/`Matrix4x4` + constructors that take vectors place them as rows, as documented (they used to become columns). The projection + stays right-handed with a depth range of [0, 1], and its `M44` is now 0. +- `Vector3.Forward` is (0, 0, −1) and `Vector3.Back` is (0, 0, 1), matching the right-handed convention. +- Equality is exact on every type and follows `System.Numerics`: `==` compares components with IEEE semantics (NaN is + not equal to NaN), `Equals` treats NaN as equal and is consistent with `GetHashCode`. Use `ApproximatelyEquals` + (new on vectors, points, matrices, lines, segments, polygons and bounding boxes) for tolerance-based comparisons. +- Predicates no longer use one absolute epsilon of `1.6e-9`: + - `IsIdentity`, `IsZero`, `IsNormalized` and the `Contains` checks use a combined tolerance (absolute `1e-12`, + relative `1e-9`). + - `IsDiagonal`, `IsTriangular`, `IsSymmetric` and `IsSkewSymmetric` compare entries relative to the largest entry, + and `IsOrthogonal` compares `M · Mᵀ` with the identity. + - `IsParallelTo` and `IsOrthogonalTo` compare the sine or cosine of the angle with `1e-9`, independent of the vector + lengths. + - `IsSingular` uses an LU decomposition with partial pivoting: a matrix is singular if a pivot is at most + `n · ε · s` (`s` = largest absolute entry), the same rule `MatrixMxN` uses. Scaled identities such as + `0.001 · I` and transformations with large translations are regular; matrices whose rows differ by many orders of + magnitude can be numerically singular. +- `Inverse()` throws for singular matrices; `TryInvert` does not throw. +- `IsDiagonal` and `IsTriangular` follow the mathematical definitions: a zero on the diagonal does not matter. +- The zero vector counts as parallel and orthogonal to every vector. +- `Line3D` is stored canonically: `Point` is the point of the line closest to the origin and `Direction` is a unit + vector whose first non-zero component is positive, so `Line3D.FromPoints(p, q).Point` is generally not `p`. +- `ToString()` uses the invariant culture everywhere; matrices print as `Matrix2x2 [[1, 2], [3, 4]]`. +- `Matrix3x3 * Vector2` divides by the resulting W component if it is not 1 (projective transformations). +- `ProjectionData` requires a field of view below π and a positive aspect ratio and throws + `ArgumentOutOfRangeException`. +- `Vector3.ScalarTripleProduct` returns the signed value `a · (b × c)`; it used to return the absolute value. +- `Normalize()` of the zero vector throws `InvalidOperationException` (it returned NaN). It also normalizes very small + and very large vectors correctly. +- `MoveTowards` stops at the target instead of overshooting. +- `LinearEquationSystem.Solve` requires exactly one coefficient per equation and throws `ArgumentException` otherwise. + Too many coefficients used to throw `IndexOutOfRangeException`, and too few were silently padded with zeros. +- Expressions: + - `!` is the factorial; it used to be the parser's internal negation marker. + - Functions require parentheses. + - Letters that are not functions or constants are variables. + - Multiplication may be implicit. + - Every syntax error is a `ParserException` with a `Position`, including empty input (it was `System.Exception`) + and unbalanced brackets (they were `InvalidOperationException` or silently ignored). + - Two or more letters directly in front of `(` must name a function (or end with a known constant or declared + variable), so `cot(x)` reports an unknown function instead of multiplying `c·o·t·x`. Write `x*y*(2)` or `x y(2)` + for products of single-letter variables. + - Input nested more than 256 levels deep or with more than 4096 nested operations is rejected with a + `ParserException` instead of overflowing the stack. +- Canvas: axis labels use the invariant culture on both axes; ticks use "nice" steps (1, 2, 5 × 10ⁿ) and labels are + always placed on grid lines; labels that would be cut off at the edges are skipped; the value indicator shows as many + decimals as the zoom level needs; zooming is limited to 10⁻⁶–10⁹ pixels per unit. + +### Fixed + +- `Matrix2x2/3x3/4x4.Inverse` returned the identity matrix, because the Gauss-Jordan helpers mutated boxed copies. + Singular matrices also returned the identity. +- Gauss-Jordan searched for the pivot in the wrong cell and did not pivot partially, so some regular systems were + reported as unsolvable. +- `Matrix2x2.IsIdentity` checked `M22 == 0`. `Matrix3x3/4x4.IsIdentity` ignored off-diagonal entries (and `M44`). +- `MatrixEnumerator` enumerated in the wrong order and threw for 1x3/1x4 matrices. +- `Matrix1x1` equality was approximate while its hash code was exact. +- `IsOrthogonal`, `IsSymmetric` and `IsSkewSymmetric` compared exactly, so real rotation matrices were "not orthogonal". +- `Matrix1x1.CofactorMatrix` and `Adjugate` threw. +- `Vector3.Transform` divided Z by W twice. +- `Matrix4x4.View` lost the camera translation and did not normalize its basis. `World` lost the position and ignored + the results of `Normalize`. +- `vector * matrix` silently computed `matrix * vector`. +- `ProjectionData` did not validate the aspect ratio, and the field-of-view error message mentioned 360°. +- `AsHorizontalMatrix`/`AsVerticalMatrix` accepted wrong target types. +- `Vector4.UnitW` was (0, 0, 1, 0). +- `CheckForParallelism` returned false whenever a component was zero. +- `Angle` returned NaN for (nearly) parallel vectors. +- `MoveTowards` returned NaN for equal vectors. +- `Line3D.IsPointOnLine` was wrong for directions with zero components. +- `Line2D` could not represent vertical lines, and coincident lines reported no intersection. Refs #9: lines can now + be created from the general form `Ax + By + C = 0`. +- Comparing two different polygons with `==` crashed with a stack overflow. +- `Polygon.GetHashCode` differed for equal polygons. +- `Polygon.Area`, `Center` and `BoundingBox` were wrong for polygons that do not contain the origin. +- `LinearEquationSystem.Solve` validated the number of equations instead of coefficients. +- Expressions: + - Unary minus replaced the first `-` in the whole string. + - Unary `+` was ignored incorrectly. + - Signs after operators (`2*-3`, `2^-1`) failed. + - `%` and `log(x, base)` could not be used. + - `.5` and `1e5` could not be parsed. + - Unbalanced brackets were ignored or crashed. + - Names with a common prefix (e.g. `sinh`) could not be added. +- Canvas (Fixes #3): + - Zooming moved the origin by 1.25 times the distance to the cursor, so the point under the cursor drifted until + coordinates overflowed. + - Coordinates passed to GDI+ were not clipped. + - The tick algorithm normalized large and small values incorrectly and threw for a height of 0. + - A grid size of 0 made the drawing loops hang. + - Function graphs were connected across poles. + - `FunctionWrapper` cached every sampled value forever. + - Pens and brushes were not disposed. + - An origin of (0, 0) could not be set. + +### Added + +- `Tolerance` with `AreEqual`, `IsZero` and `IsNegligible`, and `ApproximatelyEquals` (with optional tolerances) on + vectors, points, matrices, lines, segments, polygons and bounding boxes. +- `MatrixMxN`: an immutable matrix of any size with LU-based determinant, inverse, rank and `Solve`. +- `LineSegment2D`, `BoundingBox2D`, `IntersectionKind`, `LineIntersection2D`, `LineIntersection3D` and + `SegmentIntersection2D`. +- `Line2D`: + - factories `FromGeneralForm`, `FromSlopeIntercept`, `FromPoints`, `FromPointAndDirection` + - `A`, `B`, `C`, `Normal`, `Direction`, `IsVertical`, `IsHorizontal` + - `SolveForX`, `SignedDistanceTo`, `DistanceTo`, `ClosestPoint`, `PointAt` +- `Line3D`: `DistanceTo` (point and line), `ClosestPoint`, `ClosestPoints`, `IsParallelTo`, and `Intersect` + including skew lines. +- Uniform operators on all vectors (`Vector4` had none): `+`, `-`, unary `-`, `v * s`, `s * v`, `v / s`. +- `Vector3(Vector2, z)`, `Vector4(Vector3, w)`, `Vector3.FromHomogeneous`, `ToPoint`, `Point2D/3D.ToVector`, + `Point2D/3D.Distance` and `DistanceSquared` (instance and static). +- `Matrix4x4.Translation(Vector3)`, `Matrix2x2.Rotation/Scaling`, `Matrix3x3.Scaling(scale)`, and `ToString()` for + all matrices. +- Expressions: + - `MathExpression` with variables, `Evaluate(name, value)`, `Evaluate(dictionary)` and an allocation-free `ToFunc` + - `ExpressionContext` for custom functions, constants and multi-letter variables, with `Clone()` + - more built-ins: `atan2 sinh cosh tanh log2 exp floor ceil round sign pow min max tau` +- Canvas: + - the platform-neutral `Canvas2D` model with `Viewport`, `CanvasStyle`, `ItemStyle` and the `Changed` event + - items for points, infinite lines, segments, polygons and vectors with any tail + - functions from expression strings, optionally with an `ExpressionContext` + - the renderer interface `IDrawingContext`, with an SVG renderer (`RenderSvg`), a SkiaSharp renderer (`RenderPng`, + `RenderImage`) and a GDI+ renderer + - an Avalonia control (`Canvas2DView`) for Windows, Linux and macOS +- Samples for WinForms, Avalonia and headless export; BenchmarkDotNet benchmarks (`doc/benchmarks.md`); xUnit tests + with property-based tests (CsCheck), zero-allocation tests and a coverage gate; GitHub Actions on Windows, Linux and + macOS. Refs #5: the test suite now runs on all three platforms (see "Platform support and determinism" in the + README). + +### Performance + +- Closed-form determinants, adjugates and inverses for 2x2 to 4x4 matrices instead of recursive Laplace expansion. +- Unrolled, non-boxing operators. Equality and hash codes no longer go through enumerators and LINQ. +- Common geometry and expression operations no longer allocate. + +See [doc/benchmarks.md](doc/benchmarks.md) for measurements. + +## [1.14.7] and earlier + +Releases before 2.0 targeted .NET Framework 4.0 and are not documented in this file. + +[2.0.0]: https://github.com/dbforge/SharpMath/compare/66339fd...refactor/v2 +[1.14.7]: https://github.com/dbforge/SharpMath/tree/66339fd diff --git a/Canvas2D.png b/Canvas2D.png deleted file mode 100644 index b21aca0095337e0a92004d0e9971589e263768ae..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24965 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 F{uhB9NN4~6 diff --git a/Canvas2DFunctions.png b/Canvas2DFunctions.png deleted file mode 100644 index 4b0cc33d54a33e6189d59d7cc7fdcc82f3c6a3d1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8852 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 diff --git a/README.md b/README.md index 94a0368..dddb1b6 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,225 @@ # SharpMath -SharpMath is a C# math library supporting vectors, matrices, linear equation systems, expression parsing and soon more advanced analysis. -Specials thanks go to [NikxDa](https://github.com/nikxda) who wrote the Canvas2D control for representing vectors and functions graphically. -## Overview -![Class Diagramm](https://github.com/ProgTrade/SharpMath/blob/master/SharpMath.png) +SharpMath is a .NET math library: vectors, points, matrices, lines, segments and polygons, linear equation systems and +an expression parser. It also has a 2D canvas that plots functions and geometry and renders them through GDI+, +SkiaSharp or SVG, or shows them interactively in WinForms and Avalonia. -## 2D and 3D-Geometry -SharpMath offers many structs, such as points, vectors, lines and matrices. These are basically also used in games/computer graphics, except that they're a bit more extensive and sophisticated. Thus, the game engines provide some more flexibility and possibilites than SharpMath. -Nevertheless, you can perform simple operations (such as rotating, scaling or translating objects defined through vertices) in space and project everything onto a two-dimensional surface. +![Functions plotted with SharpMath](doc/images/canvas-functions.png) -![3D-Sample](https://trade-programming.de/pixelkram/sharpmath_projection.png) +> **SharpMath 2.0** is a rewrite for .NET 10 with many breaking changes. See [CHANGELOG.md](CHANGELOG.md) for a +> migration guide from 1.x. + +## Packages + +| Assembly | What it contains | Dependencies | Platforms | +|---|---|---|---| +| `SharpMath` | Geometry, equations, expressions | none | all | +| `SharpMath.Canvas` | Canvas model, styles, renderer interface, SVG renderer | `SharpMath` | all | +| `SharpMath.Canvas.Skia` | SkiaSharp renderer, PNG export | `SkiaSharp` | Windows, Linux, macOS | +| `SharpMath.Canvas.WinForms` | WinForms control, GDI+ renderer | Windows Desktop | Windows | +| `SharpMath.Canvas.Avalonia` | Avalonia control | `Avalonia`, `SharpMath.Canvas.Skia` | Windows, Linux, macOS | + +```mermaid +graph LR + Core[SharpMath] + Canvas[SharpMath.Canvas
model + SVG] + Skia[SharpMath.Canvas.Skia
SkiaSharp + PNG] + WinForms[SharpMath.Canvas.WinForms
GDI+ control] + Avalonia[SharpMath.Canvas.Avalonia
Avalonia control] + Canvas --> Core + Skia --> Canvas + WinForms --> Canvas + Avalonia --> Skia +``` + +The packages are not published on NuGet yet. Reference the projects from source or build them with `dotnet pack`. + +## Geometry + +All vectors, points, matrices and lines are immutable `readonly struct`s with `System.Numerics`-style names. Equality +(`==`, `Equals`, `GetHashCode`) is exact; `ApproximatelyEquals` and all predicates such as `IsOrthogonal` or +`IsParallelTo` use the tolerances from `SharpMath.Tolerance` (absolute `1e-12`, relative `1e-9`). + +```csharp +using SharpMath.Geometry; + +var a = new Vector3(1, 2, 3); +var b = new Vector3(-2, 0.5, 4); +var normal = a.Cross(b).Normalize(); +var angle = a.Angle(b); // radians +var moved = a with { Z = 0 }; // modify a copy + +var p = new Point2D(1, 2); +var q = p + new Vector2(3, 4); // point + vector = point +var direction = q - p; // point − point = vector +``` + +> **Column vectors.** SharpMath uses the textbook convention `v' = M · v`: write `matrix * vector`, and transformations +> compose from right to left. `T * R * S * v` scales first, then rotates, then translates. `System.Numerics` uses row +> vectors and composes the other way round. + +```csharp +var model = Matrix4x4.Translation(0, 0, -5) * Matrix4x4.RotationY(Math.PI / 4) * Matrix4x4.Scaling(2); +var view = Matrix4x4.LookAt(eye: new Vector3(0, 2, 5), target: Vector3.Zero, up: Vector3.Up); +var projection = Matrix4x4.PerspectiveFieldOfView(Math.PI / 3, aspectRatio: 16.0 / 9, nearPlane: 0.1, farPlane: 100); +var clip = projection * view * model * new Vector3(1, 1, 1); // includes the perspective divide + +var m = new Matrix3x3(2, 5, 2, 3, -3, 1, 1, 4, -4); // row-major +double det = m.Determinant; // 111, closed form +if (m.TryInvert(out var inverse)) + Console.WriteLine(m * inverse); // ≈ identity +``` + +The projection is right-handed with a depth range of [0, 1]; the camera looks along −Z (`Vector3.Forward`). + +For larger matrices, `MatrixMxN` offers products, transpose, determinant, inverse, rank and `Solve` via an LU +decomposition with partial pivoting. `IsSingular`, `Inverse()` and `Solve` all use the same rule: a matrix is singular +if a pivot of the LU decomposition is at most `n · ε` times its largest entry. + +### Lines, segments and polygons + +```csharp +// Ax + By + C = 0, also for vertical lines +var line = Line2D.FromGeneralForm(4.0 / 3, -1, 1.0 / 3); +double y = line.SolveForY(2); +var other = Line2D.FromPoints(new Point2D(1, 0), new Point2D(1, 5)); // vertical + +var intersection = line.Intersect(other); +if (intersection.Kind == IntersectionKind.Point) + Console.WriteLine(intersection.Point); + +var segment = new LineSegment2D(new Point2D(0, 0), new Point2D(4, 4)); +var hit = segment.Intersect(new LineSegment2D(new Point2D(0, 4), new Point2D(4, 0))); // Point (2, 2) + +var line3 = Line3D.FromPoints(new Point3D(0, 0, 0), new Point3D(1, 1, 0)); +var kind = line3.Intersect(Line3D.FromPointAndDirection(new Point3D(0, 0, 3), Vector3.UnitY)).Kind; // Skew + +var polygon = new Polygon(new Point2D(0, 0), new Point2D(4, 0), new Point2D(4, 4), new Point2D(2, 1), new Point2D(0, 4)); +Console.WriteLine($"{polygon.Area} {polygon.Perimeter} {polygon.Centroid} {polygon.Contains(new Point2D(1, 1))}"); +``` ## Linear equation systems -SharpMath can solve linear equation systems using the Gauss-Jordan algorithm internally by representing the equations as matrices. -## Expression Parsing -SharpMath knows most of the functions, constants and operators used in computations. You can provide the parser with an input string containing your expression and it will return the result when calling the corresponding function. The flexibility of the parser allows you to add further operations. This feature may be used for calculators. +```csharp +using SharpMath.Equations; + +// x − y + 2z = 6, 2x + 3y + 2z = 11, 3x + 2y + z = 8 +var system = new LinearEquationSystem( + new LinearEquation([1.0, -1, 2], 6), + new LinearEquation([2.0, 3, 2], 11), + new LinearEquation([3.0, 2, 1], 8)); +double[] solution = system.Solve(); // [1, 1, 3] +``` + +`Solve` throws an `EquationNotSolvableException` if the system has no unique solution. + +## Expressions + +`MathExpression` parses an expression once and evaluates it as often as needed, without allocating. + +```csharp +using SharpMath.Expressions; + +double value = MathExpression.Parse("3*4^2 + sin(pi/2)").Evaluate(); // 49 + +var expression = MathExpression.Parse("2x^2 - 3x + 1"); // implicit multiplication +Func f = expression.ToFunc("x"); +double y = f(1.5); + +var context = new ExpressionContext() + .AddFunction("sec", x => 1 / Math.Cos(x)) + .AddConstant("g", 9.81) + .DeclareVariable("t"); +var fall = MathExpression.Parse("g t^2 / 2", context); +double distance = fall.Evaluate("t", 3); +``` + +The parser supports: +- numbers like `1.5`, `.5` and `1e5` +- the operators `+ - * / % ^` and postfix `!`; `^` is right-associative, and `-2^2 = -4` +- parentheses and implicit multiplication: `2x`, `3(x+1)`, `(x+1)(x-1)` +- the functions `sin cos tan asin acos atan atan2 sinh cosh tanh sqrt abs ln lg log(x, base) log2 exp floor ceil round sign pow min max` +- the constants `pi`, `e` and `tau` + +Names are case-insensitive. A sequence of letters is split by longest match against the known names, so `pix` is +`pi·x`. Two or more letters directly in front of `(` must name a function: `cot(x)` is an error unless `cot` was added +to the context. Write `x*y*(2)` or `x y(2)` for products of single-letter variables. Every syntax error is a +`ParserException` with the `Position` of the problem. Use `context.Clone()` to derive variants of a configured +context. + +## Canvas + +![Geometry on the canvas](doc/images/canvas-geometry.png) + +`Canvas2D` is a platform-neutral model. It holds the viewport (pan and zoom), the grid with labelled ticks, the style +and the items: functions (delegates or expression strings), vectors, points, infinite lines, segments and polygons. +Renderers implement `IDrawingContext`; hosts forward mouse input to the model and redraw when it raises `Changed`. + +```csharp +using SharpMath.Canvas; +using SharpMath.Expressions; +using SharpMath.Geometry; + +var canvas = new Canvas2D { ShowTrackingLines = true }; +canvas.AddFunction("x^3 / 10 - x"); +canvas.AddFunction("sec(x)", context: new ExpressionContext().AddFunction("sec", x => 1 / Math.Cos(x))); +canvas.AddFunction(Math.Sin, new ItemStyle(new Stroke(Color.FromRgb(200, 0, 0), Width: 2, DashStyle.Dashed))); +canvas.AddVector(new Vector2(4, 3)); +canvas.AddPolygon(new Polygon(new Point2D(2, -2), new Point2D(6, -2), new Point2D(4, -5))); +canvas.Style = canvas.Style with { AxisStroke = new Stroke(Color.Black) }; +``` + +Ways to display or export a canvas: +- **SVG**, without dependencies: `string svg = canvas.RenderSvg(800, 600);` +- **PNG** via SkiaSharp: `canvas.RenderPng(stream, 800, 600);`, or `canvas.RenderImage(...)` for an `SKImage` +- **WinForms**: place a `Canvas2DControl` on a form and use its `Canvas` property. It renders with GDI+. +- **Avalonia**: place a `Canvas2DView` in a window and use its `Canvas` property. It renders with Skia. + +In both controls, drag with the left mouse button to pan and use the mouse wheel to zoom around the pointer. + +## Samples + +| Sample | Description | +|---|---| +| `samples/SharpMath.Samples.Canvas.WinForms` | Interactive canvas with an expression input (Windows) | +| `samples/SharpMath.Samples.Canvas.Avalonia` | The same, cross-platform | +| `samples/SharpMath.Samples.Perspective.WinForms` | A rotating wireframe cube using `LookAt` and the perspective projection (Windows) | +| `samples/SharpMath.Samples.Polygon.WinForms` | `Polygon.Contains` following the mouse pointer (Windows) | +| `samples/SharpMath.Samples.Export` | Renders PNG and SVG without UI; it generates the images in this README | + +## Platform support and determinism + +SharpMath targets .NET 10 and is tested on Windows, Linux and macOS. The maths uses plain `double` arithmetic without +platform-specific SIMD code. Results are therefore as reproducible as .NET's floating-point and `System.Math` +implementations: elementary arithmetic is IEEE 754 and identical everywhere, but functions such as `Math.Sin` may +differ in the last bits between platforms and runtime versions. If you need bit-exact lockstep results (e.g. for +networked games), avoid transcendental functions in the simulated state. + +## Building and testing + +You need the .NET 10 SDK. + +```shell +dotnet build SharpMath.slnx +dotnet test --solution SharpMath.slnx + +# Coverage gate (at least 90 % line coverage for SharpMath and SharpMath.Canvas), as in CI +dotnet test --project tests/SharpMath.Tests --coverage --coverage-output-format cobertura --coverage-output core.cobertura.xml --results-directory coverage +dotnet test --project tests/SharpMath.Canvas.Tests --coverage --coverage-output-format cobertura --coverage-output canvas.cobertura.xml --results-directory coverage +dotnet run eng/CheckCoverage.cs -- 90 coverage/core.cobertura.xml=SharpMath coverage/canvas.cobertura.xml=SharpMath.Canvas + +# Benchmarks (results in doc/benchmarks.md) +dotnet run -c Release --project benchmarks/SharpMath.Benchmarks -- --filter "*" +``` + +The WinForms projects build on every platform, but their tests only run on Windows. + +## Credits + +Thanks to [NikxDa](https://github.com/nikxda), who wrote the original Canvas2D control, and to +[Stefan Baumann](https://github.com/stefan-baumann) and [Voon Foo](https://github.com/voonfoo) for their +contributions. -![Calculator](https://trade-programming.de/pixelkram/vstiebqhlj.png) +## License -## Canvas2D -SharpMath provides a two-dimensional canvas control for representing vectors and functions graphically in your application. It was written by [NikxDa](https://github.com/nikxda). -A three-dimensional representation (e.g. for vectors, points and lines) will follow as well. Thanks to the `Geometry`-namespace, the implementation of this one should not be too hard as the projection onto a two-dimensional surface can be done using the integrated matrices. -![Canvas2D](https://github.com/ProgTrade/SharpMath/blob/master/Canvas2D.png) +SharpMath is licensed under the [MIT License](LICENSE). diff --git a/SharpMath.png b/SharpMath.png deleted file mode 100644 index e29171ec1dcf4fc0d81f52efd8df312c365c80b7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 531216 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 z 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

})%QH~T)z8ej=E%H(xe4d!76 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

Pfk^6C;UZ)ZGHNuX1%Xo?;SQtQ)z;s$ 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 +4O7vC=N7{Yq2D&b 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

L;vcvM|QaiL-%7!>fJx3|abu|uuMv?Lc69(3A~ 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

QE6oh<=i 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°GXnV#*hV_tj2vtIE}hKt;<(x-`e%bcZ|j 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