Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 163 additions & 39 deletions src/Libraries/DynamoUnits/Utilities.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
using Autodesk.DesignScript.Runtime;
using System;
using System.Collections.Generic;
using Autodesk.DesignScript.Runtime;
using System.Reflection;
using System.IO;
using System.Configuration;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using ForgeUnits = Autodesk.ForgeUnits;

namespace DynamoUnits
Expand All @@ -14,13 +15,13 @@ namespace DynamoUnits
public static class Utilities
{
private static ForgeUnits.UnitsEngine unitsEngine;
private static List<string> candidateDirectories = new List<string>();

/// <summary>
/// Path to the directory used load the schema definitions.
/// </summary>
[SupressImportIntoVM]
public static string SchemaDirectory { get; private set; } = Path.Combine(AssemblyDirectory, "unit");

public static string SchemaDirectory { get; private set; } = string.Empty;

static Utilities()
{
Expand All @@ -32,51 +33,50 @@ static Utilities()
/// </summary>
internal static void Initialize()
{
var assemblyFilePath = Assembly.GetExecutingAssembly().Location;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Initialize method now builds a list of candidate schema directories, giving ASC-based directories the highest priority. Detection of ASC directories is handled entirely in AddAscSchemaPaths. If schemas from an ASC directory load successfully, that directory is used and no further lookup is performed.

// Build candidate schema directories list
candidateDirectories.Clear();

var assemblyFilePath = Assembly.GetExecutingAssembly().Location;
var config = ConfigurationManager.OpenExeConfiguration(assemblyFilePath);
var key = config.AppSettings.Settings["schemaPath"];
string path = null;
if (key != null)
{
path = key.Value;
}

if (!string.IsNullOrEmpty(path) && Directory.Exists(path))
// Add config path if it's valid
var configPath = config.AppSettings.Settings["schemaPath"]?.Value;
if (!string.IsNullOrEmpty(configPath) && Directory.Exists(configPath))
{
SchemaDirectory = path;
candidateDirectories.Add(configPath);
}

try
{
unitsEngine = new ForgeUnits.UnitsEngine();
ForgeUnits.SchemaUtility.addDefinitionsFromFolder(SchemaDirectory, unitsEngine);
unitsEngine.resolveSchemas();
}
catch
// Add ASC schema paths from installed components
AddAscSchemaPaths(candidateDirectories);

// Add bundled schema directory as final candidate
candidateDirectories.Add(BundledSchemaDirectory);

// Try each candidate directory until we find one that works
foreach (var directory in candidateDirectories)
{
unitsEngine = null;
//There was an issue initializing the schemas at the specified path.
// Always update SchemaDirectory to the current attempt for clearer error
// reporting. If loading succeeds, SchemaDirectory reflects the working
// path. Otherwise, it shows the last path tried, which will be displayed
// in any thrown exception. If all paths fail, the exception message will
// show the default bundled schema directory, which should never fail.
SchemaDirectory = directory;

unitsEngine = TryLoadSchemaFromDirectory(directory);
if (unitsEngine != null)
{
break; // Found the schema directory, so stop trying.
}
}
}

/// <summary>
/// only use this method during tests - allows setting a different schema location without
/// worrying about distributing a test configuration file.
/// </summary>
internal static void SetTestEngine(string testSchemaDir)
{
try
{
unitsEngine = new ForgeUnits.UnitsEngine();
ForgeUnits.SchemaUtility.addDefinitionsFromFolder(testSchemaDir, unitsEngine);
unitsEngine.resolveSchemas();
}
catch
{
unitsEngine = null;
//There was an issue initializing the schemas at the specified path.
}
unitsEngine = TryLoadSchemaFromDirectory(testSchemaDir);
}

/// <summary>
Expand Down Expand Up @@ -148,20 +148,20 @@ internal static ForgeUnits.UnitsEngine ForgeUnitsEngine
{
if (unitsEngine == null)
{
throw new Exception("There was an issue loading Unit Schemas from the specified path: "
+ SchemaDirectory);
var attemptedPaths = string.Join(", ", candidateDirectories);
throw new Exception($"There was an issue loading Unit Schemas. Attempted paths: {attemptedPaths}");
}

return unitsEngine;
}
}

private static string AssemblyDirectory
private static string BundledSchemaDirectory
{
get
{
string path = Assembly.GetExecutingAssembly().Location;
return Path.GetDirectoryName(path);
return Path.Combine(Path.GetDirectoryName(path), "unit");
}
}

Expand Down Expand Up @@ -404,5 +404,129 @@ internal static bool TryParseTypeId(string typeId, out string typeName, out Vers

return false;
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As shown here, AddAscSchemaPaths uses reflection to obtain AscSdkWrapper from DynamoInstallDetective.dll. This approach avoids introducing a build-time dependency on the DLL, which is Windows-only, while DynamoUnits remains platform-agnostic.

/// <summary>
/// Adds ASC (Autodesk Shared Components) schema paths to the candidate directories list.
///
/// We use 'AscSdkWrapper' directly here because 'InstalledAscLookUp' is overkill -- it's got
/// extra logic we don't need. 'AscSdkWrapper' gives us just the version/path info for ASC
/// installs, which is all we care about for schema discovery. This also decouples us from
/// changes in 'InstalledAscLookUp' that could break or complicate schema path resolution.
/// </summary>
/// <param name="candidateDirectories">List to add discovered ASC schema paths to</param>
private static void AddAscSchemaPaths(List<string> candidateDirectories)
{
// Currently ASC discovery is only available on Windows. When cross-platform ASC
// support becomes available, we can extend this to work on other platforms.
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return;
}

try
{
// Use reflection to dynamically load DynamoInstallDetective at runtime.
// This avoids the need for a direct project reference and InternalsVisibleTo,
// maintaining cross-platform compatibility for the DynamoUnits library.
var dynamoInstallDetectiveAssembly = Assembly.LoadFrom(
Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location),
"DynamoInstallDetective.dll"));

var ascWrapperType = dynamoInstallDetectiveAssembly.GetType("DynamoInstallDetective.AscSdkWrapper");
if (ascWrapperType == null)
{
return; // AscSdkWrapper type not found
}

// Get major versions using reflection: AscSdkWrapper.GetMajorVersions()
var getMajorVersionsMethod = ascWrapperType.GetMethod("GetMajorVersions",
BindingFlags.Public | BindingFlags.Static);
var majorVersions = (string[])getMajorVersionsMethod?.Invoke(null, null);

if (majorVersions == null) return;

// Get the ASC_STATUS enum for comparison
var ascStatusType = ascWrapperType.GetNestedType("ASC_STATUS");
var successValue = Enum.Parse(ascStatusType, "SUCCESS");

foreach (var majorVersion in majorVersions)
{
// Create AscSdkWrapper instance: new AscSdkWrapper(majorVersion)
var ascWrapper = Activator.CreateInstance(ascWrapperType, majorVersion);

// Call GetInstalledPath using reflection
var getInstalledPathMethod = ascWrapperType.GetMethod("GetInstalledPath");
var parameters = new object[] { string.Empty };
var result = getInstalledPathMethod?.Invoke(ascWrapper, parameters);

// Check if result equals ASC_STATUS.SUCCESS
if (result != null && result.Equals(successValue))
{
var installPath = (string)parameters[0];
var schemaPath = Path.Combine(installPath, "coreschemas", "unit");
candidateDirectories.Add(schemaPath);
}
}
}
catch
{
// Ignore errors when discovering ASC paths - this is optional discovery.
// DynamoInstallDetective.dll might not be available on some deployments,
// or ASC might not be installed on the system.
}
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ForgeUnits.SchemaUtility.addDefinitionsFromFolder will succeed even if the specified schemaDirectory is empty. Therefore, it’s important to verify that required subdirectories such as quantity, symbol, and others exist under the unit directory; otherwise, the directory won’t qualify as a valid source for ForgeUnits.UnitsEngine.

