Compare commits

...

20 Commits

Author SHA1 Message Date
Björn Steinhagen e4f4a5533d dev -> main
.NET Build and Publish / build-connectors (push) Has been cancelled
.NET Build and Publish / deploy-installers (push) Has been cancelled
dev -> main
2026-03-19 17:15:49 +02:00
Björn Steinhagen 259b6a59f1 Merge pull request #1330 from specklesystems/main-dev
main-dev backmerge
2026-03-19 17:03:54 +02:00
Björn Steinhagen a4a2655a2a Merge remote-tracking branch 'origin/dev' into main-dev 2026-03-19 16:56:34 +02:00
Björn Steinhagen 9dd6397b01 fix(revit): restore sequential path prefix stripping (#1328) 2026-03-19 16:34:27 +02:00
Björn Steinhagen 2a04c02cba dev -> main (#1327)
.NET Build and Publish / build-connectors (push) Has been cancelled
.NET Build and Publish / deploy-installers (push) Has been cancelled
* fix(revit): anchors missing display value

* chore(deps): bump actions/upload-artifact from 6 to 7 (#1303)

Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* seq-tokens (#1308)

* chore(grasshopper): rewording (#1320)

* feat(grasshopper): adds property value field to filter objects (#1319)

Co-authored-by: Mucahit Bilal GOKER <51519350+bimgeek@users.noreply.github.com>

* feat(grasshopper): adds support for nested properties (#1313)

* feat(grasshopper): dot notation refactor

* fix(grasshopper): expand properties to not flatten

* fix(grasshopper): handle null inputs in property selector

* fix: merge conflicts

* chore(deps): bump geekyeggo/delete-artifact from 5 to 6 (#1321)

Bumps [geekyeggo/delete-artifact](https://github.com/geekyeggo/delete-artifact) from 5 to 6.
- [Release notes](https://github.com/geekyeggo/delete-artifact/releases)
- [Changelog](https://github.com/GeekyEggo/delete-artifact/blob/main/CHANGELOG.md)
- [Commits](https://github.com/geekyeggo/delete-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: geekyeggo/delete-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat(revit): parameter updater (#1307)

* Oguzhan/cnx 2941 update mechanism with fake data (#1305)

* WIP

* some untested tweaks

* feat(revit): implement update parameters binding for parameter updater (#1304)

* feat(parameter-updater): first stab at wiring up

* feat(connectors): dummy base binding

---------

Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>

* refactor: dedicated parameter updater binding (#1306)

* refactor: extract parameter updates to dedicated IParametersBinding

* chore: delete old update

* fix: disables pop-ups while updating

* fix(dui): inject base binding into RevitParametersBinding for toasts

* chore: sneaky unused using directives

* fix(revit): prioritize internal param names and deduplicate error toasts

* chore: addressed pr comments

---------

Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>

* feat(revit): adds analytical model to supported categories (#1325)

* fix(gh): restore deduplication and Revit proxy resolution (#1324)

* feat(gh): expand speckle props to now show set not union (#1322)

* feat(grasshopper): prevent dynamic components from dropping wires (#1326)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Mucahit Bilal GOKER <51519350+bimgeek@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jedd Morgan <45512892+JR-Morgan@users.noreply.github.com>
Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>
2026-03-19 13:22:57 +02:00
Björn Steinhagen 0670c1866f feat(grasshopper): prevent dynamic components from dropping wires (#1326) 2026-03-19 13:09:35 +02:00
Björn Steinhagen 60bb160a0d feat(gh): expand speckle props to now show set not union (#1322) 2026-03-19 11:41:14 +02:00
Björn Steinhagen 9127284774 fix(gh): restore deduplication and Revit proxy resolution (#1324) 2026-03-19 10:39:23 +02:00
Björn Steinhagen 0ea495e698 feat(revit): adds analytical model to supported categories (#1325) 2026-03-18 21:37:00 +02:00
Björn Steinhagen 8673879e48 feat(revit): parameter updater (#1307)
* Oguzhan/cnx 2941 update mechanism with fake data (#1305)

* WIP

* some untested tweaks

* feat(revit): implement update parameters binding for parameter updater (#1304)

* feat(parameter-updater): first stab at wiring up

* feat(connectors): dummy base binding

---------

Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>

* refactor: dedicated parameter updater binding (#1306)

* refactor: extract parameter updates to dedicated IParametersBinding

* chore: delete old update

* fix: disables pop-ups while updating

* fix(dui): inject base binding into RevitParametersBinding for toasts

* chore: sneaky unused using directives

* fix(revit): prioritize internal param names and deduplicate error toasts

* chore: addressed pr comments

---------

Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>
2026-03-18 12:32:31 +00:00
dependabot[bot] 8d04c9f9c8 chore(deps): bump geekyeggo/delete-artifact from 5 to 6 (#1321)
Bumps [geekyeggo/delete-artifact](https://github.com/geekyeggo/delete-artifact) from 5 to 6.
- [Release notes](https://github.com/geekyeggo/delete-artifact/releases)
- [Changelog](https://github.com/GeekyEggo/delete-artifact/blob/main/CHANGELOG.md)
- [Commits](https://github.com/geekyeggo/delete-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: geekyeggo/delete-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 15:46:37 +00:00
Björn Steinhagen d73ac2446a feat(grasshopper): adds support for nested properties (#1313)
* feat(grasshopper): dot notation refactor

* fix(grasshopper): expand properties to not flatten

* fix(grasshopper): handle null inputs in property selector

* fix: merge conflicts
2026-03-17 12:30:24 +00:00
Björn Steinhagen 3edc877466 feat(grasshopper): adds property value field to filter objects (#1319)
Co-authored-by: Mucahit Bilal GOKER <51519350+bimgeek@users.noreply.github.com>
2026-03-17 14:15:06 +02:00
Björn Steinhagen 9a3d41db3d chore(grasshopper): rewording (#1320) 2026-03-17 11:15:25 +02:00
Jedd Morgan eb166c0931 Merge pull request #1315 from specklesystems/jrm/main-dev
Main -> Dev
2026-03-11 13:00:34 +00:00
Jedd Morgan cb15106ca7 Merge branch 'dev' into jrm/main-dev 2026-03-11 12:59:14 +00:00
Jedd Morgan 3768157efe seq-tokens (#1308) 2026-03-11 12:52:47 +00:00
Jonathon Broughton ab0ebd5f46 fix(Navisworks): Corrects path hash collision handling (#1310)
.NET Build and Publish / build-connectors (push) Has been cancelled
.NET Build and Publish / deploy-installers (push) Has been cancelled
* update hash generation method in PathKey

* remove byte order mark in namespace declaration

* fix potential substring exception

* replace hash strings with path strings

* improve setter access modifiers

* remove byte order mark from import statement

* change delimiter in ToPathString method

* simplify ToPathString implementation
2026-03-06 14:32:45 +03:00
dependabot[bot] c51a0fda53 chore(deps): bump actions/upload-artifact from 6 to 7 (#1303)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-02 23:56:36 +00:00
Mucahit Bilal GOKER 4fdc522e42 fix(revit): anchors missing display value 2026-02-26 15:41:40 +03:00
30 changed files with 1204 additions and 266 deletions
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
run: ./build.ps1 zip
- name: ⬆️ Upload artifacts
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: output-${{ env.SEMVER }}
path: output/*.*
@@ -76,6 +76,6 @@ jobs:
display-workflow-run-url-interval: 10s
# Allows us to inspect the artifacts of failed builds, since this below step will be skipped if the above step fails
- uses: geekyeggo/delete-artifact@v5
- uses: geekyeggo/delete-artifact@v6
with:
name: output-*
@@ -1,4 +1,4 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging;
using Speckle.Connector.Navisworks.HostApp;
using Speckle.Connectors.Common.Builders;
using Speckle.Connectors.Common.Caching;
@@ -366,13 +366,13 @@ public class NavisworksRootObjectBuilder(
{
var groupKey = kvp.Key;
var geometries = kvp.Value;
var groupKeyHash = groupKey.ToHashString();
var groupKeyPath = groupKey.ToPathString();
var defProxy = new InstanceDefinitionProxy
{
name = $"Shared Geometry {groupKeyHash}",
name = $"Shared Geometry {groupKeyPath}",
objects = geometries.Select(g => g.applicationId ?? "").Where(id => !string.IsNullOrEmpty(id)).ToList(),
applicationId = $"{DEFINITION_ID_PREFIX}{groupKeyHash}",
applicationId = $"{DEFINITION_ID_PREFIX}{groupKeyPath}",
maxDepth = 0
};
@@ -2,6 +2,8 @@ using Autodesk.Revit.DB;
using Speckle.Connectors.DUI.Bridge;
using Speckle.Connectors.DUI.Models;
using Speckle.Connectors.DUI.Models.Card;
using Speckle.Connectors.DUI.Utils;
using Speckle.Connectors.Revit.HostApp;
using Speckle.Connectors.Revit.Plugin;
using Speckle.Connectors.RevitShared;
using Speckle.Connectors.RevitShared.Operations.Send.Filters;
@@ -24,6 +26,8 @@ internal sealed class BasicConnectorBindingRevit : IBasicConnectorBinding
private readonly ISpeckleApplication _speckleApplication;
private readonly ITopLevelExceptionHandler _topLevelExceptionHandler;
private readonly IRevitTask _revitTask;
private readonly ParameterUpdater _parameterUpdater;
private readonly IJsonSerializer _jsonSerializer;
public BasicConnectorBindingRevit(
DocumentModelStore store,
@@ -31,7 +35,9 @@ internal sealed class BasicConnectorBindingRevit : IBasicConnectorBinding
RevitContext revitContext,
ISpeckleApplication speckleApplication,
ITopLevelExceptionHandler topLevelExceptionHandler,
IRevitTask revitTask
IRevitTask revitTask,
ParameterUpdater parameterUpdater,
IJsonSerializer jsonSerializer
)
{
Name = "baseBinding";
@@ -41,6 +47,8 @@ internal sealed class BasicConnectorBindingRevit : IBasicConnectorBinding
_speckleApplication = speckleApplication;
_topLevelExceptionHandler = topLevelExceptionHandler;
_revitTask = revitTask;
_parameterUpdater = parameterUpdater;
_jsonSerializer = jsonSerializer;
Commands = new BasicConnectorBindingCommands(parent);
_store.DocumentChanged += (_, _) =>
@@ -0,0 +1,215 @@
using Autodesk.Revit.DB;
using Speckle.Connectors.DUI.Bindings;
using Speckle.Connectors.DUI.Bridge;
using Speckle.Connectors.DUI.Utils;
using Speckle.Connectors.Revit.HostApp;
using Speckle.Connectors.Revit.Operations.Receive;
using Speckle.Connectors.Revit.Plugin;
using Speckle.Connectors.RevitShared;
using Speckle.Converters.RevitShared.Helpers;
using Speckle.Sdk;
namespace Speckle.Connectors.Revit.Bindings;
public static class ParameterScopes
{
public const string INSTANCE = "Instance Parameters";
public const string TYPE = "Type Parameters";
public const string SYSTEM_TYPE = "System Type Parameters";
}
public record ParsedParameterPath(string Scope, string Category, string Name)
{
public string[] ToArray() => [Scope, Category, Name];
}
internal sealed class RevitParametersBinding : IParametersBinding
{
public string Name => "parametersBinding";
public IBrowserBridge Parent { get; }
private readonly RevitContext _revitContext;
private readonly ITopLevelExceptionHandler _topLevelExceptionHandler;
private readonly IRevitTask _revitTask;
private readonly ParameterUpdater _parameterUpdater;
private readonly IJsonSerializer _jsonSerializer;
private readonly IBasicConnectorBinding _baseBinding;
public RevitParametersBinding(
IBrowserBridge parent,
RevitContext revitContext,
ITopLevelExceptionHandler topLevelExceptionHandler,
IRevitTask revitTask,
ParameterUpdater parameterUpdater,
IJsonSerializer jsonSerializer,
IBasicConnectorBinding baseBinding
)
{
Parent = parent;
_revitContext = revitContext;
_topLevelExceptionHandler = topLevelExceptionHandler;
_revitTask = revitTask;
_parameterUpdater = parameterUpdater;
_jsonSerializer = jsonSerializer;
_baseBinding = baseBinding;
}
public async Task Update(string payload)
{
try
{
var wrapper = _jsonSerializer.Deserialize<ParameterChangesWrapper>(payload);
var requests = wrapper?.Changes;
if (requests == null || requests.Count == 0)
{
return;
}
var activeUIDoc =
_revitContext.UIApplication?.ActiveUIDocument
?? throw new SpeckleException("Unable to retrieve active UI document");
var doc = activeUIDoc.Document;
int successCount = 0;
List<string> errors = [];
await _revitTask
.RunAsync(() =>
{
using var t = new Transaction(doc, "Speckle: Apply Parameter Changes");
// silence pop-ups like "duplicate mark values" etc. which blocks our param updates
var failureOptions = t.GetFailureHandlingOptions();
failureOptions.SetFailuresPreprocessor(new HideWarningsFailuresPreprocessor());
t.SetFailureHandlingOptions(failureOptions);
t.Start();
foreach (var request in requests)
{
if (!TryValidateAndParseRequest(doc, request, out var element, out var parsedPath, out var errorMessage))
{
errors.Add(errorMessage!);
continue;
}
object? rawValue = request.To;
if (rawValue is Newtonsoft.Json.Linq.JValue jValue)
{
rawValue = jValue.Value;
}
var result = _parameterUpdater.Update(
element!,
parsedPath!.ToArray(),
rawValue,
request.InternalDefinitionName
);
if (result.IsSuccess)
{
successCount++;
}
else
{
errors.Add(result.ErrorMessage ?? "Unknown error");
}
}
t.Commit();
})
.ConfigureAwait(false);
if (errors.Count > 0)
{
var groupedErrors = errors.GroupBy(e => e).Select(g => g.Count() > 1 ? $"{g.Count()} x {g.Key}" : g.Key);
await _baseBinding.Commands.SetGlobalNotification(
ToastNotificationType.WARNING,
"Update Completed with Issues",
$"Applied {successCount} updates. Encountered {errors.Count} errors: {string.Join(" | ", groupedErrors)}",
autoClose: false
);
}
else
{
await _baseBinding.Commands.SetGlobalNotification(
ToastNotificationType.SUCCESS,
"Parameters Updated",
$"Successfully applied {successCount} updates."
);
}
}
catch (Exception ex)
{
_topLevelExceptionHandler.CatchUnhandled(
() => throw new SpeckleException("Failed to apply parameter updates", ex)
);
}
}
private bool TryValidateAndParseRequest(
Document doc,
ParameterChangeRequest request,
out Element? element,
out ParsedParameterPath? parsedPath,
out string? errorMessage
)
{
element = null;
parsedPath = null;
errorMessage = null;
if (string.IsNullOrEmpty(request.ApplicationId))
{
errorMessage = "Missing ApplicationId.";
return false;
}
var elementId = ElementIdHelper.GetElementIdFromUniqueId(doc, request.ApplicationId);
if (elementId == null)
{
errorMessage = $"Element UniqueId not found: {request.ApplicationId}";
return false;
}
element = doc.GetElement(elementId);
if (element == null)
{
errorMessage = $"Element is null for Id: {elementId}";
return false;
}
var rawPath = request.Path;
if (string.IsNullOrEmpty(rawPath))
{
errorMessage = "Path is missing.";
return false;
}
if (rawPath.StartsWith("properties.", StringComparison.InvariantCultureIgnoreCase))
{
rawPath = rawPath[11..];
}
if (rawPath.StartsWith("parameters.", StringComparison.InvariantCultureIgnoreCase))
{
rawPath = rawPath[11..];
}
var pathParts = rawPath.Split(['.'], 3);
if (pathParts.Length != 3)
{
errorMessage = $"Path must have 3 parts. Got: '{rawPath}'";
return false;
}
parsedPath = new ParsedParameterPath(pathParts[0], pathParts[1], pathParts[2]);
return true;
}
}
public class ParameterChangesWrapper
{
public List<ParameterChangeRequest>? Changes { get; set; }
}
@@ -39,6 +39,7 @@ internal sealed class RevitSendBinding : RevitBaseBinding, ISendBinding
private readonly LinkedModelHandler _linkedModelHandler;
private readonly IThreadContext _threadContext;
private readonly ISendOperationManagerFactory _sendOperationManagerFactory;
private readonly ParameterUpdater _parameterUpdater;
private bool _isDocChangedSubscribed;
private EventHandler<Autodesk.Revit.DB.Events.DocumentChangedEventArgs>? _documentChangedHandler;
private readonly ConnectorConfig _config;
@@ -67,6 +68,7 @@ internal sealed class RevitSendBinding : RevitBaseBinding, ISendBinding
IThreadContext threadContext,
IRevitTask revitTask,
ISendOperationManagerFactory sendOperationManagerFactory,
ParameterUpdater parameterUpdater,
IConfigStore configStore
)
: base("sendBinding", bridge)
@@ -84,6 +86,7 @@ internal sealed class RevitSendBinding : RevitBaseBinding, ISendBinding
_linkedModelHandler = linkedModelHandler;
_threadContext = threadContext;
_sendOperationManagerFactory = sendOperationManagerFactory;
_parameterUpdater = parameterUpdater;
_config = configStore.GetConnectorConfig();
Commands = new SendBindingUICommands(bridge);
@@ -198,6 +201,44 @@ internal sealed class RevitSendBinding : RevitBaseBinding, ISendBinding
);
}
public async Task UpdateParameters(List<ParameterChangeRequest> changes)
{
var document = _revitContext.UIApplication?.ActiveUIDocument?.Document;
if (document == null)
{
throw new SpeckleException("No document is active.");
}
await _threadContext.RunOnMainAsync(() =>
{
using var transaction = new Transaction(document, "Speckle Parameter Updates");
transaction.Start();
foreach (var change in changes)
{
var element = document.GetElement(change.ApplicationId);
if (element == null)
{
continue;
}
var path = ParsePath(change.Path);
var result = _parameterUpdater.Update(element, path, change.To);
}
transaction.Commit();
return Task.FromResult(true);
});
}
private string[] ParsePath(string concatenatedPath)
{
// "properties.Parameters.Type Parameters.Other.Family Name"
// → ["Type Parameters", "Other", "Family Name"]
var segments = concatenatedPath.Split('.');
return segments.Skip(2).ToArray();
}
private static (string? fileName, long? fileBytes) GetFileInfo(Document document)
{
string fullPath = document.PathName;
@@ -53,6 +53,9 @@ public static class ServiceRegistration
serviceCollection.AddSingleton<IBinding>(sp => sp.GetRequiredService<IBasicConnectorBinding>());
serviceCollection.AddSingleton<IBasicConnectorBinding, BasicConnectorBindingRevit>();
serviceCollection.AddSingleton<IBinding>(sp => sp.GetRequiredService<IParametersBinding>());
serviceCollection.AddSingleton<IParametersBinding, RevitParametersBinding>();
// serviceCollection.AddSingleton<IAppIdleManager, RevitIdleManager>();
// send operation and dependencies
@@ -66,6 +69,7 @@ public static class ServiceRegistration
serviceCollection.AddSingleton<ToSpeckleSettingsManager>();
serviceCollection.AddSingleton<ToHostSettingsManager>();
serviceCollection.AddSingleton<LinkedModelHandler>();
serviceCollection.AddSingleton<ParameterUpdater>();
// receive operation and dependencies
serviceCollection.AddScoped<IHostObjectBuilder, RevitHostObjectBuilder>();
@@ -0,0 +1,9 @@
namespace Speckle.Connectors.Revit.HostApp;
public class ParameterChangeRequest
{
public required string ApplicationId { get; init; }
public required string Path { get; init; }
public object? To { get; init; }
public string? InternalDefinitionName { get; set; }
}
@@ -0,0 +1,375 @@
using Microsoft.Extensions.Logging;
using Speckle.Converters.RevitShared.Helpers;
using Speckle.Converters.RevitShared.Services;
using Speckle.Sdk;
using DB = Autodesk.Revit.DB;
namespace Speckle.Connectors.Revit.HostApp;
/// <summary>
/// Updates parameter values on Revit elements. Mirrors the structure from ParameterExtractor.
/// Path format: ["Instance Parameters" | "Type Parameters" | "System Type Parameters", "GroupName", "ParameterName"]
/// </summary>
public class ParameterUpdater
{
private readonly RevitContext _revitContext;
private readonly ScalingServiceToHost _scalingServiceToHost;
private readonly ILogger<ParameterUpdater> _logger;
public ParameterUpdater(
RevitContext revitContext,
ScalingServiceToHost scalingServiceToHost,
ILogger<ParameterUpdater> logger
)
{
_revitContext = revitContext;
_scalingServiceToHost = scalingServiceToHost;
_logger = logger;
}
public UpdateResult Update(DB.Element element, string[] path, object? newValue, string? internalDefinitionName = null)
{
// path = ["Instance Parameters", "Identity Data", "Mark"]
if (path.Length != 3)
{
return UpdateResult.Fail(
$"Path must have exactly 3 segments: [scope, group, parameter]. Got: {string.Join(" ", path)}"
);
}
var parameterScope = path[0]; // "Instance Parameters" | "Type Parameters" | "System Type Parameters"
var groupName = path[1]; // "Identity Data", "Dimensions", etc.
var parameterKey = path[2]; // human-readable name (or internalDefinitionName if collision)
// get target element based on scope
var targetElement = GetTargetElement(element, parameterScope);
if (targetElement == null)
{
return UpdateResult.Fail($"Could not resolve target for scope: {parameterScope}");
}
// find the parameter (now using the robust lookup)
var parameter = FindParameter(targetElement, groupName, parameterKey, internalDefinitionName);
if (parameter == null)
{
return UpdateResult.Fail($"Parameter not found: {parameterKey} in group {groupName}");
}
if (parameter.IsReadOnly)
{
return UpdateResult.Fail($"Parameter '{parameterKey}' is readonly in Revit");
}
return SetParameterValue(parameter, newValue);
}
private DB.Element? GetTargetElement(DB.Element element, string scope) =>
scope switch
{
"Instance Parameters" => element,
"Type Parameters" => GetTypeElement(element),
"System Type Parameters" => GetSystemTypeElement(element),
_ => null
};
private DB.Element? GetTypeElement(DB.Element element)
{
var typeId = element.GetTypeId();
if (typeId == DB.ElementId.InvalidElementId)
{
return null;
}
return _revitContext.UIApplication?.ActiveUIDocument.Document.GetElement(typeId);
}
private DB.Element? GetSystemTypeElement(DB.Element element)
{
var system = GetMEPSystem(element);
if (system == null)
{
return null;
}
return _revitContext.UIApplication?.ActiveUIDocument.Document.GetElement(system.GetTypeId());
}
private DB.MEPSystem? GetMEPSystem(DB.Element element)
{
if (element is DB.MEPCurve curve)
{
return curve.MEPSystem;
}
if (element is DB.FamilyInstance fi)
{
var cm = fi.MEPModel?.ConnectorManager;
if (cm != null)
{
foreach (DB.Connector conn in cm.Connectors)
{
if (conn.ConnectorType == DB.ConnectorType.Physical && conn.IsConnected && conn.MEPSystem != null)
{
return conn.MEPSystem;
}
}
}
}
return null;
}
private DB.Parameter? FindParameter(
DB.Element element,
string groupName,
string parameterKey,
string? internalDefinitionName
)
{
// fast path: direct lookup using the internal definition name
if (!string.IsNullOrEmpty(internalDefinitionName))
{
// try as BuiltInParameter enum
if (Enum.TryParse(internalDefinitionName, out DB.BuiltInParameter bip) && bip != DB.BuiltInParameter.INVALID)
{
var param = element.get_Parameter(bip);
if (param != null)
{
return param;
}
}
// try as shared parameter Guid
if (Guid.TryParse(internalDefinitionName, out Guid guid))
{
var param = element.get_Parameter(guid);
if (param != null)
{
return param;
}
}
}
// fallback: iteration for project parameters or missing internal names
DB.Parameter? fallbackParameter = null;
foreach (DB.Parameter parameter in element.Parameters)
{
var definition = parameter.Definition;
if (definition == null)
{
continue;
}
var currentInternalName = GetInternalDefinitionName(parameter);
var humanName = definition.Name;
// exact internal name match (covers project params that aren't BuiltIn/Shared)
if (!string.IsNullOrEmpty(internalDefinitionName) && currentInternalName == internalDefinitionName)
{
return parameter;
}
// fallback human-readable name matching
if (humanName == parameterKey || currentInternalName == parameterKey)
{
var paramGroup = definition.GetGroupTypeId();
var groupLabel = DB.LabelUtils.GetLabelForGroup(paramGroup);
if (groupLabel == groupName)
{
return parameter;
}
fallbackParameter ??= parameter;
}
}
return fallbackParameter;
}
private string GetInternalDefinitionName(DB.Parameter parameter)
{
if (parameter.Definition is DB.InternalDefinition internalDef)
{
var bip = internalDef.BuiltInParameter;
if (bip != DB.BuiltInParameter.INVALID)
{
return bip.ToString();
}
}
return parameter.Definition.Name;
}
private UpdateResult SetParameterValue(DB.Parameter parameter, object? newValue)
{
var paramName = parameter.Definition.Name;
if (newValue == null)
{
if (parameter.StorageType == DB.StorageType.String)
{
return parameter.Set(string.Empty)
? UpdateResult.Success()
: UpdateResult.Fail("Failed to clear string parameter");
}
return UpdateResult.Fail("Cannot set non-string parameter to null");
}
try
{
var success = parameter.StorageType switch
{
DB.StorageType.String => parameter.Set(newValue.ToString()),
DB.StorageType.Integer => SetIntegerValue(parameter, newValue),
DB.StorageType.Double => SetDoubleValue(parameter, newValue),
DB.StorageType.ElementId => SetElementIdValue(parameter, newValue),
_ => false
};
return success ? UpdateResult.Success() : UpdateResult.Fail($"Failed to set parameter value to: {newValue}");
}
catch (Exception ex) when (!ex.IsFatal())
{
_logger.LogWarning(ex, "Failed to set parameter value");
return UpdateResult.Fail($"Exception for '{paramName}': {ex.Message}");
}
}
private bool SetIntegerValue(DB.Parameter parameter, object newValue)
{
if (newValue is int i)
{
return parameter.Set(i);
}
if (newValue is bool b)
{
return parameter.Set(b ? 1 : 0);
}
if (int.TryParse(newValue.ToString(), out var parsed))
{
return parameter.Set(parsed);
}
var strValue = newValue.ToString();
if (strValue == "Yes")
{
return parameter.Set(1);
}
if (strValue == "No")
{
return parameter.Set(0);
}
return parameter.SetValueString(strValue);
}
private bool SetDoubleValue(DB.Parameter parameter, object newValue)
{
double doubleValue;
if (newValue is double d)
{
doubleValue = d;
}
else if (newValue is int intVal)
{
doubleValue = intVal;
}
else if (double.TryParse(newValue.ToString(), out var parsed))
{
doubleValue = parsed;
}
else
{
return false;
}
var internalValue = _scalingServiceToHost.ScaleToNative(doubleValue, parameter.GetUnitTypeId());
return parameter.Set(internalValue);
}
private bool SetElementIdValue(DB.Parameter parameter, object newValue)
{
if (newValue is DB.ElementId eid)
{
return parameter.Set(eid);
}
// TODO: check this fckr later
// if (newValue is long idInt)
// {
// #if REVIT2024_OR_GREATER
// return parameter.Set(new DB.ElementId(idInt));
// #else
// return parameter.Set(new DB.ElementId((long)idInt));
// #endif
// }
//
// if (long.TryParse(newValue.ToString(), out var parsedId))
// {
// #if REVIT2024_OR_GREATER
// return parameter.Set(new DB.ElementId(parsedId));
// #else
// return parameter.Set(new DB.ElementId((long)parsedId));
// #endif
// }
var elementName = newValue.ToString();
if (elementName != null)
{
var foundElement = FindElementByName(elementName);
if (foundElement != null)
{
return parameter.Set(foundElement.Id);
}
}
return false;
}
private DB.Element? FindElementByName(string name)
{
var doc = _revitContext.UIApplication?.ActiveUIDocument.Document;
using var materialCollector = new DB.FilteredElementCollector(doc);
var material = materialCollector.OfClass(typeof(DB.Material)).FirstOrDefault(e => e.Name == name);
if (material != null)
{
return material;
}
using var levelCollector = new DB.FilteredElementCollector(doc);
var level = levelCollector.OfClass(typeof(DB.Level)).FirstOrDefault(e => e.Name == name);
if (level != null)
{
return level;
}
using var phaseCollector = new DB.FilteredElementCollector(doc);
var phase = phaseCollector.OfClass(typeof(DB.Phase)).FirstOrDefault(e => e.Name == name);
if (phase != null)
{
return phase;
}
return null;
}
}
// TODO: we will see, extract this guy out
public readonly struct UpdateResult
{
public bool IsSuccess { get; }
public string? ErrorMessage { get; }
private UpdateResult(bool success, string? error)
{
IsSuccess = success;
ErrorMessage = error;
}
public static UpdateResult Success() => new(true, null);
public static UpdateResult Fail(string message) => new(false, message);
}
@@ -45,6 +45,8 @@ public static class SupportedCategoriesUtils
#else
category.Name == "OST_Grids";
#endif
case CategoryType.AnalyticalModel:
return true;
case CategoryType.Model:
return
@@ -15,6 +15,7 @@
<ItemGroup>
<Compile Include="$(MSBuildThisFileDirectory)Bindings\BasicConnectorBindingRevit.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Bindings\RevitBaseBinding.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Bindings\RevitParametersBinding.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Bindings\RevitReceiveBinding.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Bindings\SelectionBinding.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Bindings\RevitSendBinding.cs" />
@@ -27,6 +28,8 @@
<Compile Include="$(MSBuildThisFileDirectory)HostApp\FamilyTransformUtils.cs" />
<Compile Include="$(MSBuildThisFileDirectory)HostApp\LevelUnpacker.cs" />
<Compile Include="$(MSBuildThisFileDirectory)HostApp\LinkedModelHandler.cs" />
<Compile Include="$(MSBuildThisFileDirectory)HostApp\ParameterChangeRequest.cs" />
<Compile Include="$(MSBuildThisFileDirectory)HostApp\ParameterUpdater.cs" />
<Compile Include="$(MSBuildThisFileDirectory)HostApp\RevitFamilyBaker.cs" />
<Compile Include="$(MSBuildThisFileDirectory)HostApp\RevitMaterialBaker.cs" />
<Compile Include="$(MSBuildThisFileDirectory)HostApp\RevitViewBaker.cs" />
@@ -26,8 +26,7 @@ public class ExpandCollection : GH_Component, IGH_VariableParameterComponent
public override Guid ComponentGuid => GetType().GUID;
protected override Bitmap Icon => Resources.speckle_collections_expand;
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
protected override void RegisterInputParams(GH_InputParamManager pManager) =>
pManager.AddParameter(
new SpeckleCollectionParam(GH_ParamAccess.item),
"Collection",
@@ -35,7 +34,6 @@ public class ExpandCollection : GH_Component, IGH_VariableParameterComponent
"The Collection you want to expand",
GH_ParamAccess.item
);
}
protected override void RegisterOutputParams(GH_OutputParamManager pManager) { }
@@ -180,27 +178,68 @@ public class ExpandCollection : GH_Component, IGH_VariableParameterComponent
private void CreateOutputs(List<OutputParamWrapper> outputParams)
{
// TODO: better, nicer handling of creation/removal
while (Params.Output.Count > 0)
{
Params.UnregisterOutputParameter(Params.Output[^1]);
}
bool needsMaintenance = false;
foreach (var newParam in outputParams)
// remove old parameters that are no longer present
for (int i = Params.Output.Count - 1; i >= 0; i--)
{
var param = new SpeckleOutputParam
var existingParam = Params.Output[i];
if (outputParams.All(p => p.Param.Name != existingParam.Name))
{
Name = newParam.Param.Name,
NickName = newParam.Param.NickName,
MutableNickName = false,
Access = newParam.Param.Access
};
Params.RegisterOutputParam(param);
Params.UnregisterOutputParameter(existingParam);
needsMaintenance = true;
}
}
Params.OnParametersChanged();
VariableParameterMaintenance();
ExpireSolution(false);
// add new parameters and update existing ones in place
for (int i = 0; i < outputParams.Count; i++)
{
var targetParam = outputParams[i].Param;
var existingParam = Params.Output.FirstOrDefault(p => p.Name == targetParam.Name);
if (existingParam != null)
{
if (existingParam.Access != targetParam.Access)
{
existingParam.Access = targetParam.Access;
needsMaintenance = true;
}
if (existingParam.NickName != targetParam.NickName)
{
existingParam.NickName = targetParam.NickName;
needsMaintenance = true;
}
int currentIndex = Params.Output.IndexOf(existingParam);
if (currentIndex != i)
{
Params.Output.RemoveAt(currentIndex);
Params.Output.Insert(i, existingParam);
needsMaintenance = true;
}
}
else
{
var newParam = new SpeckleOutputParam
{
Name = targetParam.Name,
NickName = targetParam.NickName,
MutableNickName = false,
Access = targetParam.Access,
Description = targetParam.Description
};
Params.RegisterOutputParam(newParam, i);
needsMaintenance = true;
}
}
if (needsMaintenance)
{
Params.OnParametersChanged();
VariableParameterMaintenance();
ExpireSolution(false);
}
}
public void VariableParameterMaintenance() { }
@@ -45,13 +45,13 @@ public class CreateSpeckleProperties : VariableParameterComponentBase
protected override void SolveInstance(IGH_DataAccess da)
{
var properties = new Dictionary<string, ISpecklePropertyGoo>();
var groupGoo = new SpecklePropertyGroupGoo();
// Validate for duplicate names
var paramNames = Params.Input.Select(p => p.NickName).ToList();
var duplicates = paramNames.GroupBy(x => x).Where(g => g.Count() > 1).Select(g => g.Key);
var duplicates = paramNames.GroupBy(x => x).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
if (duplicates.Any())
if (duplicates.Count != 0)
{
AddRuntimeMessage(
GH_RuntimeMessageLevel.Error,
@@ -77,11 +77,10 @@ public class CreateSpeckleProperties : VariableParameterComponentBase
if (propertyValue != null)
{
properties[paramName] = propertyValue;
groupGoo.SetValueByPath(paramName, propertyValue);
}
}
var groupGoo = new SpecklePropertyGroupGoo(properties);
da.SetData(0, groupGoo);
}
@@ -40,57 +40,91 @@ public class ExpandSpeckleProperties : GH_Component, IGH_VariableParameterCompon
protected override void SolveInstance(IGH_DataAccess da)
{
// ALWAYS run port generation on the first iteration, BEFORE validating the current item
// ensure that a null at index 0 doesn't prevent ports from being created.
if (da.Iteration == 0)
{
// gather all property groups from the input (skipNulls = true)
var allData = Params.Input[0].VolatileData.AllData(true).OfType<SpecklePropertyGroupGoo>().ToList();
// guard against empty data on file load / async operations to prevent stale ports from dropping (CNX-3245)
if (allData.Count > 0)
{
var outputParamsDict = new Dictionary<string, OutputParamWrapper>();
foreach (var propGroup in allData)
{
if (propGroup?.Value == null)
{
continue;
}
foreach (var key in propGroup.Value.Keys)
{
ISpecklePropertyGoo value = propGroup.Value[key];
object? outputValue = value switch
{
SpecklePropertyGoo prop => prop.Value,
SpecklePropertyGroupGoo pg => pg,
_ => value
};
if (!outputParamsDict.TryGetValue(key, out var existingWrapper))
{
var param = new SpeckleOutputParam
{
Name = key,
NickName = key,
Access = outputValue is IList ? GH_ParamAccess.list : GH_ParamAccess.item
};
outputParamsDict[key] = new OutputParamWrapper(param, outputValue);
}
else if (existingWrapper.Param.Access == GH_ParamAccess.item && outputValue is IList)
{
existingWrapper.Param.Access = GH_ParamAccess.list;
}
}
}
var outputParams = outputParamsDict.Values.ToList();
Name = $"Properties ({outputParams.Count})";
NickName = Name;
if (OutputMismatch(outputParams))
{
OnPingDocument()?.ScheduleSolution(5, _ => CreateOutputs(outputParams));
return;
}
}
}
SpecklePropertyGroupGoo? properties = null;
if (!da.GetData(0, ref properties) || properties?.Value == null)
{
return;
}
Name = $"Properties ({properties.Value.Count})";
NickName = Name;
var outputParams = new List<OutputParamWrapper>();
foreach (var key in properties.Value.Keys)
for (int i = 0; i < Params.Output.Count; i++)
{
ISpecklePropertyGoo value = properties.Value[key];
object? outputValue = value switch
{
SpecklePropertyGoo prop => prop.Value,
SpecklePropertyGroupGoo propGroup => propGroup,
_ => value
};
var outParam = Params.Output[i];
var param = new SpeckleOutputParam
if (properties.Value.TryGetValue(outParam.Name, out ISpecklePropertyGoo? value))
{
Name = key,
NickName = key,
Access = outputValue is IList ? GH_ParamAccess.list : GH_ParamAccess.item
};
outputParams.Add(new OutputParamWrapper(param, outputValue));
}
// handle parameter creation/update (only on first iteration)
if (da.Iteration == 0 && OutputMismatch(outputParams))
{
OnPingDocument()?.ScheduleSolution(5, _ => CreateOutputs(outputParams));
return; // exit early
}
// only set data if we have the correct parameter structure
if (Params.Output.Count == outputParams.Count)
{
for (int i = 0; i < outputParams.Count; i++)
{
var outputParam = outputParams[i];
switch (outputParam.Param.Access)
object? outputValue = value switch
{
case GH_ParamAccess.item:
da.SetData(i, outputParam.Value);
break;
case GH_ParamAccess.list:
da.SetDataList(i, outputParam.Value as IList ?? new List<object?>());
break;
SpecklePropertyGoo prop => prop.Value,
SpecklePropertyGroupGoo propGroup => propGroup,
_ => value
};
if (outParam.Access == GH_ParamAccess.item)
{
da.SetData(i, outputValue);
}
else
{
da.SetDataList(i, outputValue as IList ?? new List<object?>());
}
}
}
@@ -101,29 +135,68 @@ public class ExpandSpeckleProperties : GH_Component, IGH_VariableParameterCompon
/// </summary>
private void CreateOutputs(List<OutputParamWrapper> outputParams)
{
// remove all existing output parameters
while (Params.Output.Count > 0)
{
Params.UnregisterOutputParameter(Params.Output[^1]);
}
bool needsMaintenance = false;
// add new output parameters
foreach (var newParam in outputParams)
// remove old parameters that are no longer present
for (int i = Params.Output.Count - 1; i >= 0; i--)
{
var param = new SpeckleOutputParam
var existingParam = Params.Output[i];
if (outputParams.All(p => p.Param.Name != existingParam.Name))
{
Name = newParam.Param.Name,
NickName = newParam.Param.NickName,
MutableNickName = false,
Access = newParam.Param.Access
};
Params.RegisterOutputParam(param);
Params.UnregisterOutputParameter(existingParam);
needsMaintenance = true;
}
}
// notify gh of parameter changes
Params.OnParametersChanged();
VariableParameterMaintenance();
ExpireSolution(false);
// add new parameters and update existing ones in place to preserve wires
for (int i = 0; i < outputParams.Count; i++)
{
var targetParam = outputParams[i].Param;
var existingParam = Params.Output.FirstOrDefault(p => p.Name == targetParam.Name);
if (existingParam != null)
{
if (existingParam.Access != targetParam.Access)
{
existingParam.Access = targetParam.Access;
needsMaintenance = true;
}
if (existingParam.NickName != targetParam.NickName)
{
existingParam.NickName = targetParam.NickName;
needsMaintenance = true;
}
int currentIndex = Params.Output.IndexOf(existingParam);
if (currentIndex != i)
{
Params.Output.RemoveAt(currentIndex);
Params.Output.Insert(i, existingParam);
needsMaintenance = true;
}
}
else
{
var newParam = new SpeckleOutputParam
{
Name = targetParam.Name,
NickName = targetParam.NickName,
MutableNickName = false,
Access = targetParam.Access
};
Params.RegisterOutputParam(newParam, i);
needsMaintenance = true;
}
}
if (needsMaintenance)
{
// notify gh of parameter changes
Params.OnParametersChanged();
VariableParameterMaintenance();
ExpireSolution(false);
}
}
/// <summary>
@@ -37,19 +37,27 @@ public class FilterSpeckleObjects : GH_Component, IGH_VariableParameterComponent
pManager.AddTextParameter(
"Property Key",
"P",
"K",
"Find objects with a property that has a matching key",
GH_ParamAccess.item
);
Params.Input[2].Optional = true;
pManager.AddTextParameter(
"Property Value",
"V",
"Find objects with a property that has a matching value",
GH_ParamAccess.item
);
Params.Input[3].Optional = true;
pManager.AddTextParameter(
"Material Name",
"M",
"Find objects with a render material that has a matching name",
GH_ParamAccess.item
);
Params.Input[3].Optional = true;
Params.Input[4].Optional = true;
}
protected override void RegisterOutputParams(GH_OutputParamManager pManager)
@@ -89,10 +97,12 @@ public class FilterSpeckleObjects : GH_Component, IGH_VariableParameterComponent
string name = "";
dataAccess.GetData(1, ref name);
string property = "";
dataAccess.GetData(2, ref property);
string propertyKey = "";
dataAccess.GetData(2, ref propertyKey);
string propertyValue = "";
dataAccess.GetData(3, ref propertyValue);
string material = "";
dataAccess.GetData(3, ref material);
dataAccess.GetData(4, ref material);
// optional parameters - only read if they've been added via ⊕
string appId = "";
@@ -118,7 +128,19 @@ public class FilterSpeckleObjects : GH_Component, IGH_VariableParameterComponent
foreach (SpeckleWrapper wrapper in objects.Cast<SpeckleWrapper>())
{
if (MatchesAllFilters(wrapper, name, property, material, appId, filterByAppId, speckleId, filterBySpeckleId))
if (
MatchesAllFilters(
wrapper,
name,
propertyKey,
propertyValue,
material,
appId,
filterByAppId,
speckleId,
filterBySpeckleId
)
)
{
matchedObjects.Add(wrapper);
}
@@ -149,7 +171,8 @@ public class FilterSpeckleObjects : GH_Component, IGH_VariableParameterComponent
private bool MatchesAllFilters(
SpeckleWrapper wrapper,
string name,
string property,
string propertyKey,
string propertyValue,
string material,
string appId,
bool filterByAppId,
@@ -164,7 +187,7 @@ public class FilterSpeckleObjects : GH_Component, IGH_VariableParameterComponent
}
// filter by property
if (!MatchesPropertyFilter(wrapper, property))
if (!MatchesPropertyFilter(wrapper, propertyKey, propertyValue))
{
return false;
}
@@ -190,9 +213,12 @@ public class FilterSpeckleObjects : GH_Component, IGH_VariableParameterComponent
return true;
}
private bool MatchesPropertyFilter(SpeckleWrapper wrapper, string property)
private bool MatchesPropertyFilter(SpeckleWrapper wrapper, string propertyKey, string propertyValue)
{
if (string.IsNullOrEmpty(property))
bool hasKeyFilter = !string.IsNullOrEmpty(propertyKey);
bool hasValueFilter = !string.IsNullOrEmpty(propertyValue);
if (!hasKeyFilter && !hasValueFilter)
{
return true;
}
@@ -208,8 +234,30 @@ public class FilterSpeckleObjects : GH_Component, IGH_VariableParameterComponent
return false;
}
// use flattened properties to search ALL nested property keys
return properties.Flatten().Keys.Any(key => MatchesSearchPattern(property, key));
var flattenedProps = properties.Flatten();
// Check both property key and value simultaneously
if (hasKeyFilter && hasValueFilter)
{
return flattenedProps.Any(kvp =>
MatchesSearchPattern(propertyKey, kvp.Key)
&& MatchesSearchPattern(propertyValue, kvp.Value.Value?.ToString() ?? "")
);
}
// Check just property key
if (hasKeyFilter)
{
return flattenedProps.Keys.Any(key => MatchesSearchPattern(propertyKey, key));
}
// Check just property value
if (hasValueFilter)
{
return flattenedProps.Values.Any(val => MatchesSearchPattern(propertyValue, val.Value?.ToString() ?? ""));
}
return false;
}
private bool MatchesMaterialFilter(SpeckleWrapper wrapper, string material)
@@ -258,23 +306,23 @@ public class FilterSpeckleObjects : GH_Component, IGH_VariableParameterComponent
return false;
}
// only allow inserting after the fixed parameters (index 4+)
if (index < 4)
// only allow inserting after the fixed parameters (index 5+)
if (index < 5)
{
return false;
}
// check how many optional params are already added (total inputs - 4 fixed)
int addedOptionalCount = Params.Input.Count - 4;
// check how many optional params are already added (total inputs - 5 fixed)
int addedOptionalCount = Params.Input.Count - 5;
// we have 2 optional parameters available
return addedOptionalCount < 2;
}
public bool CanRemoveParameter(GH_ParameterSide side, int index) =>
// only allow removing optional input parameters (index 4+)
// only allow removing optional input parameters (index 5+)
side == GH_ParameterSide.Input
&& index >= 4;
&& index >= 5;
/// <remarks>
/// The ternary operator for NickName is needed due to a Grasshopper quirk where
@@ -316,12 +364,12 @@ public class FilterSpeckleObjects : GH_Component, IGH_VariableParameterComponent
return new Param_String();
}
public bool DestroyParameter(GH_ParameterSide side, int index) => side == GH_ParameterSide.Input && index >= 4;
public bool DestroyParameter(GH_ParameterSide side, int index) => side == GH_ParameterSide.Input && index >= 5;
public void VariableParameterMaintenance()
{
// ensure all optional parameters stay marked as optional
for (int i = 4; i < Params.Input.Count; i++)
for (int i = 5; i < Params.Input.Count; i++)
{
Params.Input[i].Optional = true;
}
@@ -24,14 +24,14 @@ public class PropertyGroupPathsSelector : ValueSet<IGH_Goo>
protected override void LoadVolatileData()
{
List<SpecklePropertyGroupGoo> propertyGroups = VolatileData
.AllData(true)
.OfType<SpecklePropertyGroupGoo>()
.ToList();
var allData = VolatileData.AllData(true).ToList();
if (VolatileDataCount > propertyGroups.Count)
List<SpecklePropertyGroupGoo> propertyGroups = allData.OfType<SpecklePropertyGroupGoo>().ToList();
// compare against allData.Count to safely ignore nulls (CNX-3176)
if (allData.Count > propertyGroups.Count)
{
AddRuntimeMessage(GH_RuntimeMessageLevel.Error, $"Only Speckle Properties are accepted as inputs.");
AddRuntimeMessage(GH_RuntimeMessageLevel.Error, "Only Speckle Properties are accepted as inputs.");
return;
}
@@ -35,10 +35,8 @@ public class QueryProperties : GH_Component
pManager.AddTextParameter("Keys", "K", "Property keys to filter by", GH_ParamAccess.list);
}
protected override void RegisterOutputParams(GH_OutputParamManager pManager)
{
protected override void RegisterOutputParams(GH_OutputParamManager pManager) =>
pManager.AddGenericParameter("Values", "V", "The values of the specified keys", GH_ParamAccess.list);
}
protected override void SolveInstance(IGH_DataAccess da)
{
@@ -64,7 +62,7 @@ public class QueryProperties : GH_Component
List<object?> values = [];
foreach (string key in keys)
{
var value = GetValueByPath(properties, key);
var value = properties.GetValueByPath(key);
var extractedValue = (value as SpecklePropertyGoo)?.Value ?? value;
// NOTE: if property is a list, flatten into individual items for native gh list access
@@ -80,31 +78,4 @@ public class QueryProperties : GH_Component
da.SetDataList(0, values);
}
public static ISpecklePropertyGoo? GetValueByPath(SpecklePropertyGroupGoo data, string path)
{
string[] keys = path.Split('.');
ISpecklePropertyGoo? current = data;
foreach (var key in keys)
{
if (current is SpecklePropertyGroupGoo dict)
{
if (dict.Value.TryGetValue(key, out ISpecklePropertyGoo? next))
{
current = next;
}
else
{
return null;
}
}
else
{
return null; // Current is not a dictionary, path is invalid
}
}
return current;
}
}
@@ -30,17 +30,30 @@ public class SpecklePropertiesPassthrough : SpeckleSolveInstance
private enum PropertyMode
{
Merge, // this should be default mode
Merge,
Replace,
Remove
Remove,
Update, // pre rewording (cnx-3177), keeping for scripts with settings saved
Overwrite // pre rewording (cnx-3177), keeping for scripts with settings saved
}
private PropertyMode _mode = PropertyMode.Merge;
private PropertyMode _mode = PropertyMode.Update;
private PropertyMode Mode
{
get => _mode;
set
{
// auto-migrate legacy modes to new modes
if (value == PropertyMode.Update)
{
value = PropertyMode.Merge;
}
if (value == PropertyMode.Overwrite)
{
value = PropertyMode.Replace;
}
if (_mode != value)
{
_mode = value;
@@ -96,7 +109,7 @@ public class SpecklePropertiesPassthrough : SpeckleSolveInstance
}
// validate that keys and values are of valid length
if ((Mode == PropertyMode.Merge || Mode == PropertyMode.Replace) && inputKeys.Count != inputValues.Count)
if ((Mode == PropertyMode.Update || Mode == PropertyMode.Overwrite) && inputKeys.Count != inputValues.Count)
{
AddRuntimeMessage(GH_RuntimeMessageLevel.Warning, "Keys and values are mismatched in length");
return;
@@ -108,18 +121,16 @@ public class SpecklePropertiesPassthrough : SpeckleSolveInstance
return;
}
// process the properties
Dictionary<string, ISpecklePropertyGoo> result =
inputProperties is null || Mode == PropertyMode.Replace
? new()
: inputProperties.Value.ToDictionary(entry => entry.Key, entry => entry.Value);
// deep clone to prevent mutating upstream grasshopper data
SpecklePropertyGroupGoo resultGoo =
inputProperties is null || Mode == PropertyMode.Replace ? new SpecklePropertyGroupGoo() : inputProperties.Clone();
// process keys and values
if (hasKeys)
{
// check for duplicate keys
var duplicates = inputKeys.GroupBy(x => x).Where(g => g.Count() > 1).Select(g => g.Key);
if (duplicates.Any())
var duplicates = inputKeys.GroupBy(x => x).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
if (duplicates.Count != 0)
{
AddRuntimeMessage(
GH_RuntimeMessageLevel.Error,
@@ -128,12 +139,12 @@ public class SpecklePropertiesPassthrough : SpeckleSolveInstance
return;
}
// set keyvalue pairs
// set key-value pairs
for (int i = 0; i < inputKeys.Count; i++)
{
string key = inputKeys[i];
object? value = Mode == PropertyMode.Remove ? null : inputValues[i];
ISpecklePropertyGoo? convertedValue = null;
ISpecklePropertyGoo? convertedValue;
switch (value)
{
case SpecklePropertyGroupGoo propGoo:
@@ -148,7 +159,7 @@ public class SpecklePropertiesPassthrough : SpeckleSolveInstance
{
AddRuntimeMessage(
GH_RuntimeMessageLevel.Error,
$"Values contain an invalid data type. Only strings, numbers, booleans, planes, vectors, intervals, and other Speckle properties are supported."
"Values contain an invalid data type. Only strings, numbers, booleans, planes, vectors, intervals, and other Speckle properties are supported."
);
return;
}
@@ -160,29 +171,19 @@ public class SpecklePropertiesPassthrough : SpeckleSolveInstance
switch (Mode)
{
case PropertyMode.Merge:
if (result.ContainsKey(key))
{
result[key] = convertedValue;
}
else
{
result.Add(key, convertedValue);
}
break;
case PropertyMode.Replace:
result.Add(key, convertedValue);
resultGoo.SetValueByPath(key, convertedValue);
break;
case PropertyMode.Remove:
result.Remove(key);
resultGoo.RemoveValueByPath(key);
break;
}
}
}
var groupGoo = new SpecklePropertyGroupGoo(result);
da.SetData(0, groupGoo);
da.SetDataList(1, result.Keys);
da.SetDataList(2, result.Values);
da.SetData(0, resultGoo);
da.SetDataList(1, resultGoo.Value.Keys);
da.SetDataList(2, resultGoo.Value.Values);
}
public override void AppendAdditionalMenuItems(ToolStripDropDown menu)
@@ -192,18 +193,24 @@ public class SpecklePropertiesPassthrough : SpeckleSolveInstance
Menu_AppendSeparator(menu); // modes section
foreach (PropertyMode mode in Enum.GetValues(typeof(PropertyMode)))
{
// hide "legacy modes" (before cnx-3177 rewording) from the dropdown
if (mode is PropertyMode.Update or PropertyMode.Overwrite)
{
continue;
}
var modeItem = Menu_AppendItem(menu, mode.ToString(), (_, _) => Mode = mode, true, mode == Mode);
switch (mode)
{
case PropertyMode.Merge:
modeItem.ToolTipText =
"Input keyvalue pairs will be merged with existing properties. Any existing keys will be updated with new values.";
@"Input key-value pairs will be merged with existing properties. Any existing keys will be updated with new values.";
break;
case PropertyMode.Replace:
modeItem.ToolTipText = "Existing properties will be cleared and replaced by input keyvalue pairs.";
modeItem.ToolTipText = @"Existing properties will be cleared and replaced by input key-value pairs.";
break;
case PropertyMode.Remove:
modeItem.ToolTipText = "Existing keyvalue pairs that match the input keys will be removed from properties.";
modeItem.ToolTipText = @"Existing key-value pairs that match the input keys will be removed from properties.";
break;
}
}
@@ -26,7 +26,8 @@ namespace Speckle.Connectors.GrasshopperShared.Operations.Receive;
/// </remarks>
internal sealed class LocalToGlobalMapHandler
{
public Dictionary<string, SpeckleGeometryWrapper> ConvertedObjectsMap { get; } = new();
public Dictionary<string, SpeckleGeometryWrapper> ConvertedObjectsMap { get; } = [];
private readonly HashSet<string> _processedDataObjects = [];
// injected via constructor (DI-managed)
private readonly IDataObjectInstanceRegistry _dataObjectInstanceRegistry;
@@ -113,7 +114,7 @@ internal sealed class LocalToGlobalMapHandler
var obj = atomicContext.Current;
var objId = obj.applicationId ?? obj.id;
if (objId is null || ConvertedObjectsMap.ContainsKey(objId))
if (objId is null || ConvertedObjectsMap.ContainsKey(objId) || _processedDataObjects.Contains(objId))
{
return;
}
@@ -132,6 +133,15 @@ internal sealed class LocalToGlobalMapHandler
{
List<(object, Base)> converted = SpeckleConversionContext.Current.ConvertToHost(obj);
// geometry-less data objects (cnx-2522)
bool isMetadataOnly = obj is DataObject { displayValue.Count: 0 };
// bypass the early return if this genuinely is a metadata-only DataObject (cnx-3237)
if (converted.Count == 0 && !isMetadataOnly)
{
return;
}
// get path and collection
var path = _traversalContextUnpacker.GetCollectionPath(atomicContext).ToList();
var objectCollection = CollectionRebuilder.GetOrCreateSpeckleCollectionFromPath(
@@ -143,6 +153,8 @@ internal sealed class LocalToGlobalMapHandler
// handle all DataObjects
if (obj is DataObject normalDataObject)
{
_processedDataObjects.Add(objId);
var geometries = ConvertToGeometryWrappers(converted);
var dataObjectWrapper = CreateDataObjectWrapper(normalDataObject, geometries, path, objectCollection);
@@ -150,12 +162,6 @@ internal sealed class LocalToGlobalMapHandler
return;
}
// nothing converted - nothing to do (for non-DataObjects)
if (converted.Count == 0)
{
return;
}
// handle normal geometry (not DataObject)
SpecklePropertyGroupGoo propertyGroup = new();
if (obj[Constants.PROPERTIES_PROP] is Dictionary<string, object?> props)
@@ -214,6 +220,12 @@ internal sealed class LocalToGlobalMapHandler
return;
}
// ensures we don't process the same registered DataObject multiple times due to multiple traversal encounters.
if (!_processedDataObjects.Add(dataObjectId))
{
return;
}
try
{
var path = _traversalContextUnpacker.GetCollectionPath(atomicContext).ToList();
@@ -10,7 +10,7 @@ namespace Speckle.Connectors.GrasshopperShared.Parameters;
/// </summary>
public partial class SpecklePropertyGroupGoo : GH_Goo<Dictionary<string, ISpecklePropertyGoo>>, ISpecklePropertyGoo
{
public override IGH_Goo Duplicate() => throw new NotImplementedException();
public override IGH_Goo Duplicate() => Clone();
public override string ToString() => $"Speckle Properties : ({Value.Count})";
@@ -213,5 +213,100 @@ public partial class SpecklePropertyGroupGoo : GH_Goo<Dictionary<string, ISpeckl
return dict;
}
/// <summary>
/// Performs a deep clone of the property group to prevent mutating upstream Grasshopper data.
/// </summary>
/// <returns>
/// A new SpecklePropertyGroupGoo instance with cloned nested properties.
/// Needed since adding support for dot notation [CNX-3179]
/// </returns>
public SpecklePropertyGroupGoo Clone()
{
var newDict = new Dictionary<string, ISpecklePropertyGoo>();
foreach (var kvp in Value)
{
newDict[kvp.Key] = kvp.Value is SpecklePropertyGroupGoo group ? group.Clone() : kvp.Value;
}
return new SpecklePropertyGroupGoo(newDict);
}
/// <summary>
/// Sets a property value using dot-notation path traversal. Creates nested groups if they do not exist.
/// </summary>
/// <param name="path">The dot-notation property path.</param>
/// <param name="value">The Speckle property to set.</param>
public void SetValueByPath(string path, ISpecklePropertyGoo value)
{
string[] parts = path.Split('.');
var current = Value;
for (int i = 0; i < parts.Length - 1; i++)
{
string part = parts[i];
if (!current.TryGetValue(part, out var existing) || existing is not SpecklePropertyGroupGoo group)
{
group = new SpecklePropertyGroupGoo();
current[part] = group;
}
current = group.Value;
}
current[parts[^1]] = value;
}
/// <summary>
/// Removes a property value using dot-notation path traversal.
/// </summary>
/// <param name="path">The dot-notation property path.</param>
public void RemoveValueByPath(string path)
{
string[] parts = path.Split('.');
var current = Value;
for (int i = 0; i < parts.Length - 1; i++)
{
string part = parts[i];
if (!current.TryGetValue(part, out var existing) || existing is not SpecklePropertyGroupGoo group)
{
return; // path does not exist
}
current = group.Value;
}
current.Remove(parts[^1]);
}
/// <summary>
/// Retrieves a property value using dot-notation path traversal. Attempts exact match first.
/// </summary>
/// <param name="path">The dot-notation property path.</param>
/// <returns>The matching property goo if found, otherwise null.</returns>
public ISpecklePropertyGoo? GetValueByPath(string path)
{
// attempt exact match first for literal dots in native keys
if (Value.TryGetValue(path, out var exactMatch))
{
return exactMatch;
}
string[] parts = path.Split('.');
ISpecklePropertyGoo? current = this;
foreach (var part in parts)
{
if (current is SpecklePropertyGroupGoo group && group.Value.TryGetValue(part, out var next))
{
current = next;
}
else
{
return null; // current is not a dictionary, or path is invalid
}
}
return current;
}
public override int GetHashCode() => base.GetHashCode();
}
@@ -18,6 +18,6 @@ public class DisplayValueExtractor(
/// <summary>
/// Gets the underlying geometry converter for accessing cache statistics.
/// </summary>
internal GeometryToSpeckleConverter GeometryConverter { get; } =
private GeometryToSpeckleConverter GeometryConverter { get; } =
geometryConverter ?? throw new ArgumentNullException(nameof(geometryConverter));
}
@@ -1,4 +1,4 @@
namespace Speckle.Converter.Navisworks.Paths;
namespace Speckle.Converter.Navisworks.Paths;
public readonly record struct PathKey
{
@@ -84,21 +84,9 @@ public readonly record struct PathKey
return true;
}
public override string ToString()
{
if (Data == null || Data.Length == 0)
{
return string.Empty;
}
return string.Join(",", Data);
}
public override string ToString() => Data == null || Data.Length == 0 ? string.Empty : string.Join(".", Data);
/// <summary>
/// Returns a compact string representation using the hash value as an unsigned integer.
/// Suitable for use as application IDs and definition IDs.
/// This avoids negative numbers in IDs by treating the hash as unsigned.
/// </summary>
public string ToHashString() => unchecked((uint)Hash).ToString();
public string ToPathString() => Data == null || Data.Length == 0 ? "0" : string.Join(".", Data);
}
internal sealed class PathKeyComparer : IEqualityComparer<PathKey>
@@ -108,10 +108,10 @@ public sealed class GeometryToSpeckleConverter(
var transformedWorld = _isUpright ? instanceWorld : TransformMatrixYUpToZUp(instanceWorld);
var invDefWorld = GeometryHelpers.InvertRigid(transformedWorld);
var definitionGeometry = UnbakeGeometry(geometries, invDefWorld);
var groupKeyHash = groupKey.ToHashString();
var groupKeyPath = groupKey.ToPathString();
for (int i = 0; i < definitionGeometry.Count; i++)
{
definitionGeometry[i].applicationId = $"{GEOMETRY_ID_PREFIX}{groupKeyHash}_{i}";
definitionGeometry[i].applicationId = $"{GEOMETRY_ID_PREFIX}{groupKeyPath}_{i}";
}
_registry.StoreDefinitionGeometry(groupKey, definitionGeometry);
@@ -123,10 +123,10 @@ public sealed class GeometryToSpeckleConverter(
var transformedWorld = _isUpright ? instanceWorld : TransformMatrixYUpToZUp(instanceWorld);
var instanceProxy = new InstanceProxy
{
definitionId = $"{InstanceConstants.DEFINITION_ID_PREFIX}{groupKey.ToHashString()}",
definitionId = $"{InstanceConstants.DEFINITION_ID_PREFIX}{groupKey.ToPathString()}",
transform = ConvertToMatrix4X4(transformedWorld),
units = _settings.Derived.SpeckleUnits,
applicationId = $"{InstanceConstants.INSTANCE_ID_PREFIX}{itemPathKey.ToHashString()}",
applicationId = $"{InstanceConstants.INSTANCE_ID_PREFIX}{itemPathKey.ToPathString()}",
maxDepth = 0
};
@@ -652,6 +652,7 @@ public sealed class DisplayValueExtractor
or DB.BuiltInCategory.OST_StructConnectionBolts
or DB.BuiltInCategory.OST_StructConnectionWelds
or DB.BuiltInCategory.OST_StructConnectionShearStuds
or DB.BuiltInCategory.OST_StructConnectionAnchors
)
{
// try-catch is not pretty. we need to understand this better.
@@ -0,0 +1,6 @@
namespace Speckle.Connectors.DUI.Bindings;
public interface IParametersBinding : IBinding
{
public Task Update(string payload);
}
@@ -1,7 +1,11 @@
using Microsoft.Extensions.DependencyInjection;
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Speckle.Connectors.Common;
using Speckle.Connectors.Common.Common;
using Speckle.Connectors.Logging;
using Speckle.Importers.JobProcessor.Blobs;
using Speckle.Importers.JobProcessor.JobQueue;
using Speckle.Objects.Geometry;
using Speckle.Sdk;
namespace Speckle.Importers.JobProcessor;
@@ -9,20 +13,61 @@ namespace Speckle.Importers.JobProcessor;
internal static class ServiceRegistration
{
private static readonly Application s_application = new(".NET File Import Job Processor", "jobprocessor");
private const HostAppVersion HOST_APP_VERSION = HostAppVersion.v3;
public static IServiceCollection AddJobProcessor(this IServiceCollection serviceCollection)
{
var assemblyVersion = Assembly.GetExecutingAssembly().GetVersion();
serviceCollection.AddSpeckleSdk(
s_application,
HostApplications.GetVersion(HOST_APP_VERSION),
assemblyVersion,
typeof(Point).Assembly
);
serviceCollection.AddLoggingConfig();
serviceCollection.AddTransient<Repository>();
serviceCollection.AddTransient<ImportJobFileDownloader>();
serviceCollection.AddHostedService<JobProcessorInstance>();
return serviceCollection;
}
private static IServiceCollection AddLoggingConfig(this IServiceCollection serviceCollection)
private static void AddLoggingConfig(this IServiceCollection serviceCollection)
{
serviceCollection.Initialize(s_application, HostAppVersion.v3);
return serviceCollection;
serviceCollection.AddSeqLogging(
s_application,
HOST_APP_VERSION,
#if DEBUG || LOCAL
new SpeckleLogging(Console: true, File: new(), MinimumLevel: SpeckleLogLevel.Debug),
new SpeckleTracing(Console: false),
new SpeckleMetrics(Console: false)
#else
new SpeckleLogging(
Console: true,
File: new(),
Otel:
[
new(
Endpoint: new Uri("https://seq.speckle.systems/ingest/otlp/v1/logs"),
Headers: new() { { "X-Seq-ApiKey", "zG4cU1MbOhMD699iGlAq" } }
)
],
MinimumLevel: SpeckleLogLevel.Information
),
new SpeckleTracing(
Console: false,
Otel:
[
new(
Endpoint: new Uri("https://seq.speckle.systems/ingest/otlp/v1/traces"),
Headers: new() { { "X-Seq-ApiKey", "zG4cU1MbOhMD699iGlAq" } }
)
]
),
null
#endif
);
}
}
+44 -35
View File
@@ -37,54 +37,63 @@ public static class Connector
typeof(Point).Assembly
);
return serviceCollection.AddSeqLogging(
application,
version,
#if DEBUG || LOCAL
var minimumLevel = SpeckleLogLevel.Debug;
new SpeckleLogging(Console: true, File: new(), MinimumLevel: SpeckleLogLevel.Debug),
new SpeckleTracing(Console: false),
new SpeckleMetrics(Console: false)
#else
var minimumLevel = SpeckleLogLevel.Information;
new SpeckleLogging(
Console: true,
File: new(),
Otel:
[
new(
Endpoint: new Uri("https://seq.speckle.systems/ingest/otlp/v1/logs"),
Headers: new() { { "X-Seq-ApiKey", "Y0Ya2CFVt1tCSgrbY07c" } }
)
],
MinimumLevel: SpeckleLogLevel.Information
),
new SpeckleTracing(
Console: false,
Otel:
[
new(
Endpoint: new Uri("https://seq.speckle.systems/ingest/otlp/v1/traces"),
Headers: new() { { "X-Seq-ApiKey", "Y0Ya2CFVt1tCSgrbY07c" } }
)
]
),
null
#endif
);
}
public static IDisposable AddSeqLogging(
this IServiceCollection serviceCollection,
Application application,
HostAppVersion version,
SpeckleLogging loggingConfig,
SpeckleTracing? tracingConfig,
SpeckleMetrics? metricsConfig
)
{
var assemblyVersion = Assembly.GetExecutingAssembly().GetVersion();
var (logging, tracing, metrics) = Observability.Initialize(
application.Name + " " + HostApplications.GetVersion(version),
application.Slug,
assemblyVersion,
#if DEBUG || LOCAL
new(
new SpeckleLogging(Console: true, File: new(), MinimumLevel: minimumLevel),
new SpeckleTracing(Console: false),
new SpeckleMetrics(Console: false)
)
#else
new(
new SpeckleLogging(
Console: true,
File: new(),
Otel:
[
new(
Endpoint: "https://seq-dev.speckle.systems/ingest/otlp/v1/logs",
Headers: new() { { "X-Seq-ApiKey", "y5YnBp12ZE1Czh4tzZWn" } }
)
],
MinimumLevel: minimumLevel
),
new SpeckleTracing(
Console: false,
Otel:
[
new(
Endpoint: "https://seq-dev.speckle.systems/ingest/otlp/v1/traces",
Headers: new() { { "X-Seq-ApiKey", "y5YnBp12ZE1Czh4tzZWn" } }
)
]
)
)
#endif
new(loggingConfig, tracingConfig, metricsConfig)
);
//do this after the AddSpeckleSdk so that the logging system gets values from here.
serviceCollection.AddLogging(x =>
{
x.ClearProviders();
x.AddProvider(new SpeckleLogProvider(logging));
x.SetMinimumLevel(GetMicrosoftLevel(minimumLevel));
x.SetMinimumLevel(GetMicrosoftLevel(loggingConfig.MinimumLevel));
});
serviceCollection.AddSingleton<Speckle.Sdk.Logging.ISdkActivityFactory, ConnectorActivityFactory>();
return new LoggingDisposable(logging, tracing, metrics);
@@ -84,7 +84,7 @@ internal static class LogBuilder
y.Protocol = OtlpExportProtocol.HttpProtobuf;
y.Endpoint = speckleOtelLogging.Endpoint is null
? throw new InvalidOperationException("Need a logging endpoint")
: new Uri(speckleOtelLogging.Endpoint);
: speckleOtelLogging.Endpoint;
var sb = new StringBuilder();
bool appendSemicolon = false;
foreach (var kvp in speckleOtelLogging.Headers ?? [])
@@ -38,7 +38,7 @@ internal static class MetricsBuilder
if (metrics.Endpoint is not null)
{
options.Endpoint = new Uri(metrics.Endpoint);
options.Endpoint = metrics.Endpoint;
}
}
}
@@ -44,7 +44,7 @@ internal static class TracingBuilder
if (tracing.Endpoint is not null)
{
options.Endpoint = new Uri(tracing.Endpoint);
options.Endpoint = tracing.Endpoint;
}
}
}
@@ -28,11 +28,7 @@ public record SpeckleLogging(
public record SpeckleFileLogging(string? Path = null, bool Enabled = true);
public record SpeckleOtelLogging(
string? Endpoint = null,
bool Enabled = true,
Dictionary<string, string>? Headers = null
);
public record SpeckleOtelLogging(Uri? Endpoint = null, bool Enabled = true, Dictionary<string, string>? Headers = null);
public record SpeckleTracing(bool Console = false, IEnumerable<SpeckleOtelTracing>? Otel = null)
{
@@ -40,11 +36,7 @@ public record SpeckleTracing(bool Console = false, IEnumerable<SpeckleOtelTracin
: this(console, otel is null ? null : [otel]) { }
}
public record SpeckleOtelTracing(
string? Endpoint = null,
bool Enabled = true,
Dictionary<string, string>? Headers = null
);
public record SpeckleOtelTracing(Uri? Endpoint = null, bool Enabled = true, Dictionary<string, string>? Headers = null);
public record SpeckleMetrics(bool Console = false, IEnumerable<SpeckleOtelMetrics>? Otel = null)
{
@@ -52,8 +44,4 @@ public record SpeckleMetrics(bool Console = false, IEnumerable<SpeckleOtelMetric
: this(console, otel is null ? null : [otel]) { }
}
public record SpeckleOtelMetrics(
string? Endpoint = null,
bool Enabled = true,
Dictionary<string, string>? Headers = null
);
public record SpeckleOtelMetrics(Uri? Endpoint = null, bool Enabled = true, Dictionary<string, string>? Headers = null);