diff --git a/CHANGELOG.md b/CHANGELOG.md
index fd55581..2f38ca4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Change Log
+## [8.1.0-beta] - 2026-03-20
+- Add Windowed Rhino Option
+- Add Support for Environment Variables in Paths
+- Add Support for specifying Package Directories
+
## [8.0.28-beta] - 2025-05-12
- Resolver bug fixes
diff --git a/README.md b/README.md
index 822233c..0a36670 100644
--- a/README.md
+++ b/README.md
@@ -60,6 +60,20 @@ Specify Legacy IronPython to be loaded:
true
```
+Specify Rhino To Start in Windowed Mode (default is headless):
+```xml
+true
+```
+Important note: if you set `WindowedMode` to true, you must ensure that your tests are running in a static appartment:
+To do that, add the following attribute to your setup fixture class `Apartment(ApartmentState.STA)`:
+```csharp
+[SetupFixture, Apartment(ApartmentState.STA)]
+public sealed class SetupFixture : Rhino.Testing.Fixtures.RhinoSetupFixture
+{
+ // your setup fixture implementation
+}
+```
+
Specify list of plugins to be loaded (These plugins are always loaded before Grasshopper)
```xml
@@ -71,6 +85,21 @@ Specify list of plugins to be loaded (These plugins are always loaded before Gra
```
+Specify Directories to Load Packages from
+```xml
+
+
+
+```
+
+Use Environment Variables for Paths in the config file
+```xml
+
+
+ ${env:ProgramFiles}\Rhino 8\System
+
+```
+
Specify Grasshopper to be loaded:
```xml
diff --git a/Rhino.Testing.sln b/Rhino.Testing.sln
index 50f5bc4..7cb3235 100644
--- a/Rhino.Testing.sln
+++ b/Rhino.Testing.sln
@@ -1,7 +1,7 @@
Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.8.34511.84
+# Visual Studio Version 18
+VisualStudioVersion = 18.0.11111.16
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Rhino.Testing", "src\Rhino.Testing\Rhino.Testing.csproj", "{EE10AB9B-51C3-4025-AA26-C4DD3517E04C}"
EndProject
@@ -13,6 +13,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Rhino.Testing.Tests.SetupFi
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Rhino.Testing.Tests.Utils", "src\Rhino.Testing.Tests.Utils\Rhino.Testing.Tests.Utils.csproj", "{A49EB5F1-7D83-4668-AF75-290BDCF350FA}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rhino.Testing.Tests.WindowedSetup", "src\Rhino.Testing.Tests.WindowedSetup\Rhino.Testing.Tests.WindowedSetup.csproj", "{31DA1866-70AC-437B-8B97-169EDDFDE4B1}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -39,6 +41,10 @@ Global
{A49EB5F1-7D83-4668-AF75-290BDCF350FA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A49EB5F1-7D83-4668-AF75-290BDCF350FA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A49EB5F1-7D83-4668-AF75-290BDCF350FA}.Release|Any CPU.Build.0 = Release|Any CPU
+ {31DA1866-70AC-437B-8B97-169EDDFDE4B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {31DA1866-70AC-437B-8B97-169EDDFDE4B1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {31DA1866-70AC-437B-8B97-169EDDFDE4B1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {31DA1866-70AC-437B-8B97-169EDDFDE4B1}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 7217518..1e98e29 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -15,7 +15,7 @@
- 8.0.28
+ 8.1.0
8.0.6-beta
8.0.23304.9001
$(MSBuildThisFileDirectory)src\
@@ -33,11 +33,6 @@
-
-
- runtime; build; native; contentfiles; analyzers; buildtransitive
- all
-
@@ -55,5 +50,4 @@
compile; build; native; contentfiles; analyzers; buildtransitive
-
-
\ No newline at end of file
+
diff --git a/src/Rhino.Testing.Tests.SetupAttribute/Rhino.Testing.Tests.SetupAttribute.csproj b/src/Rhino.Testing.Tests.SetupAttribute/Rhino.Testing.Tests.SetupAttribute.csproj
index 1bd3eca..8b9b024 100644
--- a/src/Rhino.Testing.Tests.SetupAttribute/Rhino.Testing.Tests.SetupAttribute.csproj
+++ b/src/Rhino.Testing.Tests.SetupAttribute/Rhino.Testing.Tests.SetupAttribute.csproj
@@ -1,6 +1,6 @@
- net48;net7.0-windows;net8.0-windows
+ net48;net8.0-windows
false
enable
enable
@@ -9,12 +9,18 @@
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
Always
diff --git a/src/Rhino.Testing.Tests.SetupFixture/Rhino.Testing.Tests.SetupFixture.csproj b/src/Rhino.Testing.Tests.SetupFixture/Rhino.Testing.Tests.SetupFixture.csproj
index 121bb3c..f79ca7f 100644
--- a/src/Rhino.Testing.Tests.SetupFixture/Rhino.Testing.Tests.SetupFixture.csproj
+++ b/src/Rhino.Testing.Tests.SetupFixture/Rhino.Testing.Tests.SetupFixture.csproj
@@ -1,6 +1,6 @@
- net48;net7.0-windows;net8.0-windows
+ net48;net8.0-windows
false
enable
enable
@@ -9,6 +9,11 @@
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
@@ -16,6 +21,7 @@
+
Always
diff --git a/src/Rhino.Testing.Tests.WindowedSetup/EventTest.cs b/src/Rhino.Testing.Tests.WindowedSetup/EventTest.cs
new file mode 100644
index 0000000..47bd2b1
--- /dev/null
+++ b/src/Rhino.Testing.Tests.WindowedSetup/EventTest.cs
@@ -0,0 +1,27 @@
+using System;
+using Rhino.Testing.Fixtures;
+
+namespace Rhino.Testing.Tests.WindowedSetup
+{
+ internal class EventTest : RhinoTestFixture
+ {
+ [Test]
+ public void NewDocumentTest()
+ {
+ bool documentEventFired = false;
+
+ void LocalScopeNewDocument(object? sender, DocumentEventArgs e)
+ {
+ documentEventFired = true;
+ }
+
+ RhinoDoc.NewDocument += LocalScopeNewDocument;
+ using (var doc = RhinoDoc.Create(null))
+ {
+ Assert.That(documentEventFired, Is.True);
+ Assert.That(doc, Is.Not.Null);
+ }
+ RhinoDoc.NewDocument -= LocalScopeNewDocument;
+ }
+ }
+}
diff --git a/src/Rhino.Testing.Tests.WindowedSetup/Rhino.Testing.Configs.xml b/src/Rhino.Testing.Tests.WindowedSetup/Rhino.Testing.Configs.xml
new file mode 100644
index 0000000..8b9af75
--- /dev/null
+++ b/src/Rhino.Testing.Tests.WindowedSetup/Rhino.Testing.Configs.xml
@@ -0,0 +1,11 @@
+
+
+ C:\Program Files\Rhino 8\System
+ false
+ false
+ false
+ false
+ false
+ false
+ true
+
diff --git a/src/Rhino.Testing.Tests.WindowedSetup/Rhino.Testing.Tests.WindowedSetup.csproj b/src/Rhino.Testing.Tests.WindowedSetup/Rhino.Testing.Tests.WindowedSetup.csproj
new file mode 100644
index 0000000..d76d4c8
--- /dev/null
+++ b/src/Rhino.Testing.Tests.WindowedSetup/Rhino.Testing.Tests.WindowedSetup.csproj
@@ -0,0 +1,29 @@
+
+
+ net48;net8.0-windows
+ false
+ enable
+ enable
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+
\ No newline at end of file
diff --git a/src/Rhino.Testing.Tests.WindowedSetup/SetupFixture.cs b/src/Rhino.Testing.Tests.WindowedSetup/SetupFixture.cs
new file mode 100644
index 0000000..08522ff
--- /dev/null
+++ b/src/Rhino.Testing.Tests.WindowedSetup/SetupFixture.cs
@@ -0,0 +1,10 @@
+namespace Rhino.Testing.Tests
+{
+ [SetUpFixture, Apartment(ApartmentState.STA)]
+ public sealed class SetupFixture : Rhino.Testing.Fixtures.RhinoSetupFixture
+ {
+ public override void OneTimeSetup() => base.OneTimeSetup();
+
+ public override void OneTimeTearDown() => base.OneTimeTearDown();
+ }
+}
diff --git a/src/Rhino.Testing.Tests.WindowedSetup/SimpleTest.cs b/src/Rhino.Testing.Tests.WindowedSetup/SimpleTest.cs
new file mode 100644
index 0000000..8420faf
--- /dev/null
+++ b/src/Rhino.Testing.Tests.WindowedSetup/SimpleTest.cs
@@ -0,0 +1,17 @@
+using Rhino.Geometry;
+using Rhino.Testing.Fixtures;
+
+namespace Rhino.Testing.Tests
+{
+ public class SimpleTest : RhinoTestFixture
+ {
+ [Test]
+ public void Test()
+ {
+ Point3d point = new(1, 2, 3);
+ Assert.That(point.X, Is.GreaterThan(0));
+ Assert.That(point.Y, Is.GreaterThan(0));
+ Assert.That(point.Z, Is.GreaterThan(0));
+ }
+ }
+}
diff --git a/src/Rhino.Testing.Tests.WindowedSetup/Usings.cs b/src/Rhino.Testing.Tests.WindowedSetup/Usings.cs
new file mode 100644
index 0000000..cefced4
--- /dev/null
+++ b/src/Rhino.Testing.Tests.WindowedSetup/Usings.cs
@@ -0,0 +1 @@
+global using NUnit.Framework;
\ No newline at end of file
diff --git a/src/Rhino.Testing.Tests/Rhino.Testing.Configs.xml b/src/Rhino.Testing.Tests/Rhino.Testing.Configs.xml
index 53cd297..69a3a53 100644
--- a/src/Rhino.Testing.Tests/Rhino.Testing.Configs.xml
+++ b/src/Rhino.Testing.Tests/Rhino.Testing.Configs.xml
@@ -7,4 +7,7 @@
true
false
false
+
+
+
diff --git a/src/Rhino.Testing.Tests/Rhino.Testing.Tests.csproj b/src/Rhino.Testing.Tests/Rhino.Testing.Tests.csproj
index 1bd3eca..e0602cf 100644
--- a/src/Rhino.Testing.Tests/Rhino.Testing.Tests.csproj
+++ b/src/Rhino.Testing.Tests/Rhino.Testing.Tests.csproj
@@ -1,6 +1,6 @@
- net48;net7.0-windows;net8.0-windows
+ net48;net8.0-windows
false
enable
enable
@@ -9,6 +9,11 @@
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
diff --git a/src/Rhino.Testing/Configs.cs b/src/Rhino.Testing/Configs.cs
index 205d399..db74e6e 100644
--- a/src/Rhino.Testing/Configs.cs
+++ b/src/Rhino.Testing/Configs.cs
@@ -50,12 +50,24 @@ public static T Deserialize(XmlSerializer serializer, string settingsFile)
[XmlElement]
public bool LoadGrasshopper { get; set; } = false;
+ [XmlElement]
+ public bool WindowedRhino { get; set; } = false;
+
+
[XmlArray("LoadPlugins")]
[XmlArrayItem("Plugin")]
#pragma warning disable CA1002 // Do not expose generic lists
#pragma warning disable CA2227 // Collection properties should be read only
public List LoadPlugins { get; set; } = new List();
#pragma warning restore CA2227 // Collection properties should be read only
+#pragma warning restore CA1002 // Do not expose generic lists
+
+ [XmlArray("PackageDirectories")]
+ [XmlArrayItem("Directory")]
+#pragma warning disable CA1002 // Do not expose generic lists
+#pragma warning disable CA2227 // Collection properties should be read only
+ public List PackageDirectories { get; set; } = new List();
+#pragma warning restore CA2227 // Collection properties should be read only
#pragma warning restore CA1002 // Do not expose generic lists
[XmlIgnore]
@@ -70,6 +82,36 @@ public Configs()
SettingsDir = Path.GetDirectoryName(SettingsFile);
}
+ ///
+ /// Support for using environment variables in the rhino system directory, package directories, and plugin paths, in the form of ${env:VAR_NAME}
+ ///
+ ///
+ ///
+ public static string ReplaceEnvVars(string path)
+ {
+ if (string.IsNullOrEmpty(path))
+ return path;
+ int startIndex = 0;
+ while (true)
+ {
+ int envStart = path.IndexOf("${env:", startIndex, StringComparison.OrdinalIgnoreCase);
+ if (envStart == -1)
+ break;
+ int envEnd = path.IndexOf('}', envStart);
+ if (envEnd == -1)
+ break;
+ string envVar = path.Substring(envStart + 6, envEnd - envStart - 6);
+ string envVal = Environment.GetEnvironmentVariable(envVar) ?? string.Empty;
+#if NET7_0_OR_GREATER
+ path = path[..envStart] + envVal + path[(envEnd + 1)..];
+#else
+ path = path.Substring(0, envStart) + envVal + path.Substring(envEnd + 1);
+#endif
+ startIndex = envStart + envVal.Length;
+ }
+ return path;
+ }
+
static Configs()
{
string cfgFile = GetConfigsFile();
@@ -77,6 +119,27 @@ static Configs()
if (File.Exists(cfgFile))
{
Current = Deserialize(new XmlSerializer(typeof(Configs)), cfgFile);
+ Current.RhinoSystemDir = ReplaceEnvVars(Current.RhinoSystemDir);
+
+ // separate our the Package Directories by the path separator and trim whitespace
+ List dirs = new List();
+ foreach (var dir in Current.PackageDirectories)
+ {
+ var path = ReplaceEnvVars(dir.Path.Trim());
+ string[] splitDirs = path.Split(new char[] { Path.PathSeparator }, StringSplitOptions.RemoveEmptyEntries);
+ foreach (var splitDir in splitDirs)
+ {
+ dirs.Add(new DirectoryConfigs() { Path = splitDir.Trim() });
+ }
+ }
+ //add the config file directory as a package directory so that plugins can be specified relative to the config file location
+ dirs.Add(new DirectoryConfigs() { Path = Path.GetDirectoryName(cfgFile) });
+ Current.PackageDirectories = dirs;
+
+ foreach (var plugin in Current.LoadPlugins)
+ {
+ plugin.Location = ReplaceEnvVars(plugin.Location);
+ }
if (Path.IsPathRooted(Current.RhinoSystemDir))
{
@@ -105,4 +168,12 @@ public sealed class PluginConfigs
[XmlAttribute]
public string Location { get; set; } = string.Empty;
}
+
+ [Serializable]
+ [XmlRoot("Directory")]
+ public sealed class DirectoryConfigs
+ {
+ [XmlAttribute]
+ public string Path { get; set; } = string.Empty;
+ }
}
diff --git a/src/Rhino.Testing/RhinoCore.cs b/src/Rhino.Testing/RhinoCore.cs
index e64ac6c..53f4bcf 100644
--- a/src/Rhino.Testing/RhinoCore.cs
+++ b/src/Rhino.Testing/RhinoCore.cs
@@ -37,6 +37,14 @@ public static void Initialize()
throw new DirectoryNotFoundException(Configs.Current.RhinoSystemDir);
}
+ //add the Package directories to the RHINO_PACKAGE_DIRS environment variable so that RhinoInside can find the plugins in those directories
+ foreach (var packageDir in Configs.Current.PackageDirectories)
+ {
+ var env = Environment.GetEnvironmentVariable("RHINO_PACKAGE_DIRS", EnvironmentVariableTarget.Process);
+ env = string.IsNullOrEmpty(env) ? packageDir.Path : $"{env};{packageDir.Path}";
+ Environment.SetEnvironmentVariable("RHINO_PACKAGE_DIRS", env, EnvironmentVariableTarget.Process);
+ }
+
RhinoInside.Resolver.Initialize(Configs.Current.RhinoSystemDir);
Configs.Current.RhinoSystemDir = RhinoInside.Resolver.RhinoSystemDirectory;
@@ -63,7 +71,7 @@ public static void Initialize()
if (Configs.Current.LoadPlugins.Count != 0)
{
- PluginLoader.LoadPlugins(Configs.Current.LoadPlugins.Select(p => p.Location));
+ PluginLoader.LoadPlugins(Configs.Current.LoadPlugins.Select(p => p.Location), Configs.Current.PackageDirectories.Select(d => d.Path));
}
if (Configs.Current.LoadGrasshopper)
diff --git a/src/Rhino.Testing/_Loaders/PluginLoader.cs b/src/Rhino.Testing/_Loaders/PluginLoader.cs
index 9095630..7b3dde7 100644
--- a/src/Rhino.Testing/_Loaders/PluginLoader.cs
+++ b/src/Rhino.Testing/_Loaders/PluginLoader.cs
@@ -6,13 +6,25 @@
using RhinoInside;
using NUnit.Framework;
+using System.Diagnostics;
namespace Rhino.Testing
{
static class PluginLoader
{
- public static string GetRHPPath(string rhpPath)
+ public static string GetRHPPath(string rhpPath, IEnumerable packageDirs = null)
{
+ if(File.Exists(rhpPath))
+ {
+ if (Path.IsPathRooted(rhpPath))
+ {
+ return rhpPath;
+ }
+ var dir = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location);
+ rhpPath = Path.Combine(dir, rhpPath);
+ return rhpPath;
+ }
+ // first look in the rhino system directory
string rhp = Path.Combine(Configs.Current.RhinoSystemDir, rhpPath);
if (File.Exists(rhp))
{
@@ -38,6 +50,22 @@ public static string GetRHPPath(string rhpPath)
}
}
+ if (packageDirs != null)
+ {
+ foreach (var packageDir in packageDirs)
+ {
+ rhp = Path.Combine(packageDir, rhpPath);
+ if (File.Exists(rhp))
+ {
+ return rhp;
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && Directory.Exists(rhp))
+ {
+ return rhp;
+ }
+ }
+ }
+
throw new FileNotFoundException(rhpPath);
}
@@ -145,13 +173,13 @@ public static void LoadGrasshopper()
}
}
- public static void LoadPlugins(IEnumerable rhpPaths)
+ public static void LoadPlugins(IEnumerable rhpPaths, IEnumerable packageDirs)
{
foreach (var rhpPath in rhpPaths)
{
- string fullPath = GetRHPPath(rhpPath);
-
+ string fullPath = GetRHPPath(rhpPath, packageDirs);
TestContext.WriteLine($"Loading plugin from {fullPath}");
+ Debug.WriteLine($"Loading plugin from {fullPath}");
if (PlugIns.PlugIn.LoadPlugIn(fullPath, out Guid _)
!= PlugIns.LoadPlugInResult.Success)
diff --git a/src/Rhino.Testing/_Loaders/RhinoCoreLoader.cs b/src/Rhino.Testing/_Loaders/RhinoCoreLoader.cs
index b98f4bd..b1e60c7 100644
--- a/src/Rhino.Testing/_Loaders/RhinoCoreLoader.cs
+++ b/src/Rhino.Testing/_Loaders/RhinoCoreLoader.cs
@@ -1,5 +1,7 @@
using System;
+using System.Collections.Generic;
using System.IO;
+using System.Threading;
using NUnit.Framework;
@@ -23,7 +25,23 @@ public static void LoadCore(bool createDoc, bool createView)
lock (s_coreLock)
{
- s_core = new Rhino.Runtime.InProcess.RhinoCore(args);
+ if (!Configs.Current.WindowedRhino)
+ {
+ s_core = new Rhino.Runtime.InProcess.RhinoCore(args);
+ }
+ else
+ {
+ //Verify apartment state before initialization
+ var apartmentState = Thread.CurrentThread.GetApartmentState();
+ TestContext.WriteLine($"Current thread apartment state: {apartmentState}");
+ if(apartmentState != ApartmentState.STA)
+ {
+ throw new InvalidOperationException("For windowed mode, thread must be static (STA). Add Apartment(ApartmentState.STA) to test SetupFixture");
+ }
+ List windowedArgs = new List(args);
+ windowedArgs.AddRange(new string[] { "/notemplate", "/nosplash"});
+ s_core = new Rhino.Runtime.InProcess.RhinoCore(windowedArgs.ToArray(), Runtime.InProcess.WindowStyle.Hidden);
+ }
if (createDoc)
{