/// <summary>
/// Attempts to load schema from the specified directory and create a UnitsEngine.
/// </summary>
/// <param name="schemaDirectory">Directory containing the schema definitions</param>
/// <returns>A ForgeUnits.UnitsEngine instance, or null if loading failed</returns>
private static ForgeUnits.UnitsEngine TryLoadSchemaFromDirectory(string schemaDirectory)
{
try
{
// Validate that the directory exists and contains required subdirectories
if (IsValidSchemaDirectory(schemaDirectory))
{
var engine = new ForgeUnits.UnitsEngine();
ForgeUnits.SchemaUtility.addDefinitionsFromFolder(schemaDirectory, engine);
engine.resolveSchemas();
return engine;
}

return null; // Invalid schema directory
}
catch
{
//There was an issue initializing the schemas at the specified path.
return null;
}
}

/// <summary>
/// Validates that a directory contains the required schema subdirectories.
/// </summary>
/// <param name="schemaDirectory">Directory to validate</param>
/// <returns>True if the directory contains all required subdirectories</returns>
private static bool IsValidSchemaDirectory(string schemaDirectory)
{
if (string.IsNullOrEmpty(schemaDirectory) || !Directory.Exists(schemaDirectory))
{
return false;
}

var requiredSubdirectories = new[] { "dimension", "quantity", "symbol", "unit" };

foreach (var subdirectory in requiredSubdirectories)
{
var subdirectoryPath = Path.Combine(schemaDirectory, subdirectory);
if (!Directory.Exists(subdirectoryPath))
{
return false;
}
}

return true;
}
}
}
15 changes: 9 additions & 6 deletions src/Libraries/GeometryColor/ImportHelpers.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using DynamoUnits;
using DynamoUnits;
Copy link
Contributor Author

