diff --git a/Connectors/Autocad/Speckle.Connectors.AutocadShared/Bindings/AutocadBasicConnectorBinding.cs b/Connectors/Autocad/Speckle.Connectors.AutocadShared/Bindings/AutocadBasicConnectorBinding.cs index 8f0d5ee90..bd8e56a5e 100644 --- a/Connectors/Autocad/Speckle.Connectors.AutocadShared/Bindings/AutocadBasicConnectorBinding.cs +++ b/Connectors/Autocad/Speckle.Connectors.AutocadShared/Bindings/AutocadBasicConnectorBinding.cs @@ -194,4 +194,11 @@ await _threadContext.RunOnMainAsync(async () => } }); } + + public async Task UpdateParameters(string payload) => + await Commands.SetGlobalNotification( + ToastNotificationType.INFO, + "Not Supported", + "Applying parameter changes is not yet supported in this host application" + ); } diff --git a/Connectors/CSi/Speckle.Connectors.CSiShared/Bindings/CsiSharedBasicConnectorBinding.cs b/Connectors/CSi/Speckle.Connectors.CSiShared/Bindings/CsiSharedBasicConnectorBinding.cs index ceefe7561..01f4dfcfe 100644 --- a/Connectors/CSi/Speckle.Connectors.CSiShared/Bindings/CsiSharedBasicConnectorBinding.cs +++ b/Connectors/CSi/Speckle.Connectors.CSiShared/Bindings/CsiSharedBasicConnectorBinding.cs @@ -71,4 +71,11 @@ public void RemoveModels(List models) => public Task HighlightModel(string modelCardId) => Task.CompletedTask; public Task HighlightObjects(IReadOnlyList objectIds) => Task.CompletedTask; + + public async Task UpdateParameters(string payload) => + await Commands.SetGlobalNotification( + ToastNotificationType.INFO, + "Not Supported", + "Applying parameter changes is not yet supported in this host application" + ); } diff --git a/Connectors/Navisworks/Speckle.Connectors.NavisworksShared/Bindings/NavisworksBasicConnectorBinding.cs b/Connectors/Navisworks/Speckle.Connectors.NavisworksShared/Bindings/NavisworksBasicConnectorBinding.cs index a52feef90..8db82f521 100644 --- a/Connectors/Navisworks/Speckle.Connectors.NavisworksShared/Bindings/NavisworksBasicConnectorBinding.cs +++ b/Connectors/Navisworks/Speckle.Connectors.NavisworksShared/Bindings/NavisworksBasicConnectorBinding.cs @@ -47,4 +47,11 @@ ISpeckleApplication speckleApplication public async Task HighlightObjects(IReadOnlyList objectIds) => // TODO: Implement highlighting logic on main thread await Task.CompletedTask; + + public async Task UpdateParameters(string payload) => + await Commands.SetGlobalNotification( + ToastNotificationType.INFO, + "Not Supported", + "Applying parameter changes is not yet supported in this host application" + ); } diff --git a/Connectors/Revit/Speckle.Connectors.RevitShared/Bindings/BasicConnectorBindingRevit.cs b/Connectors/Revit/Speckle.Connectors.RevitShared/Bindings/BasicConnectorBindingRevit.cs index 9013f4080..b5008b1d0 100644 --- a/Connectors/Revit/Speckle.Connectors.RevitShared/Bindings/BasicConnectorBindingRevit.cs +++ b/Connectors/Revit/Speckle.Connectors.RevitShared/Bindings/BasicConnectorBindingRevit.cs @@ -2,6 +2,8 @@ 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 @@ public BasicConnectorBindingRevit( RevitContext revitContext, ISpeckleApplication speckleApplication, ITopLevelExceptionHandler topLevelExceptionHandler, - IRevitTask revitTask + IRevitTask revitTask, + ParameterUpdater parameterUpdater, + IJsonSerializer jsonSerializer ) { Name = "baseBinding"; @@ -41,6 +47,8 @@ IRevitTask revitTask _speckleApplication = speckleApplication; _topLevelExceptionHandler = topLevelExceptionHandler; _revitTask = revitTask; + _parameterUpdater = parameterUpdater; + _jsonSerializer = jsonSerializer; Commands = new BasicConnectorBindingCommands(parent); _store.DocumentChanged += (_, _) => @@ -191,4 +199,132 @@ await _revitTask // activeUIDoc.ShowElements(objectIds); // ; } + + public async Task UpdateParameters(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"); + t.Start(); + + foreach (var request in requests) + { + if (string.IsNullOrEmpty(request.ApplicationId)) + { + errors.Add("Missing ApplicationId."); + continue; + } + + var elementId = ElementIdHelper.GetElementIdFromUniqueId(doc, request.ApplicationId); + if (elementId == null) + { + errors.Add($"Element UniqueId not found: {request.ApplicationId}"); + continue; + } + + var element = doc.GetElement(elementId); + if (element == null) + { + errors.Add($"Element is null for Id: {elementId}"); + continue; + } + + var rawPath = request.Path; + if (string.IsNullOrEmpty(rawPath)) + { + errors.Add("Path is missing."); + continue; + } + + // TODO: not happy about this + // 👇 + 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) + { + errors.Add($"Path must have 3 parts. Got: '{rawPath}'"); + continue; + } + // ☝️ + // TODO: not happy about this + + object? rawValue = request.To; + if (rawValue is Newtonsoft.Json.Linq.JValue jValue) + { + rawValue = jValue.Value; + } + + var result = _parameterUpdater.Update(element, pathParts, rawValue); + + if (result.IsSuccess) + { + successCount++; + } + else + { + errors.Add($"[{pathParts[2]}]: {result.ErrorMessage}"); + } + } + + t.Commit(); + }) + .ConfigureAwait(false); + + if (errors.Count > 0) + { + await Commands.SetGlobalNotification( + ToastNotificationType.WARNING, + "Update Completed with Issues", + $"Applied {successCount} updates. Encountered {errors.Count} errors: {string.Join(" | ", errors.Take(3))}", + autoClose: false + ); + } + else + { + await Commands.SetGlobalNotification( + ToastNotificationType.SUCCESS, + "Parameters Updated", + $"Successfully applied {successCount} updates." + ); + } + } + catch (Exception ex) + { + _topLevelExceptionHandler.CatchUnhandled( + () => throw new SpeckleException("Failed to apply parameter updates", ex) + ); + } + } +} + +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 @@ public RevitSendBinding( IThreadContext threadContext, IRevitTask revitTask, ISendOperationManagerFactory sendOperationManagerFactory, + ParameterUpdater parameterUpdater, IConfigStore configStore ) : base("sendBinding", bridge) @@ -84,6 +86,7 @@ IConfigStore configStore _linkedModelHandler = linkedModelHandler; _threadContext = threadContext; _sendOperationManagerFactory = sendOperationManagerFactory; + _parameterUpdater = parameterUpdater; _config = configStore.GetConnectorConfig(); Commands = new SendBindingUICommands(bridge); @@ -198,6 +201,44 @@ await manager.Process( ); } + 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..7d20eb7cd 100644 --- a/Connectors/Revit/Speckle.Connectors.RevitShared/DependencyInjection/RevitConnectorModule.cs +++ b/Connectors/Revit/Speckle.Connectors.RevitShared/DependencyInjection/RevitConnectorModule.cs @@ -66,6 +66,7 @@ public static void AddRevit(this IServiceCollection serviceCollection) 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..888553034 --- /dev/null +++ b/Connectors/Revit/Speckle.Connectors.RevitShared/HostApp/ParameterChangeRequest.cs @@ -0,0 +1,8 @@ +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; } +} 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..da845ddbc --- /dev/null +++ b/Connectors/Revit/Speckle.Connectors.RevitShared/HostApp/ParameterUpdater.cs @@ -0,0 +1,343 @@ +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; + } + + /// + /// Updates a parameter on an element. + /// + /// The Revit element + /// Path to parameter: [scope, groupName, parameterKey] + /// New value to set + public UpdateResult Update(DB.Element element, string[] path, object? newValue) + { + // 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 + var parameter = FindParameter(targetElement, groupName, parameterKey); + 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) + { + foreach (DB.Parameter parameter in element.Parameters) + { + var definition = parameter.Definition; + if (definition == null) + { + continue; + } + + // check group matches + var paramGroup = definition.GetGroupTypeId(); + var groupLabel = DB.LabelUtils.GetLabelForGroup(paramGroup); + if (groupLabel != groupName) + { + continue; + } + + // check if name matches (try human-readable first, then internal) + var humanName = definition.Name; + var internalName = GetInternalDefinitionName(parameter); + + if (humanName == parameterKey || internalName == parameterKey) + { + return parameter; + } + } + + return null; + } + + 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) + { + 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: {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/Speckle.Connectors.RevitShared.projitems b/Connectors/Revit/Speckle.Connectors.RevitShared/Speckle.Connectors.RevitShared.projitems index c40a5ebdf..84bd62e3e 100644 --- a/Connectors/Revit/Speckle.Connectors.RevitShared/Speckle.Connectors.RevitShared.projitems +++ b/Connectors/Revit/Speckle.Connectors.RevitShared/Speckle.Connectors.RevitShared.projitems @@ -27,6 +27,8 @@ + + diff --git a/Connectors/Rhino/Speckle.Connectors.RhinoShared/Bindings/RhinoBasicConnectorBinding.cs b/Connectors/Rhino/Speckle.Connectors.RhinoShared/Bindings/RhinoBasicConnectorBinding.cs index 085cd79f8..6a43873e1 100644 --- a/Connectors/Rhino/Speckle.Connectors.RhinoShared/Bindings/RhinoBasicConnectorBinding.cs +++ b/Connectors/Rhino/Speckle.Connectors.RhinoShared/Bindings/RhinoBasicConnectorBinding.cs @@ -170,4 +170,11 @@ private void HighlightObjectsOnView(IReadOnlyList rhinoObjects, IRe RhinoDoc.ActiveDoc.Views.Redraw(); } + + public async Task UpdateParameters(string payload) => + await Commands.SetGlobalNotification( + ToastNotificationType.INFO, + "Not Supported", + "Applying parameter changes is not yet supported in this host application" + ); } diff --git a/Connectors/Tekla/Speckle.Connector.TeklaShared/Bindings/TeklaBasicConnectorBinding.cs b/Connectors/Tekla/Speckle.Connector.TeklaShared/Bindings/TeklaBasicConnectorBinding.cs index edf86157d..57f604356 100644 --- a/Connectors/Tekla/Speckle.Connector.TeklaShared/Bindings/TeklaBasicConnectorBinding.cs +++ b/Connectors/Tekla/Speckle.Connector.TeklaShared/Bindings/TeklaBasicConnectorBinding.cs @@ -151,4 +151,11 @@ await Task.Run(() => _logger.LogError(ex, "Failed to highlight objects"); } } + + public async Task UpdateParameters(string payload) => + await Commands.SetGlobalNotification( + ToastNotificationType.INFO, + "Not Supported", + "Applying parameter changes is not yet supported in this host application" + ); } diff --git a/DUI3/Speckle.Connectors.DUI/Bindings/IBasicConnectorBinding.cs b/DUI3/Speckle.Connectors.DUI/Bindings/IBasicConnectorBinding.cs index 71b34770d..d57839700 100644 --- a/DUI3/Speckle.Connectors.DUI/Bindings/IBasicConnectorBinding.cs +++ b/DUI3/Speckle.Connectors.DUI/Bindings/IBasicConnectorBinding.cs @@ -23,6 +23,7 @@ public interface IBasicConnectorBinding : IBinding public Task HighlightModel(string modelCardId); public Task HighlightObjects(IReadOnlyList objectIds); + public Task UpdateParameters(string payload); public BasicConnectorBindingCommands Commands { get; } }