diff --git a/Connectors/ArcGIS/Speckle.Connectors.ArcGIS3/DependencyInjection/ArcGISConnectorModule.cs b/Connectors/ArcGIS/Speckle.Connectors.ArcGIS3/DependencyInjection/ArcGISConnectorModule.cs index 36459fcd5..1bf44d3da 100644 --- a/Connectors/ArcGIS/Speckle.Connectors.ArcGIS3/DependencyInjection/ArcGISConnectorModule.cs +++ b/Connectors/ArcGIS/Speckle.Connectors.ArcGIS3/DependencyInjection/ArcGISConnectorModule.cs @@ -68,7 +68,7 @@ public void Load(SpeckleContainerBuilder builder) builder.AddScoped>(); builder.AddScoped(); builder.AddScoped, ArcGISRootObjectBuilder>(); - builder.AddSingleton(); + builder.AddScoped(); builder.AddScoped(); diff --git a/Connectors/ArcGIS/Speckle.Connectors.ArcGIS3/HostApp/ArcGISColorManager.cs b/Connectors/ArcGIS/Speckle.Connectors.ArcGIS3/HostApp/ArcGISColorManager.cs index 86438ddbc..0c8ba6abd 100644 --- a/Connectors/ArcGIS/Speckle.Connectors.ArcGIS3/HostApp/ArcGISColorManager.cs +++ b/Connectors/ArcGIS/Speckle.Connectors.ArcGIS3/HostApp/ArcGISColorManager.cs @@ -1,7 +1,11 @@ +using System.Drawing; using ArcGIS.Core.CIM; using ArcGIS.Core.Data; +using ArcGIS.Desktop.Framework.Threading.Tasks; using ArcGIS.Desktop.Mapping; using Speckle.Converters.ArcGIS3.Utils; +using Speckle.Sdk.Models; +using Speckle.Sdk.Models.GraphTraversal; using Speckle.Sdk.Models.Proxies; namespace Speckle.Connectors.ArcGIS.HostApp; @@ -9,6 +13,7 @@ namespace Speckle.Connectors.ArcGIS.HostApp; public class ArcGISColorManager { private Dictionary ColorProxies { get; set; } = new(); + public Dictionary ObjectColorsIdMap { get; set; } = new(); /// /// Iterates through a given set of arcGIS map members (layers containing objects) and collects their colors. @@ -43,6 +48,150 @@ public List UnpackColors(List<(MapMember, int)> mapMembersWithDispla return ColorProxies.Values.ToList(); } + /// + /// Parse Color Proxies and stores in ObjectColorsIdMap the relationship between object ids and colors + /// + /// + /// + public void ParseColors(List colorProxies, Action? onOperationProgressed) + { + // injected as Singleton, so we need to clean existing proxies first + ObjectColorsIdMap = new(); + var count = 0; + foreach (ColorProxy colorProxy in colorProxies) + { + onOperationProgressed?.Invoke("Converting colors", (double)++count / colorProxies.Count); + foreach (string objectId in colorProxy.objects) + { + Color convertedColor = Color.FromArgb(colorProxy.value); + ObjectColorsIdMap.TryAdd(objectId, convertedColor); + } + } + } + + /// + /// Create a new CIMUniqueValueClass for UniqueRenderer per each object ID + /// + /// + /// + private CIMUniqueValueClass CreateColorCategory(TraversalContext tc, esriGeometryType speckleGeometryType) + { + Base baseObj = tc.Current; + + // declare default white color + Color color = Color.FromArgb(255, 255, 255, 255); + + // get color moving upwards from the object + foreach (var parent in tc.GetAscendants()) + { + if (parent.applicationId is string appId && ObjectColorsIdMap.TryGetValue(appId, out Color objColor)) + { + color = objColor; + break; + } + } + + CIMSymbolReference symbol = CreateSymbol(speckleGeometryType, color); + + // First create a "CIMUniqueValueClass" + List listUniqueValues = new() { new CIMUniqueValue { FieldValues = new string[] { baseObj.id } } }; + + CIMUniqueValueClass newUniqueValueClass = + new() + { + Editable = true, + Label = baseObj.id, + Patch = PatchShape.Default, + Symbol = symbol, + Visible = true, + Values = listUniqueValues.ToArray() + }; + return newUniqueValueClass; + } + + /// + /// Create a Symbol from GeometryType and Color + /// + /// + /// + private CIMSymbolReference CreateSymbol(esriGeometryType speckleGeometryType, Color color) + { + var symbol = SymbolFactory + .Instance.ConstructPointSymbol(ColorFactory.Instance.CreateColor(color)) + .MakeSymbolReference(); + + switch (speckleGeometryType) + { + case esriGeometryType.esriGeometryLine: + case esriGeometryType.esriGeometryPolyline: + symbol = SymbolFactory + .Instance.ConstructLineSymbol(ColorFactory.Instance.CreateColor(color)) + .MakeSymbolReference(); + break; + case esriGeometryType.esriGeometryPolygon: + case esriGeometryType.esriGeometryMultiPatch: + symbol = SymbolFactory + .Instance.ConstructPolygonSymbol(ColorFactory.Instance.CreateColor(color)) + .MakeSymbolReference(); + break; + } + + return symbol; + } + + /// + /// Add CIMUniqueValueClass to Layer Renderer (if exists); apply Renderer to Layer (again) + /// + /// + /// + public async Task SetOrEditLayerRenderer(TraversalContext tc, ObjectConversionTracker trackerItem) + { + if (trackerItem.HostAppMapMember is not FeatureLayer fLayer) + { + // do nothing with non-feature layers + return; + } + + // declare default grey color, create default symbol for the given layer geometry type + var color = Color.FromArgb(ColorFactory.Instance.GreyRGB.CIMColorToInt()); + CIMSymbolReference defaultSymbol = CreateSymbol(fLayer.ShapeType, color); + + // get existing renderer classes + List listUniqueValueClasses = new() { }; + var existingRenderer = QueuedTask.Run(() => fLayer.GetRenderer()).Result; + // should be always UniqueRenderer, it's the only type we are creating atm + if (existingRenderer is CIMUniqueValueRenderer uniqueRenderer) + { + if (uniqueRenderer.Groups[0].Classes != null) + { + listUniqueValueClasses.AddRange(uniqueRenderer.Groups[0].Classes.ToList()); + } + } + + // Add new CIMUniqueValueClass + CIMUniqueValueClass newUniqueValueClass = CreateColorCategory(tc, fLayer.ShapeType); + if (!listUniqueValueClasses.Select(x => x.Label).Contains(newUniqueValueClass.Label)) + { + listUniqueValueClasses.Add(newUniqueValueClass); + } + // Create a list of CIMUniqueValueGroup + CIMUniqueValueGroup uvg = new() { Classes = listUniqueValueClasses.ToArray(), }; + List listUniqueValueGroups = new() { uvg }; + // Create the CIMUniqueValueRenderer + CIMUniqueValueRenderer uvr = + new() + { + UseDefaultSymbol = true, + DefaultLabel = "all other values", + DefaultSymbol = defaultSymbol, + Groups = listUniqueValueGroups.ToArray(), + Fields = new string[] { "Speckle_ID" } + }; + + // Set the feature layer's renderer. + await QueuedTask.Run(() => fLayer.SetRenderer(uvr)).ConfigureAwait(false); + } + private string GetColorApplicationId(int argb, double order) => $"{argb}_{order}"; // Adds the element id to the color proxy based on colorId if it exists in ColorProxies, @@ -77,6 +226,11 @@ private void ProcessRasterLayerColors(RasterLayer rasterLayer, int displayPriori AddElementIdToColorProxy(elementAppId, argb, colorId, displayPriority); } + /// + /// Record colors from every feature of the layer into ColorProxies + /// + /// + /// private void ProcessFeatureLayerColors(FeatureLayer layer, int displayPriority) { // first get a list of layer fields @@ -247,6 +401,11 @@ out int color return true; } + /// + /// Make comparable the Label string of a UniqueValueRenderer (groupValue), and a Feature Attribute value (rowValue) + /// + /// + /// private (string, string) MakeValuesComparable(object? rowValue, string groupValue) { string newGroupValue = groupValue; diff --git a/Connectors/ArcGIS/Speckle.Connectors.ArcGIS3/Operations/Receive/HostObjectBuilder.cs b/Connectors/ArcGIS/Speckle.Connectors.ArcGIS3/Operations/Receive/HostObjectBuilder.cs index 779966a6e..14ce47c54 100644 --- a/Connectors/ArcGIS/Speckle.Connectors.ArcGIS3/Operations/Receive/HostObjectBuilder.cs +++ b/Connectors/ArcGIS/Speckle.Connectors.ArcGIS3/Operations/Receive/HostObjectBuilder.cs @@ -3,6 +3,7 @@ using ArcGIS.Core.Geometry; using ArcGIS.Desktop.Framework.Threading.Tasks; using ArcGIS.Desktop.Mapping; +using Speckle.Connectors.ArcGIS.HostApp; using Speckle.Connectors.ArcGIS.Utils; using Speckle.Connectors.Utils.Builders; using Speckle.Connectors.Utils.Conversion; @@ -16,6 +17,7 @@ using Speckle.Sdk.Models.Collections; using Speckle.Sdk.Models.GraphTraversal; using Speckle.Sdk.Models.Instances; +using Speckle.Sdk.Models.Proxies; using RasterLayer = Speckle.Objects.GIS.RasterLayer; namespace Speckle.Connectors.ArcGIS.Operations.Receive; @@ -30,6 +32,7 @@ public class ArcGISHostObjectBuilder : IHostObjectBuilder // POC: figure out the correct scope to only initialize on Receive private readonly IConversionContextStack _contextStack; private readonly GraphTraversal _traverseFunction; + private readonly ArcGISColorManager _colorManager; public ArcGISHostObjectBuilder( IRootToHostConverter converter, @@ -37,7 +40,8 @@ public ArcGISHostObjectBuilder( INonNativeFeaturesUtils nonGisFeaturesUtils, ILocalToGlobalUnpacker localToGlobalUnpacker, ILocalToGlobalConverterUtils localToGlobalConverterUtils, - GraphTraversal traverseFunction + GraphTraversal traverseFunction, + ArcGISColorManager colorManager ) { _converter = converter; @@ -46,6 +50,7 @@ GraphTraversal traverseFunction _localToGlobalUnpacker = localToGlobalUnpacker; _localToGlobalConverterUtils = localToGlobalConverterUtils; _traverseFunction = traverseFunction; + _colorManager = colorManager; } public async Task Build( @@ -62,6 +67,13 @@ CancellationToken cancellationToken // Prompt the UI conversion started. Progress bar will swoosh. onOperationProgressed?.Invoke("Converting", null); + // get colors + List? colors = (rootObject["colorProxies"] as List)?.Cast().ToList(); + if (colors != null) + { + _colorManager.ParseColors(colors, onOperationProgressed); + } + var objectsToConvertTc = _traverseFunction .Traverse(rootObject) .Where(ctx => ctx.Current is not Collection || IsGISType(ctx.Current)) @@ -161,16 +173,20 @@ await QueuedTask } else if (bakedMapMembers.TryGetValue(trackerItem.DatasetId, out MapMember? value)) { + // if the layer already created, just add more features to report, and more color categories // add layer and layer URI to tracker trackerItem.AddConvertedMapMember(value); trackerItem.AddLayerURI(value.URI); conversionTracker[item.Key] = trackerItem; // not necessary atm, but needed if we use conversionTracker further // only add a report item AddResultsFromTracker(trackerItem, results); + + // add color category + await _colorManager.SetOrEditLayerRenderer(item.Key, trackerItem).ConfigureAwait(false); } else { - // add layer to Map + // no layer yet, create and add layer to Map MapMember mapMember = await AddDatasetsToMap(trackerItem, createdLayerGroups, projectName, modelName) .ConfigureAwait(false); @@ -187,6 +203,9 @@ await QueuedTask // add report item AddResultsFromTracker(trackerItem, results); + + // add color category + await _colorManager.SetOrEditLayerRenderer(item.Key, trackerItem).ConfigureAwait(false); } onOperationProgressed?.Invoke("Adding to Map", (double)++bakeCount / conversionTracker.Count); } diff --git a/Converters/ArcGIS/Speckle.Converters.ArcGIS3/Utils/ArcGISFieldUtils.cs b/Converters/ArcGIS/Speckle.Converters.ArcGIS3/Utils/ArcGISFieldUtils.cs index d65e37489..a035ad1e3 100644 --- a/Converters/ArcGIS/Speckle.Converters.ArcGIS3/Utils/ArcGISFieldUtils.cs +++ b/Converters/ArcGIS/Speckle.Converters.ArcGIS3/Utils/ArcGISFieldUtils.cs @@ -103,6 +103,7 @@ public List GetFieldsFromSpeckleLayer(VectorLayer target) { // get all members by default, but only Dynamic ones from the basic geometry Dictionary members = new(); + members["Speckle_ID"] = baseObj.id; // to use for unique color values // leave out until we decide which properties to support on Receive /* @@ -119,7 +120,12 @@ public List GetFieldsFromSpeckleLayer(VectorLayer target) foreach (KeyValuePair field in members) { // POC: TODO check for the forbidden characters/combinations: https://support.esri.com/en-us/knowledge-base/what-characters-should-not-be-used-in-arcgis-for-field--000005588 - Func function = x => x[field.Key]; + string key = field.Key; + if (field.Key == "Speckle_ID") + { + key = "id"; + } + Func function = x => x[key]; TraverseAttributes(field, function, fieldsAndFunctions, fieldAdded); } } diff --git a/Converters/ArcGIS/Speckle.Converters.ArcGIS3/Utils/LocalToGlobalConverterUtils.cs b/Converters/ArcGIS/Speckle.Converters.ArcGIS3/Utils/LocalToGlobalConverterUtils.cs index dd3e3657b..f71f2e708 100644 --- a/Converters/ArcGIS/Speckle.Converters.ArcGIS3/Utils/LocalToGlobalConverterUtils.cs +++ b/Converters/ArcGIS/Speckle.Converters.ArcGIS3/Utils/LocalToGlobalConverterUtils.cs @@ -45,7 +45,9 @@ public Base TransformObjects(Base atomicObject, List matrix) ); } + string id = atomicObject.id; atomicObject = (Base)c; + atomicObject.id = id; // .TransformTo only transfers typed properties, we need to add back the dynamic ones: foreach (var prop in atomicObject.GetMembers(DynamicBaseMemberType.Dynamic))