diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3eacb244c..393e901ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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-* diff --git a/Connectors/Revit/Speckle.Connectors.RevitShared/Bindings/BasicConnectorBindingRevit.cs b/Connectors/Revit/Speckle.Connectors.RevitShared/Bindings/BasicConnectorBindingRevit.cs index 9013f4080..051702a38 100644 --- a/Connectors/Revit/Speckle.Connectors.RevitShared/Bindings/BasicConnectorBindingRevit.cs +++ b/Connectors/Revit/Speckle.Connectors.RevitShared/Bindings/BasicConnectorBindingRevit.cs @@ -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 += (_, _) => diff --git a/Connectors/Revit/Speckle.Connectors.RevitShared/Bindings/RevitParametersBinding.cs b/Connectors/Revit/Speckle.Connectors.RevitShared/Bindings/RevitParametersBinding.cs new file mode 100644 index 000000000..6f618ff75 --- /dev/null +++ b/Connectors/Revit/Speckle.Connectors.RevitShared/Bindings/RevitParametersBinding.cs @@ -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(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 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? Changes { get; set; } +} diff --git a/Connectors/Revit/Speckle.Connectors.RevitShared/Bindings/RevitSendBinding.cs b/Connectors/Revit/Speckle.Connectors.RevitShared/Bindings/RevitSendBinding.cs index c7a107cfd..c03d92363 100644 --- a/Connectors/Revit/Speckle.Connectors.RevitShared/Bindings/RevitSendBinding.cs +++ b/Connectors/Revit/Speckle.Connectors.RevitShared/Bindings/RevitSendBinding.cs @@ -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? _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 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; diff --git a/Connectors/Revit/Speckle.Connectors.RevitShared/DependencyInjection/RevitConnectorModule.cs b/Connectors/Revit/Speckle.Connectors.RevitShared/DependencyInjection/RevitConnectorModule.cs index 3e0199724..7971cbb99 100644 --- a/Connectors/Revit/Speckle.Connectors.RevitShared/DependencyInjection/RevitConnectorModule.cs +++ b/Connectors/Revit/Speckle.Connectors.RevitShared/DependencyInjection/RevitConnectorModule.cs @@ -53,6 +53,9 @@ public static class ServiceRegistration serviceCollection.AddSingleton(sp => sp.GetRequiredService()); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(sp => sp.GetRequiredService()); + serviceCollection.AddSingleton(); + // serviceCollection.AddSingleton(); // send operation and dependencies @@ -66,6 +69,7 @@ public static class ServiceRegistration serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); // receive operation and dependencies serviceCollection.AddScoped(); diff --git a/Connectors/Revit/Speckle.Connectors.RevitShared/HostApp/ParameterChangeRequest.cs b/Connectors/Revit/Speckle.Connectors.RevitShared/HostApp/ParameterChangeRequest.cs new file mode 100644 index 000000000..80556d3bd --- /dev/null +++ b/Connectors/Revit/Speckle.Connectors.RevitShared/HostApp/ParameterChangeRequest.cs @@ -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; } +} diff --git a/Connectors/Revit/Speckle.Connectors.RevitShared/HostApp/ParameterUpdater.cs b/Connectors/Revit/Speckle.Connectors.RevitShared/HostApp/ParameterUpdater.cs new file mode 100644 index 000000000..a96d444b7 --- /dev/null +++ b/Connectors/Revit/Speckle.Connectors.RevitShared/HostApp/ParameterUpdater.cs @@ -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; + +/// +/// Updates parameter values on Revit elements. Mirrors the structure from ParameterExtractor. +/// Path format: ["Instance Parameters" | "Type Parameters" | "System Type Parameters", "GroupName", "ParameterName"] +/// +public class ParameterUpdater +{ + private readonly RevitContext _revitContext; + private readonly ScalingServiceToHost _scalingServiceToHost; + private readonly ILogger _logger; + + public ParameterUpdater( + RevitContext revitContext, + ScalingServiceToHost scalingServiceToHost, + ILogger 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); +} diff --git a/Connectors/Revit/Speckle.Connectors.RevitShared/HostApp/SupportedCategoriesUtils.cs b/Connectors/Revit/Speckle.Connectors.RevitShared/HostApp/SupportedCategoriesUtils.cs index 6e16e2a45..e28a14d73 100644 --- a/Connectors/Revit/Speckle.Connectors.RevitShared/HostApp/SupportedCategoriesUtils.cs +++ b/Connectors/Revit/Speckle.Connectors.RevitShared/HostApp/SupportedCategoriesUtils.cs @@ -45,6 +45,8 @@ public static class SupportedCategoriesUtils #else category.Name == "OST_Grids"; #endif + case CategoryType.AnalyticalModel: + return true; case CategoryType.Model: return diff --git a/Connectors/Revit/Speckle.Connectors.RevitShared/Speckle.Connectors.RevitShared.projitems b/Connectors/Revit/Speckle.Connectors.RevitShared/Speckle.Connectors.RevitShared.projitems index c40a5ebdf..0730a2580 100644 --- a/Connectors/Revit/Speckle.Connectors.RevitShared/Speckle.Connectors.RevitShared.projitems +++ b/Connectors/Revit/Speckle.Connectors.RevitShared/Speckle.Connectors.RevitShared.projitems @@ -15,6 +15,7 @@ + @@ -27,6 +28,8 @@ + + diff --git a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Collections/ExpandCollection.cs b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Collections/ExpandCollection.cs index d0e8cd695..210723028 100644 --- a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Collections/ExpandCollection.cs +++ b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Collections/ExpandCollection.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 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() { } diff --git a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/CreateSpeckleProperties.cs b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/CreateSpeckleProperties.cs index fc675d11f..c015a2b75 100644 --- a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/CreateSpeckleProperties.cs +++ b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/CreateSpeckleProperties.cs @@ -45,13 +45,13 @@ public class CreateSpeckleProperties : VariableParameterComponentBase protected override void SolveInstance(IGH_DataAccess da) { - var properties = new Dictionary(); + 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); } diff --git a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/ExpandSpeckleProperties.cs b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/ExpandSpeckleProperties.cs index 7d1cace40..c61fc42d2 100644 --- a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/ExpandSpeckleProperties.cs +++ b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/ExpandSpeckleProperties.cs @@ -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().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(); + + 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(); - - 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()); - 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()); } } } @@ -101,29 +135,68 @@ public class ExpandSpeckleProperties : GH_Component, IGH_VariableParameterCompon /// private void CreateOutputs(List 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); + } } /// diff --git a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/FilterSpeckleObjects.cs b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/FilterSpeckleObjects.cs index b0c27c8bb..165cf4634 100644 --- a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/FilterSpeckleObjects.cs +++ b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/FilterSpeckleObjects.cs @@ -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()) { - 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; /// /// 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; } diff --git a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/PropertyGroupPathsSelector.cs b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/PropertyGroupPathsSelector.cs index e0e392a76..f5a88ca31 100644 --- a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/PropertyGroupPathsSelector.cs +++ b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/PropertyGroupPathsSelector.cs @@ -24,14 +24,14 @@ public class PropertyGroupPathsSelector : ValueSet protected override void LoadVolatileData() { - List propertyGroups = VolatileData - .AllData(true) - .OfType() - .ToList(); + var allData = VolatileData.AllData(true).ToList(); - if (VolatileDataCount > propertyGroups.Count) + List propertyGroups = allData.OfType().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; } diff --git a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/QueryProperties.cs b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/QueryProperties.cs index 8a9a4d3cb..098430e1e 100644 --- a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/QueryProperties.cs +++ b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/QueryProperties.cs @@ -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 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; - } } diff --git a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/SpecklePropertiesPassthrough.cs b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/SpecklePropertiesPassthrough.cs index c97d6fa0b..7dd21116a 100644 --- a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/SpecklePropertiesPassthrough.cs +++ b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Components/Objects/SpecklePropertiesPassthrough.cs @@ -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 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; } } diff --git a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Operations/Receive/LocalToGlobalMapHandler.cs b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Operations/Receive/LocalToGlobalMapHandler.cs index 86ea65235..b249aed82 100644 --- a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Operations/Receive/LocalToGlobalMapHandler.cs +++ b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Operations/Receive/LocalToGlobalMapHandler.cs @@ -26,7 +26,8 @@ namespace Speckle.Connectors.GrasshopperShared.Operations.Receive; /// internal sealed class LocalToGlobalMapHandler { - public Dictionary ConvertedObjectsMap { get; } = new(); + public Dictionary ConvertedObjectsMap { get; } = []; + private readonly HashSet _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 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(); diff --git a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Parameters/SpecklePropertyGroupGoo.cs b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Parameters/SpecklePropertyGroupGoo.cs index c42037925..a522d38bc 100644 --- a/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Parameters/SpecklePropertyGroupGoo.cs +++ b/Connectors/Rhino/Speckle.Connectors.GrasshopperShared/Parameters/SpecklePropertyGroupGoo.cs @@ -10,7 +10,7 @@ namespace Speckle.Connectors.GrasshopperShared.Parameters; /// public partial class SpecklePropertyGroupGoo : GH_Goo>, 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 + /// Performs a deep clone of the property group to prevent mutating upstream Grasshopper data. + /// + /// + /// A new SpecklePropertyGroupGoo instance with cloned nested properties. + /// Needed since adding support for dot notation [CNX-3179] + /// + public SpecklePropertyGroupGoo Clone() + { + var newDict = new Dictionary(); + foreach (var kvp in Value) + { + newDict[kvp.Key] = kvp.Value is SpecklePropertyGroupGoo group ? group.Clone() : kvp.Value; + } + return new SpecklePropertyGroupGoo(newDict); + } + + /// + /// Sets a property value using dot-notation path traversal. Creates nested groups if they do not exist. + /// + /// The dot-notation property path. + /// The Speckle property to set. + 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; + } + + /// + /// Removes a property value using dot-notation path traversal. + /// + /// The dot-notation property path. + 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]); + } + + /// + /// Retrieves a property value using dot-notation path traversal. Attempts exact match first. + /// + /// The dot-notation property path. + /// The matching property goo if found, otherwise null. + 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(); } diff --git a/Converters/Revit/Speckle.Converters.RevitShared/Helpers/DisplayValueExtractor.cs b/Converters/Revit/Speckle.Converters.RevitShared/Helpers/DisplayValueExtractor.cs index f566a08db..9c6013f0e 100644 --- a/Converters/Revit/Speckle.Converters.RevitShared/Helpers/DisplayValueExtractor.cs +++ b/Converters/Revit/Speckle.Converters.RevitShared/Helpers/DisplayValueExtractor.cs @@ -71,7 +71,7 @@ public sealed class DisplayValueExtractor case DB.Architecture.Room room: // api still returns geometry for unplaced rooms. // return empty list so room object is sent but with null display value - if (room.Volume == 0) + if (room.UpperLimit == null) { return new List(); } diff --git a/DUI3/Speckle.Connectors.DUI/Bindings/IParametersBinding.cs b/DUI3/Speckle.Connectors.DUI/Bindings/IParametersBinding.cs new file mode 100644 index 000000000..faae2c025 --- /dev/null +++ b/DUI3/Speckle.Connectors.DUI/Bindings/IParametersBinding.cs @@ -0,0 +1,6 @@ +namespace Speckle.Connectors.DUI.Bindings; + +public interface IParametersBinding : IBinding +{ + public Task Update(string payload); +}