@benglin benglin Oct 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ImportHelpers.CalculateMillimeterPerUnit method was previously hard-coded to create a Unit object using autodesk.unit.unit:millimeters-1.0.1. Although this schema version still exists in v2, the latest version is 2.0.0, which is what appears in the dynamoUnit.ConvertibleUnits list. As a result, the original ConvertibleUnits.Contains(mm) failed since 1.0.1 != 2.0.0. To address this, the comparison was updated to use Unit.Name values instead.

using System;
using System.Linq;
using System.Collections.Generic;
Expand Down Expand Up @@ -65,17 +65,20 @@ private static double CalculateMillimeterPerUnit(Unit dynamoUnit)
{
if (dynamoUnit != null)
{
const string millimeters = "autodesk.unit.unit:millimeters";
var mm = Unit.ByTypeID($"{millimeters}-1.0.1");
var mm = Unit.ByTypeID($"autodesk.unit.unit:millimeters-1.0.1");

if (!dynamoUnit.ConvertibleUnits.Contains(mm))
// Match millimeters `Unit` by name, not type ID, to handle different schema versions (users may
// have "millimeters-1.0.1" or "millimeters-2.0.0" in ConvertibleUnits depending on their setup)
var convertibleMm = dynamoUnit.ConvertibleUnits.FirstOrDefault(unit => unit.Name == mm.Name);
if (convertibleMm == null)
{
throw new Exception($"{dynamoUnit.Name} was not convertible to mm");
}
return Utilities.ConvertByUnits(1, dynamoUnit, mm);

return Utilities.ConvertByUnits(1, dynamoUnit, convertibleMm);
}

return -1;
}

}
}
10 changes: 8 additions & 2 deletions src/Tools/DynamoInstallDetective/AscSDKWrapper.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using Microsoft.Win32;

namespace DynamoInstallDetective
Expand Down Expand Up @@ -186,17 +187,22 @@ public ASC_STATUS GetInstalledPath(ref string installedPath)
/// <summary>
/// Get the major version of all ASC packages installed on the local machine
/// </summary>
/// <returns>An array of major versions, for example ["2026, "2027", "2028"]</returns>
/// <returns>An array of sorted major versions, for example ["2028", "2027", "2026"]</returns>
public static string[] GetMajorVersions()
{
string[] majorVersions = [];
var baseKey = Registry.LocalMachine;
var subkey = baseKey.OpenSubKey(@"SOFTWARE\Autodesk\SharedComponents");

if(subkey != null)
{
majorVersions = subkey.GetSubKeyNames();
majorVersions = subkey.GetSubKeyNames()
.OrderByDescending(version => version) // Newer versions first
.ToArray();

subkey.Close();
}

return majorVersions;
}
}
Expand Down
11 changes: 7 additions & 4 deletions test/DynamoCoreTests/UnitsOfMeasureTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,7 @@ internal class ForgeUnitsTests : UnitTestBase
[Test, Category("UnitTests")]
public void CanCreateForgeUnitType_FromLoadedTypeString()
{
// Exact version exists: use the version as-specified
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test environment now includes 2.0.0 schemas, so the test cases were updated accordingly. For version-based tests, we replaced 1.0.2 with 99.9.9 to represent a "future version," since 99.9.9 is guaranteed not to exist and thus serves as a reliable placeholder. For the same reason, we use 0.0.1 as the "past version," ensuring it also doesn’t exist.

var unitType = Unit.ByTypeID($"{milimeters}-1.0.1");
Assert.NotNull(unitType);
Assert.AreEqual("Millimeters", unitType.Name);
Expand All @@ -774,18 +775,20 @@ public void CanCreateForgeUnitType_FromLoadedTypeString()
[Test, Category("UnitTests")]
public void CanCreateForgeUnitType_FromFutureTypeString()
{
var unitType = Unit.ByTypeID($"{milimeters}-1.0.2");
// Unknown future version: use the latest known version
var unitType = Unit.ByTypeID($"{milimeters}-99.9.9");
Assert.NotNull(unitType);
Assert.AreEqual("Millimeters", unitType.Name);
Assert.AreEqual($"{milimeters}-1.0.1", unitType.TypeId);
Assert.AreEqual($"{milimeters}-2.0.0", unitType.TypeId);
}
[Test, Category("UnitTests")]
public void CanCreateForgeUnitType_FromPastTypeString()
{
var unitType = Unit.ByTypeID($"{milimeters}-1.0.0");
// Unknown past version: use the latest known version
var unitType = Unit.ByTypeID($"{milimeters}-0.0.1");
Assert.NotNull(unitType);
Assert.AreEqual("Millimeters", unitType.Name);
Assert.AreEqual($"{milimeters}-1.0.1", unitType.TypeId);
Assert.AreEqual($"{milimeters}-2.0.0", unitType.TypeId);
}
[Test, Category("UnitTests")]
public void ForgeUnitEquality()
Expand Down
Loading
Loading