Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1adeb0881 | |||
| c5ae589c31 | |||
| 8db0bda1c1 |
@@ -3,45 +3,48 @@ using Autodesk.Revit.DB.ExtensibleStorage;
|
||||
using Autodesk.Revit.UI;
|
||||
using Autodesk.Revit.UI.Events;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Speckle.Connectors.Common.Threading;
|
||||
using Speckle.Connectors.DUI.Bridge;
|
||||
using Speckle.Connectors.DUI.Models;
|
||||
using Speckle.Connectors.DUI.Utils;
|
||||
using Speckle.Connectors.Revit.Plugin;
|
||||
using Speckle.Converters.RevitShared.Helpers;
|
||||
using Speckle.Sdk;
|
||||
using Speckle.Sdk.Common;
|
||||
using Speckle.Sdk.SQLite;
|
||||
|
||||
namespace Speckle.Connectors.Revit.HostApp;
|
||||
|
||||
// POC: should be interfaced out
|
||||
internal sealed class RevitDocumentStore : DocumentModelStore
|
||||
{
|
||||
private readonly ILogger<RevitDocumentStore> _logger;
|
||||
// POC: move to somewhere central?
|
||||
private static readonly Guid s_revitDocumentStoreId = new("D35B3695-EDC9-4E15-B62A-D3FC2CB83FA3");
|
||||
|
||||
private readonly IAppIdleManager _idleManager;
|
||||
private readonly RevitContext _revitContext;
|
||||
private readonly DocumentModelStorageSchema _documentModelStorageSchema;
|
||||
private readonly IdStorageSchema _idStorageSchema;
|
||||
private readonly ITopLevelExceptionHandler _topLevelExceptionHandler;
|
||||
private readonly ISqLiteJsonCacheManager _jsonCacheManager;
|
||||
private readonly IThreadContext _threadContext;
|
||||
|
||||
public RevitDocumentStore(
|
||||
ILogger<DocumentModelStore> logger,
|
||||
IAppIdleManager idleManager,
|
||||
RevitContext revitContext,
|
||||
IJsonSerializer jsonSerializer,
|
||||
DocumentModelStorageSchema documentModelStorageSchema,
|
||||
IdStorageSchema idStorageSchema,
|
||||
ITopLevelExceptionHandler topLevelExceptionHandler,
|
||||
IRevitTask revitTask,
|
||||
ISqLiteJsonCacheManagerFactory jsonCacheManagerFactory,
|
||||
ILogger<RevitDocumentStore> logger
|
||||
IThreadContext threadContext,
|
||||
IRevitTask revitTask
|
||||
)
|
||||
: base(logger, jsonSerializer)
|
||||
{
|
||||
_jsonCacheManager = jsonCacheManagerFactory.CreateForUser("ConnectorsFileData");
|
||||
_idleManager = idleManager;
|
||||
_revitContext = revitContext;
|
||||
_documentModelStorageSchema = documentModelStorageSchema;
|
||||
_idStorageSchema = idStorageSchema;
|
||||
_topLevelExceptionHandler = topLevelExceptionHandler;
|
||||
_logger = logger;
|
||||
_threadContext = threadContext;
|
||||
|
||||
UIApplication uiApplication = _revitContext.UIApplication.NotNull();
|
||||
|
||||
@@ -98,36 +101,80 @@ internal sealed class RevitDocumentStore : DocumentModelStore
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
_threadContext
|
||||
.RunOnMain(() =>
|
||||
{
|
||||
//if not the same active document then don't save the current cards to a bad document!
|
||||
if (!EnsureActiveDocumentIsSame(document))
|
||||
{
|
||||
return;
|
||||
}
|
||||
using Transaction t = new(document, "Speckle Write State");
|
||||
t.Start();
|
||||
using DataStorage ds = GetSettingsDataStorage(document) ?? DataStorage.Create(document);
|
||||
|
||||
using Entity stateEntity = new(_documentModelStorageSchema.GetSchema());
|
||||
string serializedModels = Serialize();
|
||||
stateEntity.Set("contents", serializedModels);
|
||||
|
||||
using Entity idEntity = new(_idStorageSchema.GetSchema());
|
||||
idEntity.Set("Id", s_revitDocumentStoreId);
|
||||
|
||||
ds.SetEntity(idEntity);
|
||||
ds.SetEntity(stateEntity);
|
||||
t.Commit();
|
||||
})
|
||||
.FireAndForget();
|
||||
}
|
||||
|
||||
private bool EnsureActiveDocumentIsSame(Document document)
|
||||
{
|
||||
var localDoc = _revitContext.UIApplication?.ActiveUIDocument?.Document;
|
||||
if (localDoc == null)
|
||||
{
|
||||
var key = document.ProjectInformation.UniqueId.NotNull();
|
||||
_jsonCacheManager.UpdateObject(key, modelCardState);
|
||||
}
|
||||
catch (Exception ex) when (!ex.IsFatal())
|
||||
{
|
||||
var key = document.ProjectInformation.UniqueId.NotNull();
|
||||
_logger.LogError(ex, "Failed to save model card state for document {DocumentId}", key);
|
||||
return false;
|
||||
}
|
||||
|
||||
return localDoc.Equals(document);
|
||||
}
|
||||
|
||||
protected override void LoadState()
|
||||
{
|
||||
var document = _revitContext.UIApplication?.ActiveUIDocument?.Document;
|
||||
// POC: this can happen? A: Not really, imho (dim) (Adam seyz yes it can if loading also triggers a save)
|
||||
if (document == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var stateEntity = GetSpeckleEntity(document);
|
||||
var stateEntity = GetSpeckleEntity(_revitContext.UIApplication?.ActiveUIDocument?.Document);
|
||||
if (stateEntity == null || !stateEntity.IsValid())
|
||||
{
|
||||
ClearAndSave();
|
||||
return;
|
||||
}
|
||||
var key = document.ProjectInformation.UniqueId.NotNull();
|
||||
var state = _jsonCacheManager.GetObject(key);
|
||||
LoadFromString(state);
|
||||
|
||||
string modelsString = stateEntity.Get<string>("contents");
|
||||
LoadFromString(modelsString);
|
||||
}
|
||||
|
||||
private DataStorage? GetSettingsDataStorage(Document doc)
|
||||
{
|
||||
using FilteredElementCollector collector = new(doc);
|
||||
FilteredElementCollector dataStorages = collector.OfClass(typeof(DataStorage));
|
||||
|
||||
foreach (Element element in dataStorages)
|
||||
{
|
||||
DataStorage dataStorage = (DataStorage)element;
|
||||
Entity settingIdEntity = dataStorage.GetEntity(_idStorageSchema.GetSchema());
|
||||
if (!settingIdEntity.IsValid())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Guid id = settingIdEntity.Get<Guid>("Id");
|
||||
if (!id.Equals(s_revitDocumentStoreId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return dataStorage;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Entity? GetSpeckleEntity(Document? doc)
|
||||
|
||||
+2
-52
@@ -2,9 +2,7 @@ using Autodesk.Revit.DB;
|
||||
using Speckle.Connectors.DUI.Exceptions;
|
||||
using Speckle.Connectors.DUI.Models.Card.SendFilter;
|
||||
using Speckle.Connectors.DUI.Utils;
|
||||
using Speckle.Converters.RevitShared.Extensions;
|
||||
using Speckle.Converters.RevitShared.Helpers;
|
||||
using Speckle.Sdk;
|
||||
|
||||
namespace Speckle.Connectors.RevitShared.Operations.Send.Filters;
|
||||
|
||||
@@ -77,8 +75,8 @@ public class RevitViewsFilter : DiscriminatedObject, ISendFilter, IRevitSendFilt
|
||||
//this used to throw an exception, but we don't want to fail loudly if the view is not found
|
||||
return [];
|
||||
}
|
||||
|
||||
IEnumerable<Element> elementsInView = GetFilteredElementsForView(view);
|
||||
using var viewCollector = new FilteredElementCollector(_doc, view.Id);
|
||||
var elementsInView = viewCollector.ToElements();
|
||||
|
||||
// NOTE: FilteredElementCollector() includes sweeps and reveals from a wall family's definition and includes them as additional objects
|
||||
// on this return. displayValue for Wall already includes these, therefore we end up with duplicate elements on wall sweeps
|
||||
@@ -127,52 +125,4 @@ public class RevitViewsFilter : DiscriminatedObject, ISendFilter, IRevitSendFilt
|
||||
_revitContext = revitContext;
|
||||
_doc = _revitContext.UIApplication?.ActiveUIDocument.Document;
|
||||
}
|
||||
|
||||
// NOTE: Element collector returns parts and source elements even when Parts Visibility is set as "Show Parts" only.
|
||||
// Below function collects list of ids to exclude from final list.
|
||||
private HashSet<ElementId> GetSourceElementIdsToExclude(IEnumerable<Element> elements)
|
||||
{
|
||||
var elementsToExclude = new HashSet<ElementId>();
|
||||
|
||||
foreach (var element in elements)
|
||||
{
|
||||
// check if element is a part
|
||||
if (element.Category?.GetBuiltInCategory() == BuiltInCategory.OST_Parts && element is Part part)
|
||||
{
|
||||
try
|
||||
{
|
||||
// get source element ids from the part
|
||||
var sourceIds = part.GetSourceElementIds();
|
||||
if (sourceIds != null)
|
||||
{
|
||||
foreach (var sourceId in sourceIds)
|
||||
{
|
||||
elementsToExclude.Add(sourceId.HostElementId);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e) when (!e.IsFatal())
|
||||
{
|
||||
// silently continue processing other Parts if one fails
|
||||
// this follows the pattern used elsewhere in the codebase
|
||||
}
|
||||
}
|
||||
}
|
||||
return elementsToExclude;
|
||||
}
|
||||
|
||||
private IEnumerable<Element> GetFilteredElementsForView(View view)
|
||||
{
|
||||
using var viewCollector = new FilteredElementCollector(_doc, view.Id);
|
||||
var allElements = viewCollector.ToElements();
|
||||
|
||||
// parts filtering when view is set to show Parts only (and overwrites allElements)
|
||||
if (view.PartsVisibility == PartsVisibility.ShowPartsOnly)
|
||||
{
|
||||
var idsToExclude = GetSourceElementIdsToExclude(allElements);
|
||||
return allElements.Where(e => !idsToExclude.Contains(e.Id));
|
||||
}
|
||||
|
||||
return allElements;
|
||||
}
|
||||
}
|
||||
|
||||
+273
-43
@@ -1,95 +1,188 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Autodesk.Revit.DB;
|
||||
using Speckle.Converters.Common;
|
||||
using Speckle.Converters.Common.Objects;
|
||||
using Speckle.Converters.RevitShared.Helpers;
|
||||
using Speckle.Converters.RevitShared.Services;
|
||||
using Speckle.Converters.RevitShared.Settings;
|
||||
using Speckle.DoubleNumerics;
|
||||
using Speckle.Sdk.Common;
|
||||
using Speckle.Sdk.Common.Exceptions;
|
||||
|
||||
namespace Speckle.Converters.RevitShared.ToHost.TopLevel;
|
||||
|
||||
public class MeshConverterToHost : ITypedConverter<SOG.Mesh, List<DB.GeometryObject>>
|
||||
public class MeshConverterToHost : ITypedConverter<SOG.Mesh, List<GeometryObject>>
|
||||
{
|
||||
private readonly RevitToHostCacheSingleton _revitToHostCacheSingleton;
|
||||
private readonly ScalingServiceToHost _scalingServiceToHost;
|
||||
private readonly IReferencePointConverter _referencePointConverter;
|
||||
private readonly IConverterSettingsStore<RevitConversionSettings> _converterSettings;
|
||||
private const double PLANAR_TOLERANCE = 1e-9; // tune if needed, added to avoid numeric noise
|
||||
private const bool ALLOW_VERTEX_COLOR_OVERRIDE = true; // flip to true if colors should win
|
||||
|
||||
private Document? _lastDoc; // if this converter instance is used across open documents, we'll want to invalidate the material cache
|
||||
|
||||
public MeshConverterToHost(
|
||||
RevitToHostCacheSingleton revitToHostCacheSingleton,
|
||||
ScalingServiceToHost scalingServiceToHost,
|
||||
IReferencePointConverter referencePointConverter
|
||||
IReferencePointConverter referencePointConverter,
|
||||
IConverterSettingsStore<RevitConversionSettings> converterSettings
|
||||
)
|
||||
{
|
||||
_revitToHostCacheSingleton = revitToHostCacheSingleton;
|
||||
_scalingServiceToHost = scalingServiceToHost;
|
||||
_referencePointConverter = referencePointConverter;
|
||||
_converterSettings = converterSettings;
|
||||
}
|
||||
|
||||
public List<DB.GeometryObject> Convert(SOG.Mesh mesh)
|
||||
public List<GeometryObject> Convert(SOG.Mesh mesh)
|
||||
{
|
||||
TessellatedShapeBuilderTarget target = TessellatedShapeBuilderTarget.Mesh;
|
||||
TessellatedShapeBuilderFallback fallback = TessellatedShapeBuilderFallback.Salvage;
|
||||
const TessellatedShapeBuilderTarget TARGET = TessellatedShapeBuilderTarget.Mesh;
|
||||
const TessellatedShapeBuilderFallback FALLBACK = TessellatedShapeBuilderFallback.Salvage;
|
||||
|
||||
using var tsb = new TessellatedShapeBuilder()
|
||||
{
|
||||
Fallback = fallback,
|
||||
Target = target,
|
||||
GraphicsStyleId = ElementId.InvalidElementId
|
||||
};
|
||||
using var tsb = new TessellatedShapeBuilder();
|
||||
tsb.Fallback = FALLBACK;
|
||||
tsb.Target = TARGET;
|
||||
tsb.GraphicsStyleId = ElementId.InvalidElementId;
|
||||
// tsb.OpenConnectedFaceSet(false);
|
||||
|
||||
tsb.OpenConnectedFaceSet(false);
|
||||
var vertices = ArrayToPoints(mesh.vertices, mesh.units);
|
||||
var vertColors = DecodeVertexColors(mesh.colors);
|
||||
|
||||
ElementId materialId = ElementId.InvalidElementId;
|
||||
// optional default material from cache
|
||||
ElementId defaultMat = ElementId.InvalidElementId;
|
||||
if (
|
||||
_revitToHostCacheSingleton.MaterialsByObjectId.TryGetValue(
|
||||
mesh.applicationId ?? mesh.id.NotNull(),
|
||||
out var mappedElementId
|
||||
out var mapped
|
||||
)
|
||||
)
|
||||
{
|
||||
materialId = mappedElementId;
|
||||
defaultMat = mapped;
|
||||
}
|
||||
|
||||
bool hasExplicitMat = defaultMat != ElementId.InvalidElementId;
|
||||
|
||||
var facesByMat = new Dictionary<ElementId, List<IList<XYZ>>>();
|
||||
|
||||
int i = 0;
|
||||
while (i < mesh.faces.Count)
|
||||
{
|
||||
int n = mesh.faces[i];
|
||||
if (n < 3)
|
||||
int faceVertexCount = mesh.faces[i];
|
||||
if (faceVertexCount < 3)
|
||||
{
|
||||
n += 3; // 0 -> 3, 1 -> 4 to preserve backwards compatibility
|
||||
faceVertexCount += 3;
|
||||
}
|
||||
|
||||
var points = mesh.faces.GetRange(i + 1, n).Select(x => vertices[x]).ToArray();
|
||||
|
||||
if (IsNonPlanarQuad(points))
|
||||
var faceIdx = mesh.faces.GetRange(i + 1, faceVertexCount);
|
||||
var points = new XYZ[faceVertexCount];
|
||||
for (int k = 0; k < faceVertexCount; k++)
|
||||
{
|
||||
// Non-planar quads will be triangulated as it's more desirable than `TessellatedShapeBuilder.Build`'s attempt to make them planar.
|
||||
// TODO consider triangulating all n > 3 polygons that are non-planar
|
||||
var triPoints = new List<XYZ> { points[0], points[1], points[3] };
|
||||
var face1 = new TessellatedFace(triPoints, materialId);
|
||||
tsb.AddFace(face1);
|
||||
|
||||
triPoints = new List<XYZ> { points[1], points[2], points[3] };
|
||||
|
||||
var face2 = new TessellatedFace(triPoints, materialId);
|
||||
tsb.AddFace(face2);
|
||||
}
|
||||
else
|
||||
{
|
||||
var face = new TessellatedFace(points, materialId);
|
||||
tsb.AddFace(face);
|
||||
points[k] = vertices[faceIdx[k]];
|
||||
}
|
||||
|
||||
i += n + 1;
|
||||
var faceMaterial = FaceMat(faceIdx);
|
||||
switch (faceVertexCount)
|
||||
{
|
||||
case 4 when IsNonPlanarQuad(points):
|
||||
{
|
||||
// Non-planar quads will be triangulated as it's more desirable than
|
||||
// TessellatedShapeBuilder.Build's attempt to make them planar.
|
||||
AddFace([points[0], points[1], points[3]], faceMaterial);
|
||||
AddFace([points[1], points[2], points[3]], faceMaterial);
|
||||
break;
|
||||
}
|
||||
case > 4 when !IsPlanarNgon(points):
|
||||
{
|
||||
for (int k = 1; k < faceVertexCount - 1; k++)
|
||||
{
|
||||
AddFace([points[0], points[k], points[k + 1]], faceMaterial);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
AddFace(points, faceMaterial);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
i += faceVertexCount + 1;
|
||||
}
|
||||
|
||||
tsb.CloseConnectedFaceSet();
|
||||
var all = new List<GeometryObject>();
|
||||
|
||||
tsb.Build();
|
||||
var result = tsb.GetBuildResult();
|
||||
foreach (var kv in facesByMat)
|
||||
{
|
||||
using var perMat = new TessellatedShapeBuilder();
|
||||
perMat.Fallback = FALLBACK;
|
||||
perMat.Target = TARGET;
|
||||
perMat.GraphicsStyleId = ElementId.InvalidElementId;
|
||||
|
||||
return result.GetGeometricalObjects().ToList();
|
||||
perMat.OpenConnectedFaceSet(true);
|
||||
foreach (var tf in kv.Value.Select(pts => new TessellatedFace(pts, kv.Key)).Where(tf => tf.IsValidObject))
|
||||
{
|
||||
perMat.AddFace(tf);
|
||||
}
|
||||
|
||||
perMat.CloseConnectedFaceSet();
|
||||
perMat.Build();
|
||||
|
||||
all.AddRange(perMat.GetBuildResult().GetGeometricalObjects());
|
||||
}
|
||||
|
||||
return all;
|
||||
|
||||
void AddFace(IList<XYZ> pts, ElementId mat)
|
||||
{
|
||||
if (!facesByMat.TryGetValue(mat, out var list))
|
||||
{
|
||||
facesByMat[mat] = list = [];
|
||||
}
|
||||
|
||||
list.Add(pts);
|
||||
}
|
||||
|
||||
// local helper to pick a face material from vertex colors
|
||||
[SuppressMessage("ReSharper", "RedundantLogicalConditionalExpressionOperand")]
|
||||
ElementId FaceMat(IList<int> idx)
|
||||
{
|
||||
int vCount = vertColors.Length;
|
||||
var hasColors = vCount > 0;
|
||||
|
||||
if (!hasColors || hasExplicitMat && !ALLOW_VERTEX_COLOR_OVERRIDE)
|
||||
{
|
||||
return defaultMat;
|
||||
}
|
||||
|
||||
int sr = 0,
|
||||
sg = 0,
|
||||
sb = 0,
|
||||
c = 0;
|
||||
foreach (var v in idx)
|
||||
{
|
||||
if ((uint)v >= (uint)vCount)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var vc = vertColors[v];
|
||||
sr += vc.Red;
|
||||
sg += vc.Green;
|
||||
sb += vc.Blue;
|
||||
c++;
|
||||
}
|
||||
|
||||
if (c == 0)
|
||||
{
|
||||
return defaultMat;
|
||||
}
|
||||
|
||||
byte r = Quant((byte)(sr / c));
|
||||
byte g = Quant((byte)(sg / c));
|
||||
byte b = Quant((byte)(sb / c));
|
||||
return GetOrCreateMaterial(_converterSettings.Current.Document, r, g, b);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsNonPlanarQuad(IList<XYZ> points)
|
||||
@@ -117,7 +210,59 @@ public class MeshConverterToHost : ITypedConverter<SOG.Mesh, List<DB.GeometryObj
|
||||
1,
|
||||
1
|
||||
);
|
||||
return matrix.GetDeterminant() != 0;
|
||||
|
||||
return Math.Abs(matrix.GetDeterminant()) > PLANAR_TOLERANCE;
|
||||
}
|
||||
|
||||
private static bool IsPlanarNgon(IList<XYZ> vertices)
|
||||
{
|
||||
int n = vertices.Count;
|
||||
if (n < 4)
|
||||
{
|
||||
return true; // 3 points always define a plane
|
||||
}
|
||||
|
||||
// Newell’s method for robust best-fit plane =>
|
||||
// https://www.realtimerendering.com/resources/GraphicsGems/gemsiii/newell.c
|
||||
double normalX = 0,
|
||||
normalY = 0,
|
||||
normalZ = 0;
|
||||
for (int i = 0, j = n - 1; i < n; j = i, i++)
|
||||
{
|
||||
var u = vertices[i];
|
||||
var v = vertices[j];
|
||||
normalX += (v.Y - u.Y) * (v.Z + u.Z);
|
||||
normalY += (v.Z - u.Z) * (v.X + u.X);
|
||||
normalZ += (v.X - u.X) * (v.Y + u.Y);
|
||||
}
|
||||
|
||||
var length = Math.Sqrt(normalX * normalX + normalY * normalY + normalZ * normalZ);
|
||||
if (length < 1e-12)
|
||||
{
|
||||
return true; // degenerate polygon; treat as planar
|
||||
}
|
||||
|
||||
normalX /= length;
|
||||
normalY /= length;
|
||||
normalZ /= length;
|
||||
|
||||
var pointOnPlane = vertices[0];
|
||||
double normalisedPlane = -(normalX * pointOnPlane.X + normalY * pointOnPlane.Y + normalZ * pointOnPlane.Z);
|
||||
|
||||
// max signed distance of all vertices to plane
|
||||
double maxSignedDistance = 0;
|
||||
for (int i = 1; i < n; i++)
|
||||
{
|
||||
var p = vertices[i];
|
||||
double distance = normalX * p.X + normalY * p.Y + normalZ * p.Z + normalisedPlane;
|
||||
maxSignedDistance = Math.Max(maxSignedDistance, Math.Abs(distance));
|
||||
if (maxSignedDistance > PLANAR_TOLERANCE)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private XYZ[] ArrayToPoints(IList<double> arr, string units)
|
||||
@@ -128,7 +273,7 @@ public class MeshConverterToHost : ITypedConverter<SOG.Mesh, List<DB.GeometryObj
|
||||
}
|
||||
|
||||
XYZ[] points = new XYZ[arr.Count / 3];
|
||||
var fTypeId = _scalingServiceToHost.UnitsToNative(units) ?? UnitTypeId.Meters;
|
||||
var fTypeId = _scalingServiceToHost.UnitsToNative(units);
|
||||
|
||||
for (int i = 2, k = 0; i < arr.Count; i += 3)
|
||||
{
|
||||
@@ -146,4 +291,89 @@ public class MeshConverterToHost : ITypedConverter<SOG.Mesh, List<DB.GeometryObj
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
private readonly Dictionary<int, ElementId> _matCache = new();
|
||||
|
||||
private static Color[] DecodeVertexColors(IList<int>? argb)
|
||||
{
|
||||
if (argb == null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var outArr = new Color[argb.Count];
|
||||
for (int i = 0; i < argb.Count; i++)
|
||||
{
|
||||
uint v = unchecked((uint)argb[i]); // Speckle stores ARGB in a signed int
|
||||
byte r = (byte)((v >> 16) & 0xFF);
|
||||
byte g = (byte)((v >> 8) & 0xFF);
|
||||
byte b = (byte)(v & 0xFF);
|
||||
|
||||
outArr[i] = new Color(r, g, b);
|
||||
}
|
||||
|
||||
return outArr;
|
||||
}
|
||||
|
||||
private static byte Quant(byte v, int step = 17)
|
||||
{
|
||||
int q = (int)Math.Round(v / (double)step) * step;
|
||||
return (byte)Math.Max(0, Math.Min(255, q));
|
||||
}
|
||||
|
||||
private ElementId GetOrCreateMaterial(Document doc, byte r, byte g, byte b)
|
||||
{
|
||||
if (!ReferenceEquals(doc, _lastDoc)) // essentially a document change check hack
|
||||
{
|
||||
_matCache.Clear();
|
||||
_lastDoc = doc;
|
||||
}
|
||||
|
||||
int key = (r << 16) | (g << 8) | b;
|
||||
if (_matCache.TryGetValue(key, out var id))
|
||||
{
|
||||
return id;
|
||||
}
|
||||
|
||||
string name = $"Speckle_DS_{r}_{g}_{b}";
|
||||
|
||||
Material? existing;
|
||||
using (var filteredElementCollector = new FilteredElementCollector(doc))
|
||||
{
|
||||
filteredElementCollector.OfClass(typeof(Material)); // add the filter on the same instance
|
||||
existing = filteredElementCollector
|
||||
.Cast<Material>() // enumerate inside the using
|
||||
.FirstOrDefault(m => string.Equals(m.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
return _matCache[key] = existing.Id;
|
||||
}
|
||||
|
||||
ElementId mid;
|
||||
if (doc.IsModifiable)
|
||||
{
|
||||
using var st = new SubTransaction(doc);
|
||||
st.Start();
|
||||
mid = CreateMaterialWithColor(doc, name, r, g, b);
|
||||
st.Commit();
|
||||
}
|
||||
else
|
||||
{
|
||||
using var t = new Transaction(doc, "Create DS Material");
|
||||
t.Start();
|
||||
mid = CreateMaterialWithColor(doc, name, r, g, b);
|
||||
t.Commit();
|
||||
}
|
||||
|
||||
return _matCache[key] = mid;
|
||||
|
||||
static ElementId CreateMaterialWithColor(Document doc, string name, byte r, byte g, byte b)
|
||||
{
|
||||
var materialId = Material.Create(doc, name);
|
||||
((Material)doc.GetElement(materialId)).Color = new Color(r, g, b);
|
||||
return materialId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -22,14 +22,15 @@ public class ClassPropertiesExtractor
|
||||
{
|
||||
Dictionary<string, object?> elementPropertiesDict = ExtractElementProperties(element);
|
||||
|
||||
// type specific properties
|
||||
// add type specific props not included in parameters.
|
||||
// so far, no extra props are needed
|
||||
/*
|
||||
switch (element)
|
||||
{
|
||||
// area scheme for area elements
|
||||
case DB.Area area:
|
||||
elementPropertiesDict.Add("areaScheme", area.AreaScheme?.Name);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
*/
|
||||
|
||||
return elementPropertiesDict;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Speckle.Converters.Rhino.Extensions;
|
||||
|
||||
public static class GeometryBaseExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Getting translation vector from origin to the Geometry bbox Center (if geometry is far from origin and translation needed)
|
||||
/// This is needed for some objects, because of Rhino using single precision numbers for Mesh vertices: https://wiki.mcneel.com/rhino/farfromorigin
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// Vector from origin to Geometry bbox center (if translation needed), otherwise zero-length vector.
|
||||
/// </returns>
|
||||
public static bool IsFarFromOrigin(this RG.GeometryBase geometry, out RG.Vector3d vectorToGeometry)
|
||||
{
|
||||
var geometryBbox = geometry.GetBoundingBox(false); // 'false' for 'accurate' parameter to accelerate bbox calculation
|
||||
if (geometryBbox.Min.DistanceTo(RG.Point3d.Origin) > 1e5 || geometryBbox.Max.DistanceTo(RG.Point3d.Origin) > 1e5)
|
||||
{
|
||||
vectorToGeometry = new RG.Vector3d(geometryBbox.Center);
|
||||
return true;
|
||||
}
|
||||
|
||||
vectorToGeometry = new RG.Vector3d();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
using Rhino;
|
||||
using Rhino;
|
||||
|
||||
namespace Speckle.Converters.Rhino;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the settings used for Rhino and Grasshopper conversions.
|
||||
/// </summary>
|
||||
public record RhinoConversionSettings(RhinoDoc Document, string SpeckleUnits, bool AddVisualizationProperties);
|
||||
public record RhinoConversionSettings(
|
||||
RhinoDoc Document,
|
||||
string SpeckleUnits,
|
||||
bool ModelFarFromOrigin,
|
||||
bool AddVisualizationProperties
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Rhino;
|
||||
using Rhino;
|
||||
using Speckle.Converters.Common;
|
||||
using Speckle.InterfaceGenerator;
|
||||
|
||||
@@ -13,5 +13,24 @@ public class RhinoConversionSettingsFactory(
|
||||
public RhinoConversionSettings Current => settingsStore.Current;
|
||||
|
||||
public RhinoConversionSettings Create(RhinoDoc document, bool addVisualizationProperties) =>
|
||||
new(document, unitsConverter.ConvertOrThrow(RhinoDoc.ActiveDoc.ModelUnitSystem), addVisualizationProperties);
|
||||
new(
|
||||
document,
|
||||
unitsConverter.ConvertOrThrow(RhinoDoc.ActiveDoc.ModelUnitSystem),
|
||||
ModelFarFromOrigin(),
|
||||
addVisualizationProperties
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Quick check whether any of the objects in the scene might be located too far from origin and cause precision issues during meshing.
|
||||
/// It prevents 'normal' Rhino models (not too far from origin) from unnecessary Bbox calculations on every object on Send.
|
||||
/// </summary>
|
||||
private bool ModelFarFromOrigin()
|
||||
{
|
||||
RG.BoundingBox bbox = RhinoDoc.ActiveDoc.Objects.BoundingBox;
|
||||
if (bbox.Min.DistanceTo(RG.Point3d.Origin) > 1e5 || bbox.Max.DistanceTo(RG.Point3d.Origin) > 1e5)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+61
-14
@@ -1,13 +1,14 @@
|
||||
using Rhino;
|
||||
using Rhino.DocObjects;
|
||||
using Speckle.Converters.Common.Objects;
|
||||
using Speckle.Converters.Rhino.Extensions;
|
||||
using Speckle.DoubleNumerics;
|
||||
using Speckle.Sdk.Common.Exceptions;
|
||||
|
||||
namespace Speckle.Converters.Rhino.ToSpeckle.Meshing;
|
||||
|
||||
public static class DisplayMeshExtractor
|
||||
{
|
||||
public static RG.Mesh GetDisplayMesh(RhinoObject obj, RhinoDoc doc)
|
||||
public static RG.Mesh GetDisplayMesh(RhinoObject obj)
|
||||
{
|
||||
// note: unsure this is nice, we get bigger meshes - we should to benchmark (conversion time vs size tradeoffs)
|
||||
var joinedMesh = new RG.Mesh();
|
||||
@@ -22,15 +23,15 @@ public static class DisplayMeshExtractor
|
||||
switch (obj)
|
||||
{
|
||||
case BrepObject brep:
|
||||
joinedMesh.Append(GetGeometryDisplayMesh(brep.BrepGeometry, doc));
|
||||
joinedMesh.Append(GetGeometryDisplayMesh(brep.BrepGeometry));
|
||||
break;
|
||||
case ExtrusionObject extrusion:
|
||||
joinedMesh.Append(GetGeometryDisplayMesh(extrusion.ExtrusionGeometry.ToBrep(), doc));
|
||||
joinedMesh.Append(GetGeometryDisplayMesh(extrusion.ExtrusionGeometry.ToBrep()));
|
||||
break;
|
||||
case SubDObject subDObject:
|
||||
if (subDObject.Geometry is RG.SubD subdGeometry)
|
||||
{
|
||||
joinedMesh.Append(GetGeometryDisplayMesh(subdGeometry, doc));
|
||||
joinedMesh.Append(GetGeometryDisplayMesh(subdGeometry));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -48,16 +49,18 @@ public static class DisplayMeshExtractor
|
||||
/// <summary>
|
||||
/// Extracting Rhino Mesh from Rhino GeometryBase using specified MeshingParameters settings, e.g. minimumEdgeLength.
|
||||
/// </summary>
|
||||
public static RG.Mesh GetGeometryDisplayMesh(RG.GeometryBase geometry, RhinoDoc doc)
|
||||
public static RG.Mesh GetGeometryDisplayMesh(RG.GeometryBase geometry, bool highPrecision = false)
|
||||
{
|
||||
double minEdgeLength = highPrecision ? GetAccurateMinEdgeLegth(geometry) : 0.05;
|
||||
|
||||
// declare "renderMeshes" as a separate var, because it needs to be checked for null after each Mesh.Create method
|
||||
RG.Mesh[] renderMeshes;
|
||||
var joinedMesh = new RG.Mesh();
|
||||
RG.MeshingParameters meshParams = RG.MeshingParameters.DocumentCurrentSetting(doc);
|
||||
|
||||
switch (geometry)
|
||||
{
|
||||
case RG.Brep brep:
|
||||
renderMeshes = RG.Mesh.CreateFromBrep(brep, meshParams);
|
||||
renderMeshes = RG.Mesh.CreateFromBrep(brep, new(0.05, minEdgeLength));
|
||||
break;
|
||||
case RG.SubD subd:
|
||||
#pragma warning disable CA2000
|
||||
@@ -66,7 +69,7 @@ public static class DisplayMeshExtractor
|
||||
renderMeshes = [subdMesh];
|
||||
break;
|
||||
case RG.Extrusion extrusion:
|
||||
renderMeshes = RG.Mesh.CreateFromBrep(extrusion.ToBrep(), meshParams);
|
||||
renderMeshes = RG.Mesh.CreateFromBrep(extrusion.ToBrep(), new(0.05, minEdgeLength));
|
||||
break;
|
||||
default:
|
||||
throw new ConversionException($"Unsupported object for display mesh generation {geometry.GetType().FullName}");
|
||||
@@ -90,17 +93,61 @@ public static class DisplayMeshExtractor
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracting Rhino Mesh and converting to Speckle with the most suitable settings
|
||||
/// Calculating optimal meshing parameter 'minimumEdgeLength' for the given geometry.
|
||||
/// </summary>
|
||||
private static double GetAccurateMinEdgeLegth(RG.GeometryBase geometry)
|
||||
{
|
||||
// adjust meshing parameters if Brep edges are too close to the document tolerance
|
||||
double minEdgeLength = 0.05;
|
||||
if (geometry is RG.Brep brep && brep.Edges.Any(x => x.GetLength() < minEdgeLength))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return minEdgeLength;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracting Rhino Mesh and converting to Speckle with the most suitable settings (e.g. moving to origin first, if needed)
|
||||
/// This is needed because of Rhino using single precision numbers for Mesh vertices: https://wiki.mcneel.com/rhino/farfromorigin
|
||||
/// </summary>
|
||||
/// <returns>List of converted Speckle meshes</returns>
|
||||
public static List<SOG.Mesh> GetSpeckleMeshes(
|
||||
RG.GeometryBase geometry,
|
||||
ITypedConverter<RG.Mesh, SOG.Mesh> meshConverter,
|
||||
RhinoDoc doc
|
||||
bool modelFarFromOrigin,
|
||||
string units,
|
||||
ITypedConverter<RG.Mesh, SOG.Mesh> meshConverter
|
||||
)
|
||||
{
|
||||
RG.Mesh displayMesh = GetGeometryDisplayMesh(geometry, doc);
|
||||
List<SOG.Mesh> displayValue = new() { meshConverter.Convert(displayMesh) };
|
||||
RG.GeometryBase geometryToMesh = geometry;
|
||||
RG.Vector3d? vector = null;
|
||||
|
||||
// 1.1. If needed, move geometry to origin
|
||||
if (modelFarFromOrigin && geometry.IsFarFromOrigin(out RG.Vector3d vectorToGeometry))
|
||||
{
|
||||
geometryToMesh = geometry.Duplicate();
|
||||
geometryToMesh.Transform(RG.Transform.Translation(-vectorToGeometry));
|
||||
vector = vectorToGeometry;
|
||||
}
|
||||
// 1.2. Extract Rhino Mesh
|
||||
RG.Mesh movedDisplayMesh = GetGeometryDisplayMesh(geometryToMesh, true);
|
||||
|
||||
// 2. Convert extracted Mesh to Speckle. We don't move geometry back yet, because 'far from origin' geometry is causing Speckle conversion issues too
|
||||
List<SOG.Mesh> displayValue = new() { meshConverter.Convert(movedDisplayMesh) };
|
||||
|
||||
// 3. Move Speckle geometry back from origin, if translation was applied
|
||||
MoveSpeckleMeshes(displayValue, vector, units);
|
||||
|
||||
return displayValue;
|
||||
}
|
||||
|
||||
public static void MoveSpeckleMeshes(List<SOG.Mesh> displayValue, RG.Vector3d? vectorToGeometry, string units)
|
||||
{
|
||||
if (vectorToGeometry is RG.Vector3d vector)
|
||||
{
|
||||
Matrix4x4 matrix = new(1, 0, 0, vector.X, 0, 1, 0, vector.Y, 0, 0, 1, vector.Z, 0, 0, 0, 1);
|
||||
SO.Transform transform = new() { matrix = matrix, units = units };
|
||||
displayValue.ForEach(x => x.Transform(transform));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -30,8 +30,9 @@ public class BrepToSpeckleConverter : ITypedConverter<RG.Brep, SOG.BrepX>
|
||||
|
||||
List<SOG.Mesh> displayValue = DisplayMeshExtractor.GetSpeckleMeshes(
|
||||
target,
|
||||
_meshConverter,
|
||||
_settingsStore.Current.Document
|
||||
_settingsStore.Current.ModelFarFromOrigin,
|
||||
_settingsStore.Current.SpeckleUnits,
|
||||
_meshConverter
|
||||
);
|
||||
|
||||
var bx = new SOG.BrepX()
|
||||
|
||||
+3
-2
@@ -30,8 +30,9 @@ public class ExtrusionToSpeckleConverter : ITypedConverter<RG.Extrusion, SOG.Ext
|
||||
|
||||
List<SOG.Mesh> displayValue = DisplayMeshExtractor.GetSpeckleMeshes(
|
||||
target,
|
||||
_meshConverter,
|
||||
_settingsStore.Current.Document
|
||||
_settingsStore.Current.ModelFarFromOrigin,
|
||||
_settingsStore.Current.SpeckleUnits,
|
||||
_meshConverter
|
||||
);
|
||||
|
||||
var bx = new SOG.ExtrusionX()
|
||||
|
||||
+3
-2
@@ -41,8 +41,9 @@ public class HatchToSpeckleConverter : ITypedConverter<RG.Hatch, SOG.Region>
|
||||
|
||||
List<SOG.Mesh> displayValue = DisplayMeshExtractor.GetSpeckleMeshes(
|
||||
brep,
|
||||
_meshConverter,
|
||||
_settingsStore.Current.Document
|
||||
_settingsStore.Current.ModelFarFromOrigin,
|
||||
_settingsStore.Current.SpeckleUnits,
|
||||
_meshConverter
|
||||
);
|
||||
|
||||
return new SOG.Region
|
||||
|
||||
+23
-16
@@ -1,5 +1,7 @@
|
||||
using Speckle.Converters.Common;
|
||||
using Speckle.Converters.Common.Objects;
|
||||
using Speckle.Converters.Rhino.Extensions;
|
||||
using Speckle.Converters.Rhino.ToSpeckle.Meshing;
|
||||
using Speckle.Sdk.Common.Exceptions;
|
||||
|
||||
namespace Speckle.Converters.Rhino.ToSpeckle.Raw;
|
||||
@@ -32,34 +34,39 @@ public class MeshToSpeckleConverter : ITypedConverter<RG.Mesh, SOG.Mesh>
|
||||
throw new ValidationException("Cannot convert a mesh with 0 vertices/faces");
|
||||
}
|
||||
|
||||
SOG.Mesh convertedMesh = ConvertMesh(target);
|
||||
// Extracting Rhino Mesh and converting to Speckle with the most suitable settings (e.g. moving to origin first, if needed)
|
||||
// This is needed because of Rhino using single precision numbers for Mesh vertices: https://wiki.mcneel.com/rhino/farfromorigin
|
||||
RG.Mesh meshToConvert = target;
|
||||
RG.Vector3d? vector = null;
|
||||
|
||||
// 1. If needed, move geometry to origin
|
||||
if (_settingsStore.Current.ModelFarFromOrigin && target.IsFarFromOrigin(out RG.Vector3d vectorToGeometry))
|
||||
{
|
||||
meshToConvert = (RG.Mesh)target.Duplicate();
|
||||
meshToConvert.Transform(RG.Transform.Translation(-vectorToGeometry));
|
||||
vector = vectorToGeometry;
|
||||
}
|
||||
// 2. Convert extracted Mesh to Speckle. We don't move geometry back yet, because 'far from origin' geometry is causing Speckle conversion issues too
|
||||
SOG.Mesh convertedMesh = ConvertMesh(meshToConvert);
|
||||
|
||||
// 3. Move Speckle geometry back from origin, if translation was applied
|
||||
DisplayMeshExtractor.MoveSpeckleMeshes([convertedMesh], vector, _settingsStore.Current.SpeckleUnits);
|
||||
|
||||
return convertedMesh;
|
||||
}
|
||||
|
||||
// Rhino common is casting mesh vertex coords from doubles to float: by default the api returns `Vertices` as float instead of double precision
|
||||
// https://github.com/mcneel/rhino3dm/blob/71c63a8c1c87782a13a1b76c825e4b792b36fd09/src/dotnet/opennurbs/opennurbs_mesh.cs#L6990-L7000
|
||||
// We need to use double precision or else meshes far from origin will come out distorted: do *not* access `Vertices` directly - use `ToPoint3dArray`
|
||||
private double[] ConvertDoublePrecisionVertices(RG.Mesh target)
|
||||
private SOG.Mesh ConvertMesh(RG.Mesh target)
|
||||
{
|
||||
var vertexCoordinates = new double[target.Vertices.Count * 3];
|
||||
RG.Point3d[] vertices = target.Vertices.ToPoint3dArray();
|
||||
var x = 0;
|
||||
for (int i = 0; i < vertices.Length; i++)
|
||||
for (int i = 0; i < target.Vertices.Count; i++)
|
||||
{
|
||||
var v = vertices[i];
|
||||
var v = target.Vertices[i];
|
||||
vertexCoordinates[x++] = v.X;
|
||||
vertexCoordinates[x++] = v.Y;
|
||||
vertexCoordinates[x++] = v.Z;
|
||||
}
|
||||
|
||||
return vertexCoordinates;
|
||||
}
|
||||
|
||||
private SOG.Mesh ConvertMesh(RG.Mesh target)
|
||||
{
|
||||
var vertexCoordinates = ConvertDoublePrecisionVertices(target);
|
||||
|
||||
List<int> faces = new();
|
||||
|
||||
foreach (RG.MeshNgon polygon in target.GetNgonAndFacesEnumerable())
|
||||
@@ -74,7 +81,7 @@ public class MeshToSpeckleConverter : ITypedConverter<RG.Mesh, SOG.Mesh>
|
||||
}
|
||||
|
||||
var colors = new int[target.VertexColors.Count];
|
||||
int x = 0;
|
||||
x = 0;
|
||||
foreach (var c in target.VertexColors)
|
||||
{
|
||||
colors[x++] = c.ToArgb();
|
||||
|
||||
+3
-2
@@ -30,8 +30,9 @@ public class SubDToSpeckleConverter : ITypedConverter<RG.SubD, SOG.SubDX>
|
||||
|
||||
List<SOG.Mesh> displayValue = DisplayMeshExtractor.GetSpeckleMeshes(
|
||||
target,
|
||||
_meshConverter,
|
||||
_settingsStore.Current.Document
|
||||
_settingsStore.Current.ModelFarFromOrigin,
|
||||
_settingsStore.Current.SpeckleUnits,
|
||||
_meshConverter
|
||||
);
|
||||
|
||||
var bx = new SOG.SubDX()
|
||||
|
||||
@@ -115,30 +115,6 @@ public class ConfigBinding : IBinding
|
||||
}
|
||||
}
|
||||
|
||||
public GlobalConfig? GetGlobalConfig()
|
||||
{
|
||||
var rawConfig = _jsonCacheManager.GetObject("global");
|
||||
if (rawConfig is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var config = _serializer.Deserialize<GlobalConfig>(rawConfig);
|
||||
if (config is null)
|
||||
{
|
||||
throw new SerializationException("Failed to deserialize global config");
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
catch (SerializationException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public AccountsConfig? GetAccountsConfig()
|
||||
{
|
||||
var rawConfig = _jsonCacheManager.GetObject("accounts");
|
||||
@@ -206,11 +182,6 @@ public class ConnectorConfig
|
||||
public bool DarkTheme { get; set; } = true;
|
||||
}
|
||||
|
||||
public class GlobalConfig
|
||||
{
|
||||
public bool IsUpdateNotificationDisabled { get; set; }
|
||||
}
|
||||
|
||||
public class AccountsConfig
|
||||
{
|
||||
public string? UserSelectedAccountId { get; set; }
|
||||
|
||||
@@ -968,7 +968,6 @@ Global
|
||||
Connectors\Autocad\Speckle.Connectors.Civil3dShared\Speckle.Connectors.Civil3dShared.projitems*{4459f2b1-a340-488e-a856-eb2ae9c72ad4}*SharedItemsImports = 5
|
||||
Converters\Revit\Speckle.Converters.RevitShared\Speckle.Converters.RevitShared.projitems*{4d40a101-07e6-4ff2-8934-83434932591d}*SharedItemsImports = 5
|
||||
Converters\Tekla\Speckle.Converters.TeklaShared\Speckle.Converters.TeklaShared.projitems*{52666479-5401-47d6-b7ba-d554784439ea}*SharedItemsImports = 13
|
||||
Connectors\Rhino\Speckle.Connectors.RhinoShared\Speckle.Connectors.RhinoShared.projitems*{5422f2c8-1e00-4dae-bb01-65a17be8cd68}*SharedItemsImports = 5
|
||||
Converters\Autocad\Speckle.Converters.AutocadShared\Speckle.Converters.AutocadShared.projitems*{5505b953-d434-49ce-8ebd-efd7b3c378f7}*SharedItemsImports = 5
|
||||
Converters\Navisworks\Speckle.Converters.NavisworksShared\Speckle.Converters.NavisworksShared.projitems*{56680ea7-3599-4d88-83a5-b43ba93ac046}*SharedItemsImports = 5
|
||||
Converters\Rhino\Speckle.Converters.RhinoShared\Speckle.Converters.RhinoShared.projitems*{56a909ae-6e99-4d4d-a22e-38bdc5528b8e}*SharedItemsImports = 5
|
||||
|
||||
Reference in New Issue
Block a user