Compare commits

...

47 Commits

Author SHA1 Message Date
Mucahit Bilal GOKER d4ee1f2a55 feat(revit): add roomId and spaceId parameters (#1181)
.NET Build and Publish / build-connectors (push) Has been cancelled
.NET Build and Publish / deploy-installers (push) Has been cancelled
* add roomId and spaceId parameters

* clean up
2025-11-11 10:30:27 +03:00
Jedd Morgan 4f960cc670 readme tweaks (#1179) 2025-11-10 13:52:43 +00:00
Jedd Morgan 1f63c1f8b3 Merge pull request #1165 from specklesystems/main
chore: Back merge main -> Dev
2025-11-10 13:48:30 +00:00
Jedd Morgan 2ed9ffbca7 Use Openheadless for unit support (#1178) 2025-11-10 12:53:55 +00:00
Jedd Morgan d87b862e2b add readme (#1173) 2025-11-10 12:49:39 +00:00
Mucahit Bilal GOKER 3ad3ad2f01 feat(rhino): use existing materials when loading (#1177)
* reuse existing materials

* comment out purge material

* fix: unnecessary using directives

* more details in exception message and remove material purge.

---------

Co-authored-by: Björn Steinhagen <88777268+bjoernsteinhagen@users.noreply.github.com>
2025-11-10 15:43:04 +03:00
Björn Steinhagen 6db7e46401 fix(grasshopper): refresh parameter UI when toggling empty properties mode (#1174) 2025-11-10 15:07:57 +03:00
Björn Steinhagen 13fc24c7c7 fix(rhino): handles null active doc (#1180) 2025-11-10 14:51:06 +03:00
Jedd Morgan cf86158b83 Ignore (#1171) 2025-11-03 12:57:17 +00:00
Björn Steinhagen 25eb955636 feat(etabs): add volume calculation for frames and shells (#1167)
* feat: adds frame volume prop

* feat: adds shell volume prop

* refactor: simplify caching

* fix(etabs): use IsInfinity for etabs 21
2025-10-28 17:39:30 +02:00
dependabot[bot] 7862a858ae chore(deps): bump actions/upload-artifact from 4 to 5 (#1166)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 18:09:02 +00:00
Jedd Morgan bc18d3b494 fix(rhino-importer): Use main thread always for document creation (#1161)
.NET Build and Publish / build-connectors (push) Has been cancelled
.NET Build and Publish / deploy-installers (push) Has been cancelled
* Use main thread

* format

* configure await false

* pass args only once
2025-10-27 11:11:56 +00:00
Björn Steinhagen fd34f22028 fix(grasshopper): applicationId for SpeckleDataObject on creation (#1159)
* chore: formatting

* fix: generate appId on object creation

* feat: validate uniqueness of objects within a collection
2025-10-25 20:46:20 +01:00
Jedd Morgan 958c9e5e94 Merge pull request #1158 from specklesystems/main
Main -> Dev back merge
2025-10-20 15:42:23 +01:00
Jedd Morgan 7c7260c603 Merge pull request #1157 from specklesystems/dev
.NET Build and Publish / build-connectors (push) Has been cancelled
.NET Build and Publish / deploy-installers (push) Has been cancelled
Update dev into main
2025-10-20 15:39:33 +01:00
Oğuzhan Koral bae9e3e0f1 Prevent crashes on unnamed files (#1154)
Co-authored-by: Björn Steinhagen <88777268+bjoernsteinhagen@users.noreply.github.com>
Co-authored-by: Jedd Morgan <45512892+JR-Morgan@users.noreply.github.com>
2025-10-20 17:33:00 +03:00
Oğuzhan Koral 26b0394613 feat(revit): display value proxies (#1140)
* POC

* some fixes

* Handle autocad, rhino and sketchup receives

* Handle revit receive

* Fix transform issues

* fix: custom mesh id logic

* Hash function

* Merge pull request #1142 from specklesystems/jedd/cnx-2657-hashing-the-meshes

feat(revit)!: Use Hash function for mesh geometry instance ids

* Use v2 style transform

* extra comments

* experiment1

* correct transform logic and disposal

* corrected transform logic

* simplify (maybe) the transform combination

* refactor(revit): replace tuples with DisplayValueResult record for display values (#1145)

* Clear instance proxies per conversion

* fix: material assignment on revit receive (#1146)

* Fix: enable send caching (#1148)

* Enable caching without definition proxy noise

* Log element id relationship while creating cache to filter after conversion to attach root

* Update RevitRootObjectBuilder.cs

* Clear cache on document swap

* More clean up

* fix(revit): defer material proxy population to prevent duplicate instance mesh IDs (#1155)

* fix(revit): defer material proxy population to prevent duplicate instance mesh IDs

* chore: formatting

* chore: campsite

* refactor: throwing on cache error

* refactor: move material proxy population into cache singleton

* fix: di

* fix(rhino): match cleaned block names when purging instance definitions (#1156)

* fix(rhino): match cleaned block names when purging instance definitions

* refactor: simplification

* chore: comments

* refactor: use .Contains

---------

Co-authored-by: Björn <steinhagen.bjoern@gmail.com>
Co-authored-by: Jedd Morgan <45512892+JR-Morgan@users.noreply.github.com>
Co-authored-by: Björn Steinhagen <88777268+bjoernsteinhagen@users.noreply.github.com>
2025-10-20 15:26:56 +01:00
Dogukan Karatas 689ef0bcbe Merge pull request #1149 from specklesystems/dogukan/cnx-2490-receive-property-sets-in-civil-3d
feat (civil): receive property sets
2025-10-17 18:09:25 +02:00
Dogukan Karatas 461585b782 adds additional cast 2025-10-17 17:58:01 +02:00
Claire Kuang ea33f35a7d removes unnecessary casting on send 2025-10-17 16:57:38 +01:00
Claire Kuang 7427f1a2f3 adds constants and better host object builder methods 2025-10-17 16:08:40 +01:00
Dogukan Karatas b7984bf97e add additional cleaning 2025-10-17 15:25:35 +02:00
Dogukan Karatas 9b24a45b6e property set pruge 2025-10-17 12:50:58 +02:00
Claire Kuang 4ace81a422 Merge branch 'dev' into dogukan/cnx-2490-receive-property-sets-in-civil-3d 2025-10-17 10:02:40 +01:00
Björn Steinhagen a60790c92c fix(grasshopper): account selection not respected when url input connected (#1150)
* fix: account switching on urlInput
* chore: good ol' comments
* chore: adding server url to exception message
2025-10-17 10:02:12 +01:00
Dogukan Karatas fd0d00cac3 Merge branch 'dev' into dogukan/cnx-2490-receive-property-sets-in-civil-3d 2025-10-16 18:48:01 +02:00
Dogukan Karatas 498396e611 Merge pull request #1153 from specklesystems/dogukan/build-package-version-bump
chore(build): bump Microsoft.Build version
2025-10-16 18:47:18 +02:00
Dogukan Karatas 5444377398 bump version 2025-10-16 18:29:20 +02:00
Dogukan Karatas 9d981f9800 ci check 2025-10-16 17:59:13 +02:00
Dogukan Karatas 14e17fb67d prefix and purge 2025-10-16 17:52:30 +02:00
Dogukan Karatas 0ffa7685fd rather cast than convert 2025-10-16 17:16:20 +02:00
Dogukan Karatas dc7d4671e4 default value simplification 2025-10-16 16:40:37 +02:00
Dogukan Karatas 10cb5cd66f removed the parser 2025-10-16 16:24:53 +02:00
Jedd Morgan cb15d9f77a Merge pull request #1152 from specklesystems/jrm/trigger-ci
Remove arcgis from readme
2025-10-16 15:05:53 +01:00
Jedd Morgan da74faef9b read me change to trigger ci 2025-10-16 15:02:58 +01:00
Claire Kuang 4368833c7e Merge branch 'dev' into dogukan/cnx-2490-receive-property-sets-in-civil-3d 2025-10-16 09:56:57 +01:00
Dogukan Karatas a20df41316 readonly dict 2025-10-15 22:56:50 +02:00
Dogukan Karatas ccf48dbad1 process property set definitions from root object 2025-10-15 22:42:33 +02:00
Björn Steinhagen 6700aa27bc fix(revit): handle level extraction for face-based family instances (#1151)
* fix: get levels for face-based instances

* fix: level unpacker
2025-10-15 22:06:01 +02:00
Dogukan Karatas df525eab63 moved the baker to the connector 2025-10-15 21:25:12 +02:00
Dogukan Karatas 275901626f created an abstract class 2025-10-15 16:35:19 +02:00
Claire Kuang fac0dc31b2 Merge branch 'dev' into dogukan/cnx-2490-receive-property-sets-in-civil-3d 2025-10-15 12:48:26 +01:00
Dogukan Karatas 8696eca1f0 use active transaction 2025-10-15 13:12:41 +02:00
Dogukan Karatas d647c71cf5 baker added 2025-10-15 12:25:01 +02:00
Dogukan Karatas 9b218dd808 Revert "created PropertySetConverter"
This reverts commit 112093f914.
2025-10-15 12:06:04 +02:00
Björn Steinhagen 9f39dc521d fix(revit): clamp PBR material properties to avoid receive failures (#1147)
* fix: clamping roughness vaues

* chore: extending to other 0 - 1 values
2025-10-14 16:15:52 +02:00
Dogukan Karatas 112093f914 created PropertySetConverter 2025-10-13 14:08:35 +02:00
73 changed files with 2271 additions and 730 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
run: ./build.ps1 zip
- name: ⬆️ Upload artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: output-${{ env.SEMVER }}
path: output/*.*
+9 -9
View File
@@ -16,12 +16,12 @@
},
"Microsoft.Build": {
"type": "Direct",
"requested": "[17.11.4, )",
"resolved": "17.11.4",
"contentHash": "UMC7DfeFEHY2GGHHaghybUuUlLaByFHEFudR2PehMgDBuRuLAUePp1iaa4eFtVzepRzMtIbeSCVJCzzX3NV2Gg==",
"requested": "[17.11.48, )",
"resolved": "17.11.48",
"contentHash": "g8Kn575mNAKcuFotV3C7xvF+IbxuHennl67LH2shL2au1U6UqwReTDygCHyU04+koc2Yn7fHIbVQaC08HqEWow==",
"dependencies": {
"Microsoft.Build.Framework": "17.11.4",
"Microsoft.NET.StringTools": "17.11.4",
"Microsoft.Build.Framework": "17.11.48",
"Microsoft.NET.StringTools": "17.11.48",
"System.Collections.Immutable": "8.0.0",
"System.Configuration.ConfigurationManager": "8.0.0",
"System.Reflection.Metadata": "8.0.0",
@@ -82,8 +82,8 @@
},
"Microsoft.Build.Framework": {
"type": "Transitive",
"resolved": "17.11.4",
"contentHash": "u28uDihlqxtt8h2dL1ZJOZ7TRkxBK+HGr+3FgQpILVo7Q7gErkw8mYW9R+RM5PtxvZTdYb/4MWDL66vdIsANBQ=="
"resolved": "17.11.48",
"contentHash": "C3WIMt2wBl4++NX3jSEpTq5KXBhvAV154R4JrYHkfy9JSBcXWiL0mkgpspk5xSdOj+fS/uz7zluIy6bMM1fkkQ=="
},
"Microsoft.Build.Tasks.Git": {
"type": "Transitive",
@@ -97,8 +97,8 @@
},
"Microsoft.NET.StringTools": {
"type": "Transitive",
"resolved": "17.11.4",
"contentHash": "mudqUHhNpeqIdJoUx2YDWZO/I9uEDYVowan89R6wsomfnUJQk6HteoQTlNjZDixhT2B4IXMkMtgZtoceIjLRmA=="
"resolved": "17.11.48",
"contentHash": "0IQo089IGBEC4jgtishauZMVr9ZxOWNiGKeDvyzZlvw7p2r253lJh6IJCLLFWXvZnVrVO5mnsYIPamxFPzM08w=="
},
"Microsoft.NETFramework.ReferenceAssemblies.net461": {
"type": "Transitive",
@@ -0,0 +1,296 @@
using Autodesk.AutoCAD.DatabaseServices;
using Speckle.Connectors.Autocad.HostApp;
using Speckle.Connectors.Autocad.HostApp.Extensions;
using Speckle.Connectors.Common.Builders;
using Speckle.Connectors.Common.Conversion;
using Speckle.Connectors.Common.Extensions;
using Speckle.Connectors.Common.Operations;
using Speckle.Connectors.Common.Operations.Receive;
using Speckle.Converters.Common;
using Speckle.Sdk.Common;
using Speckle.Sdk.Dependencies;
using Speckle.Sdk.Models;
using Speckle.Sdk.Models.Collections;
using Speckle.Sdk.Models.Instances;
using AutocadColor = Autodesk.AutoCAD.Colors.Color;
namespace Speckle.Connectors.Autocad.Operations.Receive;
/// <summary>
/// <para>Base class for AutoCAD host object builders. Expects to be a scoped dependency per receive operation.</para>
/// </summary>
public abstract class AutocadHostObjectBaseBuilder : IHostObjectBuilder
{
private readonly IRootToHostConverter _converter;
private readonly AutocadLayerBaker _layerBaker;
private readonly AutocadGroupBaker _groupBaker;
private readonly AutocadInstanceBaker _instanceBaker;
private readonly IAutocadMaterialBaker _materialBaker;
private readonly IAutocadColorBaker _colorBaker;
private readonly AutocadContext _autocadContext;
private readonly RootObjectUnpacker _rootObjectUnpacker;
private readonly IReceiveConversionHandler _conversionHandler;
protected AutocadHostObjectBaseBuilder(
IRootToHostConverter converter,
AutocadLayerBaker layerBaker,
AutocadGroupBaker groupBaker,
AutocadInstanceBaker instanceBaker,
IAutocadMaterialBaker materialBaker,
IAutocadColorBaker colorBaker,
AutocadContext autocadContext,
RootObjectUnpacker rootObjectUnpacker,
IReceiveConversionHandler conversionHandler
)
{
_converter = converter;
_layerBaker = layerBaker;
_groupBaker = groupBaker;
_instanceBaker = instanceBaker;
_materialBaker = materialBaker;
_colorBaker = colorBaker;
_autocadContext = autocadContext;
_rootObjectUnpacker = rootObjectUnpacker;
_conversionHandler = conversionHandler;
}
public Task<HostObjectBuilderResult> Build(
Base rootObject,
string projectName,
string modelName,
IProgress<CardProgress> onOperationProgressed,
CancellationToken cancellationToken
)
{
// Prompt the UI conversion started. Progress bar will swoosh.
onOperationProgressed.Report(new("Converting", null));
// Layer filter for received commit with project and model name
_layerBaker.CreateLayerFilter(projectName, modelName);
// 0 - Clean then Rock n Roll!
string baseLayerPrefix = _autocadContext.RemoveInvalidChars($"SPK-{projectName}-{modelName}-");
PreReceiveDeepClean(baseLayerPrefix);
// 1 - Unpack objects and proxies from root commit object
var unpackedRoot = _rootObjectUnpacker.Unpack(rootObject);
// 2 - Split atomic objects and instance components with their path
var (atomicObjects, instanceComponents) = _rootObjectUnpacker.SplitAtomicObjectsAndInstances(
unpackedRoot.ObjectsToConvert
);
var atomicObjectsWithPath = _layerBaker.GetAtomicObjectsWithPath(atomicObjects);
var instanceComponentsWithPath = _layerBaker.GetInstanceComponentsWithPath(instanceComponents);
// POC: these are not captured by traversal, so we need to re-add them here
if (unpackedRoot.DefinitionProxies != null && unpackedRoot.DefinitionProxies.Count > 0)
{
var transformed = unpackedRoot.DefinitionProxies.Select(proxy =>
(Array.Empty<Collection>(), proxy as IInstanceComponent)
);
instanceComponentsWithPath.AddRange(transformed);
}
// 3 - Parse and bake proxies (materials and colors), as they are used later down the line by layers and objects
if (unpackedRoot.RenderMaterialProxies != null)
{
_materialBaker.ParseAndBakeRenderMaterials(
unpackedRoot.RenderMaterialProxies,
baseLayerPrefix,
onOperationProgressed
);
}
if (unpackedRoot.ColorProxies != null)
{
_colorBaker.ParseColors(unpackedRoot.ColorProxies, onOperationProgressed);
}
// 3.5 - Parse and bake additional proxies that are needed for conversion
ParseAndBakeAdditionalProxies(rootObject, baseLayerPrefix);
// 4 - Convert atomic objects
HashSet<ReceiveConversionResult> results = new();
HashSet<string> bakedObjectIds = new();
Dictionary<string, IReadOnlyCollection<Entity>> applicationIdMap = new();
var count = 0;
foreach (var (layerPath, atomicObject) in atomicObjectsWithPath)
{
onOperationProgressed.Report(new("Converting objects", (double)++count / atomicObjects.Count));
var ex = _conversionHandler.TryConvert(() =>
{
cancellationToken.ThrowIfCancellationRequested();
string objectId = atomicObject.applicationId ?? atomicObject.id.NotNull();
IReadOnlyCollection<Entity> convertedObjects = ConvertObject(atomicObject, layerPath, baseLayerPrefix);
applicationIdMap[objectId] = convertedObjects;
results.UnionWith(
convertedObjects.Select(e => new ReceiveConversionResult(
Status.SUCCESS,
atomicObject,
e.GetSpeckleApplicationId(),
e.GetType().ToString()
))
);
bakedObjectIds.UnionWith(convertedObjects.Select(e => e.GetSpeckleApplicationId()));
});
if (ex != null)
{
results.Add(new(Status.ERROR, atomicObject, null, null, ex));
}
}
// 5 - Convert instances
var (createdInstanceIds, consumedObjectIds, instanceConversionResults) = _instanceBaker.BakeInstances(
instanceComponentsWithPath,
applicationIdMap,
baseLayerPrefix,
onOperationProgressed
);
bakedObjectIds.RemoveWhere(id => consumedObjectIds.Contains(id));
bakedObjectIds.UnionWith(createdInstanceIds);
results.RemoveWhere(result => result.ResultId != null && consumedObjectIds.Contains(result.ResultId));
results.UnionWith(instanceConversionResults);
// 6 - Create groups
if (unpackedRoot.GroupProxies != null)
{
IReadOnlyCollection<ReceiveConversionResult> groupResults = _groupBaker.CreateGroups(
unpackedRoot.GroupProxies,
applicationIdMap
);
results.UnionWith(groupResults);
}
return Task.FromResult(new HostObjectBuilderResult(bakedObjectIds, results));
}
protected void PreReceiveDeepClean(string baseLayerPrefix)
{
_layerBaker.DeleteAllLayersByPrefix(baseLayerPrefix);
_instanceBaker.PurgeInstances(baseLayerPrefix);
_materialBaker.PurgeMaterials(baseLayerPrefix);
PreReceiveAdditionalDeepClean(baseLayerPrefix);
}
/// <summary>
/// Method for adding app-specific additional deep clean of the document prior to receiving.
/// </summary>
protected virtual void PreReceiveAdditionalDeepClean(string baseLayerPrefix) { }
/// <summary>
/// Method for parsing and baking additional app-specific proxies on the root prior to converting and baking objects
/// </summary>
protected virtual void ParseAndBakeAdditionalProxies(Base rootObject, string baseLayerPrefix) { }
private IReadOnlyCollection<Entity> ConvertObject(Base obj, Collection[] layerPath, string baseLayerNamePrefix)
{
string layerName = _layerBaker.CreateLayerForReceive(layerPath, baseLayerNamePrefix);
var convertedEntities = new HashSet<Entity>();
using var tr = Application.DocumentManager.CurrentDocument.Database.TransactionManager.StartTransaction();
// 1: convert
var converted = _converter.Convert(obj);
// 2: handle result
switch (converted)
{
case Entity entity:
var bakedEntity = BakeObject(entity, obj, layerName, tr);
convertedEntities.Add(bakedEntity);
break;
case List<(Entity, Base)> listConversionResult: // this is from fallback conversion for brep/brepx/subdx/extrusionx/polycurve
var bakedFallbackEntities = BakeObjectsAsGroup(listConversionResult, obj, layerName, baseLayerNamePrefix, tr);
convertedEntities.UnionWith(bakedFallbackEntities);
break;
default:
// TODO: capture defualt case with report object here? Same as in Rhino
break;
}
tr.Commit();
return convertedEntities.Freeze();
}
private Entity BakeObject(
Entity entity,
Base originalObject,
string layerName,
Transaction tr,
Base? parentObject = null
)
{
var objId = originalObject.applicationId ?? originalObject.id.NotNull();
if (_colorBaker.ObjectColorsIdMap.TryGetValue(objId, out AutocadColor? color))
{
entity.Color = color;
}
if (_materialBaker.TryGetMaterialId(originalObject, parentObject, out ObjectId matId))
{
entity.MaterialId = matId;
}
entity.AppendToDb(layerName);
// Hook for derived classes to perform additional operations after entity is added to database
PostBakeEntity(entity, originalObject, tr);
return entity;
}
/// <summary>
/// Method for additional app-specific operations on entities after the entity has been added to the document database.
/// Called after the entity is added to the database in an open transaction
/// </summary>
/// <param name="entity"></param>
/// <param name="originalObject"></param>
/// <param name="tr"></param>
protected virtual void PostBakeEntity(Entity entity, Base originalObject, Transaction tr)
{
// Default implementation does nothing - override in derived classes
}
private List<Entity> BakeObjectsAsGroup(
List<(Entity, Base)> fallbackConversionResult,
Base parentObject,
string layerName,
string baseLayerName,
Transaction tr
)
{
var ids = new ObjectIdCollection();
var entities = new List<Entity>();
foreach (var (conversionResult, originalObject) in fallbackConversionResult)
{
BakeObject(conversionResult, originalObject, layerName, tr, parentObject);
ids.Add(conversionResult.ObjectId);
entities.Add(conversionResult);
}
if (entities.Count <= 1) // return if empty list or only one, because we don't want to create empty or single item groups.
{
return entities;
}
var groupDictionary = (DBDictionary)
tr.GetObject(Application.DocumentManager.CurrentDocument.Database.GroupDictionaryId, OpenMode.ForWrite);
var groupName = _autocadContext.RemoveInvalidChars(
$@"{parentObject.speckle_type.Split('.').Last()} - {parentObject.applicationId ?? parentObject.id} ({baseLayerName})"
);
var newGroup = new Group(groupName, true);
newGroup.Append(ids);
groupDictionary.UpgradeOpen();
groupDictionary.SetAt(groupName, newGroup);
tr.AddNewlyCreatedDBObject(newGroup, true);
return entities;
}
}
@@ -1,238 +1,35 @@
using Autodesk.AutoCAD.DatabaseServices;
using Speckle.Connectors.Autocad.HostApp;
using Speckle.Connectors.Autocad.HostApp.Extensions;
using Speckle.Connectors.Common.Builders;
using Speckle.Connectors.Common.Conversion;
using Speckle.Connectors.Common.Extensions;
using Speckle.Connectors.Common.Operations;
using Speckle.Connectors.Common.Operations.Receive;
using Speckle.Converters.Common;
using Speckle.Sdk.Common;
using Speckle.Sdk.Dependencies;
using Speckle.Sdk.Models;
using Speckle.Sdk.Models.Collections;
using Speckle.Sdk.Models.Instances;
using AutocadColor = Autodesk.AutoCAD.Colors.Color;
namespace Speckle.Connectors.Autocad.Operations.Receive;
/// <summary>
/// <para>Expects to be a scoped dependency per receive operation.</para>
/// <para>AutoCAD-specific host object builder. Expects to be a scoped dependency per receive operation.</para>
/// </summary>
public class AutocadHostObjectBuilder(
IRootToHostConverter converter,
AutocadLayerBaker layerBaker,
AutocadGroupBaker groupBaker,
AutocadInstanceBaker instanceBaker,
IAutocadMaterialBaker materialBaker,
IAutocadColorBaker colorBaker,
AutocadContext autocadContext,
RootObjectUnpacker rootObjectUnpacker,
IReceiveConversionHandler conversionHandler
) : IHostObjectBuilder
public sealed class AutocadHostObjectBuilder : AutocadHostObjectBaseBuilder
{
public Task<HostObjectBuilderResult> Build(
Base rootObject,
string projectName,
string modelName,
IProgress<CardProgress> onOperationProgressed,
CancellationToken cancellationToken
public AutocadHostObjectBuilder(
IRootToHostConverter converter,
AutocadLayerBaker layerBaker,
AutocadGroupBaker groupBaker,
AutocadInstanceBaker instanceBaker,
IAutocadMaterialBaker materialBaker,
IAutocadColorBaker colorBaker,
AutocadContext autocadContext,
RootObjectUnpacker rootObjectUnpacker,
IReceiveConversionHandler conversionHandler
)
{
// Prompt the UI conversion started. Progress bar will swoosh.
onOperationProgressed.Report(new("Converting", null));
// Layer filter for received commit with project and model name
layerBaker.CreateLayerFilter(projectName, modelName);
// 0 - Clean then Rock n Roll!
string baseLayerPrefix = autocadContext.RemoveInvalidChars($"SPK-{projectName}-{modelName}-");
PreReceiveDeepClean(baseLayerPrefix);
// 1 - Unpack objects and proxies from root commit object
var unpackedRoot = rootObjectUnpacker.Unpack(rootObject);
// 2 - Split atomic objects and instance components with their path
var (atomicObjects, instanceComponents) = rootObjectUnpacker.SplitAtomicObjectsAndInstances(
unpackedRoot.ObjectsToConvert
);
var atomicObjectsWithPath = layerBaker.GetAtomicObjectsWithPath(atomicObjects);
var instanceComponentsWithPath = layerBaker.GetInstanceComponentsWithPath(instanceComponents);
// POC: these are not captured by traversal, so we need to re-add them here
if (unpackedRoot.DefinitionProxies != null && unpackedRoot.DefinitionProxies.Count > 0)
{
var transformed = unpackedRoot.DefinitionProxies.Select(proxy =>
(Array.Empty<Collection>(), proxy as IInstanceComponent)
);
instanceComponentsWithPath.AddRange(transformed);
}
// 3 - Bake materials and colors, as they are used later down the line by layers and objects
if (unpackedRoot.RenderMaterialProxies != null)
{
materialBaker.ParseAndBakeRenderMaterials(
unpackedRoot.RenderMaterialProxies,
baseLayerPrefix,
onOperationProgressed
);
}
if (unpackedRoot.ColorProxies != null)
{
colorBaker.ParseColors(unpackedRoot.ColorProxies, onOperationProgressed);
}
// 4 - Convert atomic objects
HashSet<ReceiveConversionResult> results = new();
HashSet<string> bakedObjectIds = new();
Dictionary<string, IReadOnlyCollection<Entity>> applicationIdMap = new();
var count = 0;
foreach (var (layerPath, atomicObject) in atomicObjectsWithPath)
{
onOperationProgressed.Report(new("Converting objects", (double)++count / atomicObjects.Count));
var ex = conversionHandler.TryConvert(() =>
{
cancellationToken.ThrowIfCancellationRequested();
string objectId = atomicObject.applicationId ?? atomicObject.id.NotNull();
IReadOnlyCollection<Entity> convertedObjects = ConvertObject(atomicObject, layerPath, baseLayerPrefix);
applicationIdMap[objectId] = convertedObjects;
results.UnionWith(
convertedObjects.Select(e => new ReceiveConversionResult(
Status.SUCCESS,
atomicObject,
e.GetSpeckleApplicationId(),
e.GetType().ToString()
))
);
bakedObjectIds.UnionWith(convertedObjects.Select(e => e.GetSpeckleApplicationId()));
});
if (ex != null)
{
results.Add(new(Status.ERROR, atomicObject, null, null, ex));
}
}
// 5 - Convert instances
var (createdInstanceIds, consumedObjectIds, instanceConversionResults) = instanceBaker.BakeInstances(
instanceComponentsWithPath,
applicationIdMap,
baseLayerPrefix,
onOperationProgressed
);
bakedObjectIds.RemoveWhere(id => consumedObjectIds.Contains(id));
bakedObjectIds.UnionWith(createdInstanceIds);
results.RemoveWhere(result => result.ResultId != null && consumedObjectIds.Contains(result.ResultId));
results.UnionWith(instanceConversionResults);
// 6 - Create groups
if (unpackedRoot.GroupProxies != null)
{
IReadOnlyCollection<ReceiveConversionResult> groupResults = groupBaker.CreateGroups(
unpackedRoot.GroupProxies,
applicationIdMap
);
results.UnionWith(groupResults);
}
return Task.FromResult(new HostObjectBuilderResult(bakedObjectIds, results));
}
private void PreReceiveDeepClean(string baseLayerPrefix)
{
layerBaker.DeleteAllLayersByPrefix(baseLayerPrefix);
instanceBaker.PurgeInstances(baseLayerPrefix);
materialBaker.PurgeMaterials(baseLayerPrefix);
}
private IReadOnlyCollection<Entity> ConvertObject(Base obj, Collection[] layerPath, string baseLayerNamePrefix)
{
string layerName = layerBaker.CreateLayerForReceive(layerPath, baseLayerNamePrefix);
var convertedEntities = new HashSet<Entity>();
using var tr = Application.DocumentManager.CurrentDocument.Database.TransactionManager.StartTransaction();
// 1: convert
var converted = converter.Convert(obj);
// 2: handle result
switch (converted)
{
case Entity entity:
var bakedEntity = BakeObject(entity, obj, layerName);
convertedEntities.Add(bakedEntity);
break;
case List<(Entity, Base)> listConversionResult: // this is from fallback conversion for brep/brepx/subdx/extrusionx/polycurve
var bakedFallbackEntities = BakeObjectsAsGroup(listConversionResult, obj, layerName, baseLayerNamePrefix);
convertedEntities.UnionWith(bakedFallbackEntities);
break;
default:
// TODO: capture defualt case with report object here? Same as in Rhino
break;
}
tr.Commit();
return convertedEntities.Freeze();
}
private Entity BakeObject(Entity entity, Base originalObject, string layerName, Base? parentObject = null)
{
var objId = originalObject.applicationId ?? originalObject.id.NotNull();
if (colorBaker.ObjectColorsIdMap.TryGetValue(objId, out AutocadColor? color))
{
entity.Color = color;
}
if (materialBaker.TryGetMaterialId(originalObject, parentObject, out ObjectId matId))
{
entity.MaterialId = matId;
}
entity.AppendToDb(layerName);
return entity;
}
private List<Entity> BakeObjectsAsGroup(
List<(Entity, Base)> fallbackConversionResult,
Base parentObject,
string layerName,
string baseLayerName
)
{
var ids = new ObjectIdCollection();
var entities = new List<Entity>();
foreach (var (conversionResult, originalObject) in fallbackConversionResult)
{
BakeObject(conversionResult, originalObject, layerName, parentObject);
ids.Add(conversionResult.ObjectId);
entities.Add(conversionResult);
}
if (entities.Count <= 1) // return if empty list or only one, because we don't want to create empty or single item groups.
{
return entities;
}
var tr = Application.DocumentManager.CurrentDocument.Database.TransactionManager.TopTransaction;
var groupDictionary = (DBDictionary)
tr.GetObject(Application.DocumentManager.CurrentDocument.Database.GroupDictionaryId, OpenMode.ForWrite);
var groupName = autocadContext.RemoveInvalidChars(
$@"{parentObject.speckle_type.Split('.').Last()} - {parentObject.applicationId ?? parentObject.id} ({baseLayerName})"
);
var newGroup = new Group(groupName, true);
newGroup.Append(ids);
groupDictionary.UpgradeOpen();
groupDictionary.SetAt(groupName, newGroup);
tr.AddNewlyCreatedDBObject(newGroup, true);
return entities;
}
: base(
converter,
layerBaker,
groupBaker,
instanceBaker,
materialBaker,
colorBaker,
autocadContext,
rootObjectUnpacker,
conversionHandler
) { }
}
@@ -38,6 +38,7 @@
<Compile Include="$(MSBuildThisFileDirectory)HostApp\Extensions\EntityExtensions.cs" />
<Compile Include="$(MSBuildThisFileDirectory)HostApp\Extensions\SpeckleApplicationIdExtensions.cs" />
<Compile Include="$(MSBuildThisFileDirectory)HostApp\TransactionContext.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Operations\Receive\AutocadHostObjectBaseBuilder.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Operations\Receive\AutocadHostObjectBuilder.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Operations\Send\AutocadRootObject.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Operations\Send\AutocadRootObjectBaseBuilder.cs" />
@@ -3,6 +3,8 @@ using Microsoft.Extensions.DependencyInjection;
using Speckle.Connectors.Autocad.DependencyInjection;
using Speckle.Connectors.Autocad.Operations.Send;
using Speckle.Connectors.Civil3dShared.Bindings;
using Speckle.Connectors.Civil3dShared.HostApp;
using Speckle.Connectors.Civil3dShared.Operations.Receive;
using Speckle.Connectors.Civil3dShared.Operations.Send;
using Speckle.Connectors.Common.Builders;
using Speckle.Connectors.DUI.Bindings;
@@ -24,10 +26,12 @@ public static class Civil3dConnectorModule
// add receive
serviceCollection.LoadReceive();
serviceCollection.AddScoped<IHostObjectBuilder, Civil3dHostObjectBuilder>();
serviceCollection.AddSingleton<IBinding, Civil3dReceiveBinding>();
// additional classes
serviceCollection.AddScoped<PropertySetDefinitionHandler>();
serviceCollection.AddScoped<PropertySetBaker>();
// automatically detects the Class:IClass interface pattern to register all generated interfaces
serviceCollection.AddMatchingInterfacesAsTransient(Assembly.GetExecutingAssembly());
@@ -0,0 +1,404 @@
using Microsoft.Extensions.Logging;
using Speckle.Connectors.Common.Operations;
using Speckle.Converters.Civil3dShared;
using Speckle.Converters.Civil3dShared.Helpers;
using Speckle.Converters.Civil3dShared.ToSpeckle;
using Speckle.Converters.Common;
using Speckle.Sdk;
using Speckle.Sdk.Models;
using AAEC = Autodesk.Aec;
using AAECPDB = Autodesk.Aec.PropertyData.DatabaseServices;
using ADB = Autodesk.AutoCAD.DatabaseServices;
namespace Speckle.Connectors.Civil3dShared.HostApp;
/// <summary>
/// Helper class to bake property sets to entities on receive.
/// </summary>
public class PropertySetBaker
{
private const string PROP_SET_DEF_DICT_NAME = "AecPropertySetDefs";
private readonly IConverterSettingsStore<Civil3dConversionSettings> _settingsStore;
private readonly ILogger<PropertySetBaker> _logger;
private readonly PropertyHandler _propertyHandler;
/// <summary>
/// Map of property set definition name to its ObjectId. Populated during ParsePropertySetDefinitions.
/// </summary>
private readonly Dictionary<string, ADB.ObjectId> _propertySetDefinitionMap = new();
public PropertySetBaker(
IConverterSettingsStore<Civil3dConversionSettings> settingsStore,
ILogger<PropertySetBaker> logger
)
{
_settingsStore = settingsStore;
_logger = logger;
_propertyHandler = new PropertyHandler();
}
/// <summary>
/// Removes all property set definitions with a prefix before receive operation.
/// </summary>
public void PurgePropertySets(string namePrefix)
{
ADB.Database db = _settingsStore.Current.Document.Database;
using var tr = db.TransactionManager.StartTransaction();
List<ADB.ObjectId> definitionsToDelete = new();
// Access the property set definition dictionary from the named object dictionary
var nod = (ADB.DBDictionary)tr.GetObject(db.NamedObjectsDictionaryId, ADB.OpenMode.ForRead);
if (nod.Contains(PROP_SET_DEF_DICT_NAME))
{
ADB.ObjectId propSetDefsDictId = nod.GetAt(PROP_SET_DEF_DICT_NAME);
var propSetDefsDict = (ADB.DBDictionary)tr.GetObject(propSetDefsDictId, ADB.OpenMode.ForRead);
// Iterate through all property set definitions in the dictionary
foreach (ADB.DBDictionaryEntry entry in propSetDefsDict)
{
if (entry.Key.Contains(namePrefix))
{
definitionsToDelete.Add(entry.Value);
}
}
}
// Delete the matching definitions
foreach (ADB.ObjectId defId in definitionsToDelete)
{
try
{
var propSetDef = (AAECPDB.PropertySetDefinition)tr.GetObject(defId, ADB.OpenMode.ForWrite);
propSetDef.Erase();
}
catch (Exception ex) when (!ex.IsFatal())
{
_logger.LogWarning(ex, "Failed to purge property set definition");
}
}
tr.Commit();
}
/// <summary>
/// Parse and bake all property set definitions from the root object.
/// Should be called after purging and after materials/colors are parsed.
/// </summary>
public void ParseAndBakePropertySetDefinitions(Base rootObject, string namePrefix)
{
_propertySetDefinitionMap.Clear();
if (rootObject[ProxyKeys.PROPERTYSET_DEFINITIONS] is not Dictionary<string, object?> definitions)
{
return;
}
if (definitions.Count == 0)
{
return;
}
using var tr = _settingsStore.Current.Document.Database.TransactionManager.StartTransaction();
foreach (var definition in definitions)
{
string setName = definition.Key;
object? setDefObj = definition.Value;
if (setDefObj is not Dictionary<string, object?> setDefData)
{
_logger.LogWarning("Property set definition {SetName} has invalid data format", setName);
continue;
}
if (!setDefData.TryGetValue(PropertySetDefinitionHandler.PROP_SET_PROP_DEFS_KEY, out var propDefsObj))
{
_logger.LogWarning("Property set definition {SetName} missing propertyDefinitions", setName);
continue;
}
if (propDefsObj is not Dictionary<string, object?> propertyDefinitions)
{
_logger.LogWarning("Property set definition {SetName} propertyDefinitions has invalid format", setName);
continue;
}
ADB.ObjectId defId = CreatePropertySetDefinition(setName, propertyDefinitions, namePrefix, tr);
if (!defId.IsNull)
{
_propertySetDefinitionMap[setName] = defId;
}
}
tr.Commit();
}
/// <summary>
/// Try to bake property sets from a Speckle object to a Civil3D entity.
/// </summary>
public bool TryBakePropertySets(ADB.Entity entity, Base sourceObject, ADB.Transaction tr)
{
if (
sourceObject["properties"] is not Dictionary<string, object?> properties
|| !properties.TryGetValue("Property Sets", out var propertySetsObj)
|| propertySetsObj is not Dictionary<string, object?> propertySets
|| propertySets.Count == 0
)
{
return false;
}
try
{
foreach (var propertySet in propertySets)
{
string setName = propertySet.Key;
object? setDataObj = propertySet.Value;
if (setDataObj is not Dictionary<string, object?> setData)
{
_logger.LogWarning("Property set {SetName} has invalid data format", setName);
continue;
}
if (!TryBakePropertySet(entity, setName, setData, tr))
{
_logger.LogWarning("Failed to bake property set {SetName} onto entity", setName);
}
}
return true;
}
catch (Exception ex) when (!ex.IsFatal())
{
_logger.LogError(ex, "Failed to bake property sets onto entity {Handle}", entity.Handle);
return false;
}
}
private bool TryBakePropertySet(
ADB.Entity entity,
string setName,
Dictionary<string, object?> setData,
ADB.Transaction tr
)
{
try
{
if (!_propertySetDefinitionMap.TryGetValue(setName, out ADB.ObjectId propertySetDefId))
{
_logger.LogWarning("Property set definition {SetName} not found in definition map", setName);
return false;
}
if (propertySetDefId.IsNull)
{
return false;
}
if (ObjectHasPropertySet(entity, propertySetDefId))
{
throw new SpeckleException($"Property set '{setName}' already exists on entity.");
}
return AddPropertySetToEntity(entity, propertySetDefId, setData, tr);
}
catch (Exception ex) when (!ex.IsFatal())
{
_logger.LogWarning(ex, "Failed to process property set {SetName}", setName);
return false;
}
}
private ADB.ObjectId CreatePropertySetDefinition(
string setName,
Dictionary<string, object?> propertyDefinitions,
string namePrefix,
ADB.Transaction tr
)
{
var db = _settingsStore.Current.Document.Database;
using AAECPDB.DictionaryPropertySetDefinitions propSetDefs = new(db);
string prefixedName = $"{setName}-{namePrefix}";
AAECPDB.PropertySetDefinition propSetDef = new();
propSetDef.SetToStandard(db);
propSetDef.SubSetDatabaseDefaults(db);
//propSetDef.Description = "Property Set Definition added by Speckle"; // POC: should use the description that was published. can this back in if needed
propSetDef.AppliesToAll = true;
foreach (var propertyDefinition in propertyDefinitions)
{
string propertyName = propertyDefinition.Key;
object? propertyDefObj = propertyDefinition.Value;
if (propertyDefObj is not Dictionary<string, object?> propertyDefDict)
{
continue;
}
if (
!propertyDefDict.TryGetValue(PropertySetDefinitionHandler.PROP_DEF_TYPE_KEY, out var dataTypeStr)
|| dataTypeStr is not string dataTypeString
)
{
_logger.LogError(
"Property set definition {SetName} is invalid: property {PropertyName} missing or invalid dataType",
setName,
propertyName
);
return ADB.ObjectId.Null;
}
if (!Enum.TryParse(dataTypeString, out AAEC.PropertyData.DataType dataType))
{
_logger.LogError(
"Property set definition {SetName} is invalid: unsupported data type {DataType} for property {PropertyName}",
setName,
dataTypeString,
propertyName
);
return ADB.ObjectId.Null;
}
AAECPDB.PropertyDefinition propDef = new() { DataType = dataType, Name = propertyName };
propDef.SetToStandard(db);
propDef.SubSetDatabaseDefaults(db);
if (
propertyDefDict.TryGetValue(PropertySetDefinitionHandler.PROP_DEF_DEFAULT_VALUE_KEY, out object? defaultValue)
&& defaultValue != null
)
{
try
{
// Cast numeric types to avoid bad numeric value errors
var convertedValue = dataType switch
{
AAEC.PropertyData.DataType.Integer => (int)(long)defaultValue,
AAEC.PropertyData.DataType.AutoIncrement => (int)(long)defaultValue,
_ => defaultValue
};
propDef.DefaultData = convertedValue;
}
catch (Exception ex) when (!ex.IsFatal())
{
_logger.LogWarning(
ex,
"Failed to set default value for property {PropertyName}, continuing without default",
propertyName
);
}
}
propSetDef.Definitions.Add(propDef);
}
propSetDefs.AddNewRecord(prefixedName, propSetDef);
tr.AddNewlyCreatedDBObject(propSetDef, true);
return propSetDef.ObjectId;
}
private bool ObjectHasPropertySet(ADB.DBObject obj, ADB.ObjectId propertySetId)
{
try
{
ADB.ObjectId tempId = AAECPDB.PropertyDataServices.GetPropertySet(obj, propertySetId);
return !tempId.IsNull;
}
catch (Autodesk.AutoCAD.Runtime.Exception ex) when (!ex.IsFatal())
{
return false;
}
}
private bool AddPropertySetToEntity(
ADB.Entity entity,
ADB.ObjectId propertySetDefId,
Dictionary<string, object?> setData,
ADB.Transaction tr
)
{
try
{
if (!entity.IsWriteEnabled)
{
entity.UpgradeOpen();
}
AAECPDB.PropertyDataServices.AddPropertySet(entity, propertySetDefId);
return TrySetPropertyValues(entity, propertySetDefId, setData, tr);
}
catch (Exception ex) when (!ex.IsFatal())
{
_logger.LogWarning(ex, "Failed to add property set to entity");
return false;
}
}
private bool TrySetPropertyValues(
ADB.Entity entity,
ADB.ObjectId propertySetDefId,
Dictionary<string, object?> setData,
ADB.Transaction tr
)
{
try
{
ADB.ObjectId propertySetId = AAECPDB.PropertyDataServices.GetPropertySet(entity, propertySetDefId);
var propertySet = (AAECPDB.PropertySet)tr.GetObject(propertySetId, ADB.OpenMode.ForWrite);
var setDefinition = (AAECPDB.PropertySetDefinition)tr.GetObject(propertySetDefId, ADB.OpenMode.ForRead);
// Build a map of property names to definition IDs
Dictionary<string, int> propertyNameToId = new();
foreach (AAECPDB.PropertyDefinition propDef in setDefinition.Definitions)
{
propertyNameToId[propDef.Name] = propDef.Id;
}
foreach (var propertyEntry in setData)
{
string propertyName = propertyEntry.Key;
object? propertyDataObj = propertyEntry.Value;
if (propertyDataObj is not Dictionary<string, object?> propertyDataDict)
{
continue;
}
if (!propertyDataDict.TryGetValue("value", out var value) || value == null)
{
continue;
}
if (!propertyNameToId.TryGetValue(propertyName, out int propertyId))
{
continue;
}
_propertyHandler.TryGetValue(
() =>
{
propertySet.SetAt(propertyId, value);
return true;
},
out _
);
}
return true;
}
catch (Exception ex) when (!ex.IsFatal())
{
_logger.LogWarning(ex, "Failed to update property set values");
return false;
}
}
}
@@ -0,0 +1,60 @@
using Autodesk.AutoCAD.DatabaseServices;
using Speckle.Connectors.Autocad.HostApp;
using Speckle.Connectors.Autocad.Operations.Receive;
using Speckle.Connectors.Civil3dShared.HostApp;
using Speckle.Connectors.Common.Operations;
using Speckle.Connectors.Common.Operations.Receive;
using Speckle.Converters.Common;
using Speckle.Sdk.Models;
namespace Speckle.Connectors.Civil3dShared.Operations.Receive;
/// <summary>
/// <para>Civil3D specific host object builder with property set support. Expects to be a scoped dependency per receive operation.</para>
/// </summary>
public sealed class Civil3dHostObjectBuilder : AutocadHostObjectBaseBuilder
{
private readonly PropertySetBaker _propertySetBaker;
public Civil3dHostObjectBuilder(
IRootToHostConverter converter,
AutocadLayerBaker layerBaker,
AutocadGroupBaker groupBaker,
AutocadInstanceBaker instanceBaker,
IAutocadMaterialBaker materialBaker,
IAutocadColorBaker colorBaker,
AutocadContext autocadContext,
RootObjectUnpacker rootObjectUnpacker,
IReceiveConversionHandler conversionHandler,
PropertySetBaker propertySetBaker
)
: base(
converter,
layerBaker,
groupBaker,
instanceBaker,
materialBaker,
colorBaker,
autocadContext,
rootObjectUnpacker,
conversionHandler
)
{
_propertySetBaker = propertySetBaker;
}
protected override void PreReceiveAdditionalDeepClean(string baseLayerPrefix)
{
_propertySetBaker.PurgePropertySets(baseLayerPrefix);
}
protected override void ParseAndBakeAdditionalProxies(Base rootObject, string baseLayerPrefix)
{
_propertySetBaker.ParseAndBakePropertySetDefinitions(rootObject, baseLayerPrefix);
}
protected override void PostBakeEntity(Entity entity, Base originalObject, Transaction tr)
{
_propertySetBaker.TryBakePropertySets(entity, originalObject, tr);
}
}
@@ -11,11 +11,15 @@
<ItemGroup>
<Compile Include="$(MSBuildThisFileDirectory)Bindings\Civil3dReceiveBinding.cs" />
<Compile Include="$(MSBuildThisFileDirectory)DependencyInjection\Civil3dConnectorModule.cs" />
<Compile Include="$(MSBuildThisFileDirectory)HostApp\PropertySetBaker.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Operations\Receive\Civil3dHostObjectBuilder.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Operations\Send\Civil3dRootObjectBuilder.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Bindings\Civil3dSendBinding.cs" />
</ItemGroup>
<ItemGroup>
<Folder Include="$(MSBuildThisFileDirectory)DependencyInjection\" />
<Folder Include="$(MSBuildThisFileDirectory)HostApp\" />
<Folder Include="$(MSBuildThisFileDirectory)Operations\Receive\" />
<Folder Include="$(MSBuildThisFileDirectory)Operations\Send\" />
</ItemGroup>
</Project>
@@ -1,8 +1,7 @@
using Microsoft.Extensions.Logging;
using Speckle.Connectors.CSiShared.HostApp.Helpers;
using Speckle.Converters.Common;
using Speckle.Converters.CSiShared;
using Speckle.Converters.CSiShared.Utils;
using Speckle.Converters.CSiShared.ToSpeckle.Helpers;
using Speckle.Converters.ETABSShared.ToSpeckle.Helpers;
namespace Speckle.Connectors.ETABSShared.HostApp.Helpers;
@@ -11,54 +10,55 @@ namespace Speckle.Connectors.ETABSShared.HostApp.Helpers;
/// </summary>
public class EtabsShellSectionPropertyExtractor : IApplicationShellSectionPropertyExtractor
{
private readonly IConverterSettingsStore<CsiConversionSettings> _settingsStore;
private readonly ILogger<EtabsShellSectionPropertyExtractor> _logger;
private readonly CsiToSpeckleCacheSingleton _csiToSpeckleCacheSingleton;
private readonly EtabsShellSectionResolver _etabsShellSectionResolver;
public EtabsShellSectionPropertyExtractor(
IConverterSettingsStore<CsiConversionSettings> settingsStore,
ILogger<EtabsShellSectionPropertyExtractor> logger,
EtabsShellSectionResolver etabsShellSectionResolver
EtabsShellSectionResolver etabsShellSectionResolver,
CsiToSpeckleCacheSingleton csiToSpeckleCacheSingleton
)
{
_settingsStore = settingsStore;
_logger = logger;
_etabsShellSectionResolver = etabsShellSectionResolver;
_csiToSpeckleCacheSingleton = csiToSpeckleCacheSingleton;
}
/// <summary>
/// Extract shell section properties
/// Extract shell section properties from cache.
/// </summary>
/// <remarks>
/// sectionName is unique across all types (Wall, Slab and Deck)
/// There is no general query such as PropArea.GetShell() - rather we have to be specific on the type, for example
/// PropArea.GetWall() or PropArea.GetDeck() BUT we can't get the building type given a SectionName.
/// Hence the introduction of ResolveSection.
/// By the time this method is called during section unpacking, all sections should already be
/// resolved and cached by <see cref="EtabsShellPropertiesExtractor"/> during object conversion.
/// </remarks>
public void ExtractProperties(string sectionName, Dictionary<string, object?> properties)
{
// Step 01: Finding the appropriate api query for the unknown section type (wall, deck or slab)
Dictionary<string, object?> resolvedProperties = _etabsShellSectionResolver.ResolveSection(sectionName);
var sectionProps = GetSectionProperties(sectionName);
// Step 02: Mutate properties dictionary with resolved properties
foreach (var nestedDictionary in resolvedProperties)
// shallow copy nested dictionaries into provided properties dict to mutate it (required by interface contract)
foreach (var kvp in sectionProps)
{
if (nestedDictionary.Value is not Dictionary<string, object?> nestedValues)
{
_logger.LogWarning(
"Unexpected value type for key {Key} in section {SectionName}. Expected Dictionary<string, object?>, got {ActualType}",
nestedDictionary.Key,
sectionName,
nestedDictionary.Value?.GetType().Name ?? "null"
);
continue;
}
var nestedProperties = properties.EnsureNested(nestedDictionary.Key);
foreach (var kvp in nestedValues)
{
nestedProperties[kvp.Key] = kvp.Value;
}
properties[kvp.Key] = kvp.Value;
}
}
private Dictionary<string, object?> GetSectionProperties(string sectionName)
{
// return cached properties directly
if (_csiToSpeckleCacheSingleton.ShellSectionPropertiesCache.TryGetValue(sectionName, out var cachedProperties))
{
return cachedProperties;
}
// fallback - shouldn't happen because cached populated on the fly as sections appear in the extractor
_logger.LogWarning(
"Section {SectionName} not in cache during unpacking - resolving via API (expensive)",
sectionName
);
var resolved = _etabsShellSectionResolver.ResolveSection(sectionName);
_csiToSpeckleCacheSingleton.ShellSectionPropertiesCache[sectionName] = resolved;
return resolved;
}
}
@@ -26,7 +26,6 @@ public static class ServiceRegistration
services.AddScoped<IApplicationShellSectionPropertyExtractor, EtabsShellSectionPropertyExtractor>();
services.AddScoped<EtabsSectionPropertyDefinitionService>();
services.AddScoped<EtabsSectionPropertyExtractor>();
services.AddScoped<EtabsShellSectionResolver>();
return services;
}
@@ -17,7 +17,6 @@
<Compile Include="$(MSBuildThisFileDirectory)HostApp\Helpers\EtabsSectionPropertyDefinitionService.cs" />
<Compile Include="$(MSBuildThisFileDirectory)HostApp\Helpers\EtabsSectionPropertyExtractor.cs" />
<Compile Include="$(MSBuildThisFileDirectory)HostApp\Helpers\EtabsShellSectionPropertyExtractor.cs" />
<Compile Include="$(MSBuildThisFileDirectory)HostApp\Helpers\EtabsShellSectionResolver.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Plugin\EtabsPluginBase.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Plugin\EtabsSpeckleFormBase.cs" />
<Compile Include="$(MSBuildThisFileDirectory)ServiceRegistration.cs" />
@@ -29,9 +29,11 @@ internal sealed class RevitSendBinding : RevitBaseBinding, ISendBinding
private readonly DocumentModelStore _store;
private readonly ICancellationManager _cancellationManager;
private readonly ISendConversionCache _sendConversionCache;
private readonly ToSpeckleSettingsManager _toSpeckleSettingsManager;
private readonly ElementUnpacker _elementUnpacker;
private readonly IRevitConversionSettingsFactory _revitConversionSettingsFactory;
private readonly RevitToSpeckleCacheSingleton _revitToSpeckleCacheSingleton;
private readonly ITopLevelExceptionHandler _topLevelExceptionHandler;
private readonly LinkedModelHandler _linkedModelHandler;
private readonly IThreadContext _threadContext;
@@ -55,6 +57,7 @@ internal sealed class RevitSendBinding : RevitBaseBinding, ISendBinding
ToSpeckleSettingsManager toSpeckleSettingsManager,
ElementUnpacker elementUnpacker,
IRevitConversionSettingsFactory revitConversionSettingsFactory,
RevitToSpeckleCacheSingleton revitToSpeckleCacheSingleton,
ITopLevelExceptionHandler topLevelExceptionHandler,
LinkedModelHandler linkedModelHandler,
IThreadContext threadContext,
@@ -71,6 +74,7 @@ internal sealed class RevitSendBinding : RevitBaseBinding, ISendBinding
_toSpeckleSettingsManager = toSpeckleSettingsManager;
_elementUnpacker = elementUnpacker;
_revitConversionSettingsFactory = revitConversionSettingsFactory;
_revitToSpeckleCacheSingleton = revitToSpeckleCacheSingleton;
_topLevelExceptionHandler = topLevelExceptionHandler;
_linkedModelHandler = linkedModelHandler;
_threadContext = threadContext;
@@ -449,6 +453,7 @@ internal sealed class RevitSendBinding : RevitBaseBinding, ISendBinding
private async Task OnDocumentChanged()
{
_sendConversionCache.ClearCache();
_revitToSpeckleCacheSingleton.ClearCache();
if (_cancellationManager.NumberOfOperations > 0)
{
@@ -34,18 +34,22 @@ public class LevelUnpacker
Dictionary<string, LevelProxy> levelProxies = new();
foreach (var element in elements)
{
if (levelProxies.TryGetValue(element.LevelId.ToString(), out LevelProxy? levelProxy))
// NOTE: Use level.UniqueId (not element.LevelId) as key
// face-based instances don't have a valid element.LevelId, hence all the changes in the LevelExtractor
var level = _levelExtractor.GetLevel(element);
if (level is null)
{
continue;
}
string levelKey = level.UniqueId;
if (levelProxies.TryGetValue(levelKey, out LevelProxy? levelProxy))
{
levelProxy.objects.Add(element.UniqueId);
}
else
{
var level = _levelExtractor.GetLevel(element);
if (level is null)
{
continue;
}
var levelDataObject = new DataObject()
{
name = level.Name,
@@ -53,11 +57,11 @@ public class LevelUnpacker
properties = _propertiesExtractor.GetProperties(level)
};
var unitSettings = _converterSettings.Current.Document.GetUnits();
var lengthUnitType = unitSettings.GetFormatOptions(Autodesk.Revit.DB.SpecTypeId.Length).GetUnitTypeId();
var lengthUnitType = unitSettings.GetFormatOptions(SpecTypeId.Length).GetUnitTypeId();
levelDataObject["elevation"] = UnitUtils.ConvertFromInternalUnits(level.Elevation, lengthUnitType);
levelDataObject["units"] = _converterSettings.Current.SpeckleUnits;
levelProxies[element.LevelId.ToString()] = new LevelProxy()
levelProxies[levelKey] = new LevelProxy()
{
applicationId = level.UniqueId,
objects = [element.UniqueId],
@@ -120,7 +120,7 @@ internal sealed class RevitDocumentStore : DocumentModelStore
var x = doc.PathName;
if (string.IsNullOrEmpty(x))
{
return null;
return doc.Title;
}
return x;
#endif
@@ -120,9 +120,14 @@ public class RevitMaterialBaker
try
{
// all values assumed to be on the 0 - 1 scale need to pass through this validation and logging (if assumption wrong)
double roughness = ClampToUnitRange(speckleRenderMaterial.roughness, "roughness", speckleRenderMaterial.name);
double opacity = ClampToUnitRange(speckleRenderMaterial.opacity, "opacity", speckleRenderMaterial.name);
double metalness = ClampToUnitRange(speckleRenderMaterial.metalness, "metalness", speckleRenderMaterial.name);
var diffuse = System.Drawing.Color.FromArgb(speckleRenderMaterial.diffuse);
double transparency = 1 - speckleRenderMaterial.opacity;
double smoothness = 1 - speckleRenderMaterial.roughness;
double transparency = 1 - opacity;
double smoothness = 1 - roughness;
string materialId = speckleRenderMaterial.applicationId ?? speckleRenderMaterial.id.NotNull();
string matName = _revitUtils.RemoveInvalidChars($"{speckleRenderMaterial.name}-({materialId})-{baseLayerName}");
@@ -130,7 +135,7 @@ public class RevitMaterialBaker
var revitMaterial = (Material)_converterSettings.Current.Document.GetElement(newMaterialId);
revitMaterial.Color = new Color(diffuse.R, diffuse.G, diffuse.B);
revitMaterial.Transparency = (int)(transparency * 100);
revitMaterial.Shininess = (int)(speckleRenderMaterial.metalness * 128);
revitMaterial.Shininess = (int)(metalness * 128);
revitMaterial.Smoothness = (int)(smoothness * 128);
foreach (var objectId in proxy.objects)
@@ -163,4 +168,30 @@ public class RevitMaterialBaker
document.Delete(materialIds);
}
}
/// <summary>
/// After CNX-2661, we've seen some edge cases contradicting the expected 0 - 1 range for PRB properties.
/// Defensively, we'd rather clamp these values than throw.
/// </summary>
/// <remarks>
/// Created a method so that we can extend the checks to any numerical value potentially leading to a negative value,
/// which would throw an exception. Generalised method since Math.Clamp() only available since C# 8.0 and this method
/// handles logging (in the hope that we can get a better feel for these "weird" models, e.g. 0 - 100 scale??)
/// </remarks>
private double ClampToUnitRange(double value, string propertyName, string materialName)
{
if (value is < 0 or > 1)
{
_logger.LogWarning(
"Material '{MaterialName}' has an invalid {PropertyName} value of {Value} and was clamped to 0 - 1 range",
materialName,
propertyName,
value
);
value = Math.Min(Math.Max(0, value), 1);
}
return value;
}
}
@@ -110,7 +110,8 @@ public sealed class RevitHostObjectBuilder(
// TODO: TransformTo and material baking needs to be fixed in Revit!!
// create a mapping from original to modified IDs <- so that we can actually map ids in the proxies to the objects
Dictionary<string, string> originalToModifiedIds = new();
// as part of CNX-2677, we have a one-to-many problem. many instances share the same reference, so we use a list
Dictionary<string, List<string>> originalToModifiedIds = new();
// modify application IDs BEFORE material baking
foreach (LocalToGlobalMap localToGlobalMap in localToGlobalMaps)
@@ -139,7 +140,13 @@ public sealed class RevitHostObjectBuilder(
string modifiedAppId = $"{originalAppId}_{Guid.NewGuid().ToString("N")[..8]}";
if (originalAppId != null)
{
originalToModifiedIds[originalAppId] = modifiedAppId;
if (!originalToModifiedIds.TryGetValue(originalAppId, out List<string>? modifiedIds))
{
modifiedIds = new List<string>();
originalToModifiedIds[originalAppId] = modifiedIds;
}
modifiedIds.Add(modifiedAppId);
}
localToGlobalMap.AtomicObject.applicationId = modifiedAppId;
@@ -152,14 +159,20 @@ public sealed class RevitHostObjectBuilder(
{
foreach (var proxy in unpackedRoot.RenderMaterialProxies)
{
var updatedObjects = new List<string>();
var objectIdsToUse = new List<string>();
foreach (var objectId in proxy.objects)
{
// Use the modified ID if it exists, otherwise keep the original <- this SUCKS and we need to change
string idToUse = originalToModifiedIds.TryGetValue(objectId, out var modifiedId) ? modifiedId : objectId;
updatedObjects.Add(idToUse);
if (originalToModifiedIds.TryGetValue(objectId, out var modifiedIds))
{
objectIdsToUse.AddRange(modifiedIds);
}
else
{
objectIdsToUse.Add(objectId);
}
}
proxy.objects = updatedObjects;
proxy.objects = objectIdsToUse;
}
}
@@ -183,6 +183,7 @@ public class RevitRootObjectBuilder(
// non-transformed elements can safely rely on cache
// TODO: Potential here to transform cached objects and NOT reconvert,
// TODO: we wont do !hasTransform here, and re-set application id before this
if (!hasTransform && sendConversionCache.TryGetValue(projectId, applicationId, out ObjectReference? value))
{
converted = value;
@@ -248,6 +249,17 @@ public class RevitRootObjectBuilder(
var levelProxies = levelUnpacker.Unpack(flatElements);
rootObject[ProxyKeys.LEVEL] = levelProxies;
rootObject[ProxyKeys.INSTANCE_DEFINITION] = revitToSpeckleCacheSingleton.GetInstanceDefinitionProxiesForObjects(
idsAndSubElementIds
);
rootObject.elements.Add(
new Collection()
{
elements = revitToSpeckleCacheSingleton.GetBaseObjectsForObjects(idsAndSubElementIds),
name = "revitInstancedObjects"
}
);
// NOTE: these are currently not used anywhere, we'll skip them until someone calls for it back
// rootObject[ProxyKeys.PARAMETER_DEFINITIONS] = _parameterDefinitionHandler.Definitions;
@@ -64,6 +64,13 @@ public class CreateCollection : VariableParameterComponentBase
}
}
// validate for duplicate application IDs across the entire collection hierarchy
if (HasDuplicateApplicationIds(rootCollection))
{
AddRuntimeMessage(GH_RuntimeMessageLevel.Error, "The same object(s) cannot appear in multiple collections");
return; // error already added in validation method
}
dataAccess.SetData(0, new SpeckleCollectionWrapperGoo(rootCollection));
}
@@ -182,6 +189,54 @@ public class CreateCollection : VariableParameterComponentBase
}
}
/// <summary>
/// Validates that all application IDs are unique across the entire collection hierarchy.
/// Shows an error if duplicates are found, indicating objects appear in multiple collections.
/// </summary>
/// <returns>True if duplicates exist, false if all IDs are unique</returns>
private bool HasDuplicateApplicationIds(SpeckleCollectionWrapper rootCollection)
{
// args to CheckForDuplicateApplicationIds passed in since the method can recursively check
var seenIds = new HashSet<string>();
var duplicateIds = new HashSet<string>();
// iterate, create hash set and check all application IDs
ProcessAndCheckForDuplicateApplicationIds(rootCollection, seenIds, duplicateIds);
return duplicateIds.Count > 0;
}
/// <summary>
/// Recursively collects application IDs from all in the collection hierarchy.
/// </summary>
/// <remarks>
/// Only checks the wrapper's ApplicationId, not for example geometries within DataObjects.
/// </remarks>
private void ProcessAndCheckForDuplicateApplicationIds(
SpeckleCollectionWrapper collection,
HashSet<string> seenIds,
HashSet<string> duplicateIds
)
{
foreach (var element in collection.Elements)
{
switch (element)
{
case SpeckleCollectionWrapper childCollection:
// recurse into child collections
ProcessAndCheckForDuplicateApplicationIds(childCollection, seenIds, duplicateIds);
break;
case SpeckleWrapper wrapper:
if (wrapper.ApplicationId != null && !seenIds.Add(wrapper.ApplicationId))
{
duplicateIds.Add(wrapper.ApplicationId);
}
break;
}
}
}
// IGH_VariableParameterComponent implementation
public override bool CanInsertParameter(GH_ParameterSide side, int index) => side == GH_ParameterSide.Input;
@@ -162,6 +162,7 @@ public class CreateSpeckleProperties : VariableParameterComponentBase
Params.RegisterInputParam(param);
}
Params.OnParametersChanged();
ExpireSolution(true);
}
@@ -103,7 +103,7 @@ public class SpeckleDataObjectPassthrough()
List<SpeckleGeometryWrapperGoo> inputGeometry = new();
if (!da.GetDataList(1, inputGeometry) && result == null)
{
AddRuntimeMessage(GH_RuntimeMessageLevel.Warning, $"Pass in a Speckle DataObject or Geometries.");
AddRuntimeMessage(GH_RuntimeMessageLevel.Warning, "Pass in a Speckle DataObject or Geometries");
return;
}
@@ -111,7 +111,7 @@ public class SpeckleDataObjectPassthrough()
{
if (inputGeo.Value is SpeckleBlockInstanceWrapper)
{
AddRuntimeMessage(GH_RuntimeMessageLevel.Error, $"DataObjects cannot contain Block Instances.");
AddRuntimeMessage(GH_RuntimeMessageLevel.Error, "DataObjects cannot contain Block Instances");
return;
}
}
@@ -158,6 +158,10 @@ public class SpeckleDataObjectPassthrough()
result.Properties = inputProperties;
}
// generate application ID for new data objects. Unlike SpeckleGeometry, DataObject wrappers aren't created
// through casting (which auto-generates IDs), so we must explicitly ensure an ID exists here
result.ApplicationId ??= Guid.NewGuid().ToString();
// get the path
string? path =
result.Path.Count > 1 ? string.Join(Constants.LAYER_PATH_DELIMITER, result.Path) : result.Path.FirstOrDefault();
@@ -138,7 +138,7 @@ public class SpeckleGeometryPassthrough()
if (result == null && inputGeometry == null)
{
AddRuntimeMessage(GH_RuntimeMessageLevel.Warning, $"Pass in a Speckle Geometry or Geometry.");
AddRuntimeMessage(GH_RuntimeMessageLevel.Warning, "Pass in a Speckle Geometry or Geometry");
return;
}
@@ -87,6 +87,7 @@ public class SpeckleSelectModelComponent : GH_Component
string? urlInput = null;
// SCENARIO 1: Component has input wire connected
if (da.GetData(0, ref urlInput))
{
UrlInput = urlInput;
@@ -99,6 +100,11 @@ public class SpeckleSelectModelComponent : GH_Component
return;
}
if (_justPastedIn)
{
RestoreAccountFromStoredState();
}
try
{
// NOTE: once we split the logic in Sender and Receiver components, we need to set flag correctly
@@ -132,22 +138,9 @@ public class SpeckleSelectModelComponent : GH_Component
_storedUserId = SpeckleOperationWizard.SelectedAccount?.id;
}
if (_justPastedIn && _storedUserId != null && !string.IsNullOrEmpty(_storedUserId))
if (_justPastedIn)
{
try
{
SpeckleOperationWizard.SetAccountFromId(_storedUserId);
}
catch (SpeckleAccountManagerException e)
{
// Swallow and move onto checking server.
Console.WriteLine(e);
}
if (_storedServer != null && SpeckleOperationWizard.SelectedAccount == null)
{
SpeckleOperationWizard.SetAccountFromIdAndUrl(_storedUserId, _storedServer);
}
RestoreAccountFromStoredState();
}
// Validate backing data
@@ -396,4 +389,39 @@ public class SpeckleSelectModelComponent : GH_Component
VersionContextMenuButton.ExpirePreview(redraw);
base.ExpirePreview(redraw);
}
/// <summary>
/// Restores the account from stored state when the component is pasted or loaded from file.
/// </summary>
/// <remarks>
/// Attempts to restore account in two stages:
/// <list type="number">
/// <item>First tries to get account by stored user ID</item>
/// <item>If that fails and server url is available, falls back to getting any account matching the server</item>
/// </list>
/// Only executes when <see cref="_justPastedIn"/> is true and <see cref="_storedUserId"/> is not empty.
/// </remarks>
private void RestoreAccountFromStoredState()
{
if (_storedUserId is null || string.IsNullOrEmpty(_storedUserId))
{
return;
}
try
{
SpeckleOperationWizard.SetAccountFromId(_storedUserId);
}
catch (SpeckleAccountManagerException e)
{
Console.WriteLine(e);
}
// Fallback: if account wasn't found by ID but we have a server URL,
// try to find any account matching that server
if (_storedServer != null && SpeckleOperationWizard.SelectedAccount == null)
{
SpeckleOperationWizard.SetAccountFromIdAndUrl(_storedUserId, _storedServer);
}
}
}
@@ -79,22 +79,40 @@ public class SpeckleOperationWizard
var resources = SpeckleResourceBuilder.FromUrlString(input, token);
if (resources.Length == 0)
{
throw new SpeckleException($"Input url string was empty");
throw new SpeckleException("Input url string was empty");
}
if (resources.Length > 1)
{
throw new SpeckleException($"Input multi-model url is not supported");
throw new SpeckleException("Input multi-model url is not supported");
}
var resource = resources.First();
using var scope = PriorityLoader.CreateScopeForActiveDocument();
var account = resource.Account.GetAccount(scope);
SetAccount(account, false);
var urlDerivedAccount = resource.Account.GetAccount(scope);
// if no account is selected, happily go through the url derived account approach
if (SelectedAccount == null)
{
throw new SpeckleException("No account found for server URL");
SetAccount(urlDerivedAccount, false);
}
// if we have an account from right-click context-menu, we rely on that and just validate that it's actually applicable to that server
else if (urlDerivedAccount != null && SelectedAccount.serverInfo.url != urlDerivedAccount.serverInfo.url)
{
throw new SpeckleException(
$"Selected account is for '{SelectedAccount.serverInfo.url}' "
+ $"but URL requires '{urlDerivedAccount.serverInfo.url}'"
);
}
// we have both scenarios covered
// Scenario #1 - default account from url
// Scenario #2 - triggered by account switch on right-click context (and validated)
if (SelectedAccount == null)
{
throw new SpeckleException(
$"No appropriate account found for the given '{urlDerivedAccount?.serverInfo.url}' server"
);
}
IClient client = _clientFactory.Create(SelectedAccount);
@@ -83,7 +83,7 @@ public sealed class RhinoSendBinding : ISendBinding
_sendOperationManagerFactory = sendOperationManagerFactory;
_rhinoLayerHelper = rhinoLayerHelper;
Commands = new SendBindingUICommands(parent); // POC: Commands are tightly coupled with their bindings, at least for now, saves us injecting a factory.
PreviousUnitSystem = RhinoDoc.ActiveDoc.ModelUnitSystem;
PreviousUnitSystem = RhinoDoc.ActiveDoc?.ModelUnitSystem ?? UnitSystem.None;
SubscribeToRhinoEvents();
}
@@ -104,10 +104,10 @@ public class RhinoInstanceBaker : IInstanceBaker<IReadOnlyCollection<string>>
attributes
);
// POC: check on defIndex -1, means we haven't created anything - this is most likely an recoverable error at this stage
// POC: check on defIndex -1, means we haven't created anything - this is most likely an unrecoverable error at this stage
if (defIndex == -1)
{
throw new ConversionException("Failed to create an instance defintion object.");
throw new ConversionException("Failed to create an instance definition object.");
}
if (definitionProxy.applicationId != null)
@@ -170,9 +170,13 @@ public class RhinoInstanceBaker : IInstanceBaker<IReadOnlyCollection<string>>
public void PurgeInstances(string namePrefix)
{
var currentDoc = RhinoDoc.ActiveDoc; // POC: too much right now to interface around
// clean name prefix to match how block names are created
var cleanedPrefix = RhinoUtils.CleanBlockDefinitionName(namePrefix);
foreach (var definition in currentDoc.InstanceDefinitions)
{
if (!definition.IsDeleted && definition.Name.Contains(namePrefix))
if (!definition.IsDeleted && definition.Name.Contains(cleanedPrefix))
{
currentDoc.InstanceDefinitions.Delete(definition.Index, true, false);
}
@@ -1,5 +1,4 @@
using Microsoft.Extensions.Logging;
using Rhino;
using Speckle.Converters.Common;
using Speckle.Converters.Rhino;
using Speckle.Objects.Other;
@@ -44,35 +43,43 @@ public class RhinoMaterialBaker
string materialId = speckleRenderMaterial.applicationId ?? speckleRenderMaterial.id.NotNull();
string matName = $"{speckleRenderMaterial.name}-({materialId})-{baseLayerName}";
matName = matName.Replace("[", "").Replace("]", ""); // "Material" doesn't like square brackets if we create from here. Once they created from Rhino UI, all good..
Color diffuse = Color.FromArgb(speckleRenderMaterial.diffuse);
Color emissive = Color.FromArgb(speckleRenderMaterial.emissive);
double transparency = 1 - speckleRenderMaterial.opacity;
Material rhinoMaterial =
new()
{
Name = matName,
DiffuseColor = diffuse,
EmissionColor = emissive,
Transparency = transparency
};
// Check if material with this name already exists in the document
int matIndex = doc.Materials.Find(matName, ignoreDeletedMaterials: true);
// try to get additional properties
if (speckleRenderMaterial["ior"] is double ior)
{
rhinoMaterial.IndexOfRefraction = ior;
}
if (speckleRenderMaterial["shine"] is double shine)
{
rhinoMaterial.Shine = shine;
}
int matIndex = doc.Materials.Add(rhinoMaterial);
// POC: check on matIndex -1, means we haven't created anything - this is most likely an recoverable error at this stage
// If material doesn't exist, create it
if (matIndex == -1)
{
throw new ConversionException("Failed to add a material to the document.");
Color diffuse = Color.FromArgb(speckleRenderMaterial.diffuse);
Color emissive = Color.FromArgb(speckleRenderMaterial.emissive);
double transparency = 1 - speckleRenderMaterial.opacity;
Material rhinoMaterial =
new()
{
Name = matName,
DiffuseColor = diffuse,
EmissionColor = emissive,
Transparency = transparency
};
// try to get additional properties
if (speckleRenderMaterial["ior"] is double ior)
{
rhinoMaterial.IndexOfRefraction = ior;
}
if (speckleRenderMaterial["shine"] is double shine)
{
rhinoMaterial.Shine = shine;
}
matIndex = doc.Materials.Add(rhinoMaterial);
// POC: check on matIndex -1, means we haven't created anything - this is most likely an recoverable error at this stage
if (matIndex == -1)
{
throw new ConversionException($"Failed to add a material to the document: '{matName}' (ID: {materialId})");
}
}
// Create the object <> material index map
@@ -87,27 +94,4 @@ public class RhinoMaterialBaker
}
}
}
/// <summary>
/// Removes all materials with a name starting with <paramref name="namePrefix"/> from the active document
/// </summary>
/// <param name="namePrefix"></param>
public void PurgeMaterials(string namePrefix)
{
var currentDoc = RhinoDoc.ActiveDoc; // POC: too much right now to interface around
foreach (Material material in currentDoc.Materials)
{
try
{
if (!material.IsDeleted && material.Name != null && material.Name.Contains(namePrefix))
{
currentDoc.Materials.Delete(material);
}
}
catch (Exception ex) when (!ex.IsFatal())
{
_logger.LogError(ex, "Failed to purge a material from the document");
}
}
}
}
@@ -218,13 +218,23 @@ public class RhinoMaterialUnpacker
? pbRenderMaterial.Material.EmissionColor
: pbRenderMaterial.Emission.AsSystemColor(); // pbRenderMaterial.emission gives wrong color for emission materials, and material.emissioncolor gives the wrong value for most others *shrug*
// NOTE: added after CNX-2661, without having file that caused issue hard to say what the issue is
// api bug / funny model (custom textures) / upgrade from old model (e.g. Rhino 6)? who knows.
// PBR standard is 0-1. Clamping to valid range. This may indicate texture data is in wrong scale.
double roughness = pbRenderMaterial.Roughness;
if (roughness < 0 || roughness > 1)
{
_logger.LogWarning("Material '{Name}' has invalid roughness value of {Value}", renderMaterial.Name, roughness);
roughness = Math.Min(Math.Max(0, roughness), 1); // Math.Clamp() only from C# 8.0
}
SpeckleRenderMaterial speckleRenderMaterial =
new()
{
name = renderMaterialName,
opacity = opacity,
metalness = pbRenderMaterial.Metallic,
roughness = pbRenderMaterial.Roughness,
roughness = roughness,
diffuse = diffuse.ToArgb(),
emissive = emissive.ToArgb(),
applicationId = renderMaterial.Id.ToString()
@@ -256,7 +256,8 @@ public class RhinoHostObjectBuilder : IHostObjectBuilder
.RunOnMain(() =>
{
_instanceBaker.PurgeInstances(baseLayerName);
_materialBaker.PurgeMaterials(baseLayerName);
// Materials are now reused across receives instead of being purged
// _materialBaker.PurgeMaterials(baseLayerName);
var doc = _converterSettings.Current.Document;
// Cleans up any previously received objects
@@ -8,25 +8,11 @@ namespace Speckle.Converters.CSiShared.ToSpeckle.Helpers;
/// Extracts properties common to frame elements across CSi products (e.g., Etabs, Sap2000)
/// using the FrameObj API calls.
/// </summary>
/// <remarks>
/// Design Decisions:
/// <list type="bullet">
/// <item>
/// <description>
/// Individual methods preferred over batched calls due to:
/// <list type="bullet">
/// <item><description>Independent API calls with no performance gain from batching (?)</description></item>
/// <item><description>Easier debugging and error tracing</description></item>
/// <item><description>Simpler maintenance as each method maps to one API concept</description></item>
/// </list>
/// </description>
/// </item>
/// </list>
/// </remarks>
public sealed class CsiFramePropertiesExtractor
{
private readonly IConverterSettingsStore<CsiConversionSettings> _settingsStore;
private readonly CsiToSpeckleCacheSingleton _csiToSpeckleCacheSingleton;
private readonly DatabaseTableExtractor _databaseTableExtractor;
private static readonly string[] s_releaseKeys =
[
@@ -36,15 +22,17 @@ public sealed class CsiFramePropertiesExtractor
"Torsion",
"Moment 22 (Minor)",
"Moment 33 (Major)"
]; // Note: caching keys for better performance
];
public CsiFramePropertiesExtractor(
CsiToSpeckleCacheSingleton csiToSpeckleCacheSingleton,
IConverterSettingsStore<CsiConversionSettings> settingsStore
IConverterSettingsStore<CsiConversionSettings> settingsStore,
DatabaseTableExtractor databaseTableExtractor
)
{
_csiToSpeckleCacheSingleton = csiToSpeckleCacheSingleton;
_settingsStore = settingsStore;
_databaseTableExtractor = databaseTableExtractor;
}
public void ExtractProperties(CsiFrameWrapper frame, PropertyExtractionResult frameData)
@@ -61,13 +49,29 @@ public sealed class CsiFramePropertiesExtractor
assignments[CommonObjectProperty.PROPERTY_MODIFIERS] = GetModifiers(frame);
assignments["End Releases"] = GetReleases(frame);
// NOTE: sectionId and materialId a "quick-fix" to enable filtering in the viewer etc.
// NOTE: sectionId and materialId a "quick-fix" to enable filtering in the viewer etc. Strings are unique
// Assign sectionId to variable as this will be an argument for the GetMaterialName method
string sectionId = GetSectionName(frame);
string materialId = GetMaterialName(sectionId);
assignments[ObjectPropertyKey.SECTION_ID] = sectionId;
assignments[ObjectPropertyKey.MATERIAL_ID] = materialId;
// CNX-2725 adds more numeric props for dashboard-ing
double length = GetLength(frame);
double area = GetCrossSectionalArea(sectionId);
double volume = double.NaN;
if (!double.IsNaN(length) && !double.IsNaN(area) && length > 0 && area > 0)
{
// I am paranoid about what etabs could throw our way
double computedVolume = length * area;
volume = (!double.IsInfinity(computedVolume) && !double.IsNaN(computedVolume)) ? computedVolume : double.NaN;
}
geometry.AddWithUnits(ObjectPropertyKey.LENGTH, length, _settingsStore.Current.SpeckleUnits);
geometry.AddWithUnits(ObjectPropertyKey.CROSS_SECTIONAL_AREA, area, $"{_settingsStore.Current.SpeckleUnits}²");
geometry.AddWithUnits(ObjectPropertyKey.VOLUME, volume, $"{_settingsStore.Current.SpeckleUnits}³");
// store the object, section, and material id relationships in their corresponding caches to be accessed by the connector
if (!string.IsNullOrEmpty(sectionId))
{
@@ -196,4 +200,56 @@ public sealed class CsiFramePropertiesExtractor
_ = _settingsStore.Current.SapModel.PropFrame.GetMaterial(sectionName, ref materialName);
return materialName;
}
private double GetLength(CsiFrameWrapper frame)
{
// using the DatabaseTableExtractor fetch table with key "Frame Assignments - Summary"
// limit query size to "UniqueName" and "Length" fields
string length = _databaseTableExtractor
.GetTableData("Frame Assignments - Summary", requestedColumns: ["UniqueName", ObjectPropertyKey.LENGTH])
.GetRowValue(frame.Name, ObjectPropertyKey.LENGTH);
// all database data is returned as strings
return double.TryParse(length, out double result) ? result : double.NaN;
}
private double GetCrossSectionalArea(string sectionName)
{
if (_csiToSpeckleCacheSingleton.FrameSectionAreaCache.TryGetValue(sectionName, out double value))
{
return value;
}
double area = 0,
as2 = 0,
as3 = 0,
torsion = 0,
i22 = 0,
i33 = 0,
s22 = 0,
s33 = 0,
z22 = 0,
z33 = 0,
r22 = 0,
r33 = 0;
int result = _settingsStore.Current.SapModel.PropFrame.GetSectProps(
sectionName,
ref area,
ref as2,
ref as3,
ref torsion,
ref i22,
ref i33,
ref s22,
ref s33,
ref z22,
ref z33,
ref r22,
ref r33
);
double validatedArea = result == 0 ? area : double.NaN;
_csiToSpeckleCacheSingleton.FrameSectionAreaCache.Add(sectionName, validatedArea);
return validatedArea;
}
}
@@ -8,28 +8,18 @@ namespace Speckle.Converters.CSiShared.ToSpeckle.Helpers;
/// Extracts properties common to shell elements across CSi products (e.g., Etabs, Sap2000)
/// using the AreaObj API calls.
/// </summary>
/// <remarks>
/// Design Decisions:
/// <list type="bullet">
/// <item>
/// <description>
/// Individual methods preferred over batched calls due to:
/// <list type="bullet">
/// <item><description>Independent API calls with no performance gain from batching (?)</description></item>
/// <item><description>Easier debugging and error tracing</description></item>
/// <item><description>Simpler maintenance as each method maps to one API concept</description></item>
/// </list>
/// </description>
/// </item>
/// </list>
/// </remarks>
public sealed class CsiShellPropertiesExtractor
{
private readonly IConverterSettingsStore<CsiConversionSettings> _settingsStore;
private readonly CsiToSpeckleCacheSingleton _csiToSpeckleCacheSingleton;
public CsiShellPropertiesExtractor(IConverterSettingsStore<CsiConversionSettings> settingsStore)
public CsiShellPropertiesExtractor(
IConverterSettingsStore<CsiConversionSettings> settingsStore,
CsiToSpeckleCacheSingleton csiToSpeckleCacheSingleton
)
{
_settingsStore = settingsStore;
_csiToSpeckleCacheSingleton = csiToSpeckleCacheSingleton;
}
public void ExtractProperties(CsiShellWrapper shell, PropertyExtractionResult shellData)
@@ -37,7 +27,7 @@ public sealed class CsiShellPropertiesExtractor
shellData.ApplicationId = shell.GetSpeckleApplicationId(_settingsStore.Current.SapModel);
var geometry = shellData.Properties.EnsureNested(ObjectPropertyCategory.GEOMETRY);
geometry["Joints"] = GetPointNames(shell); // TODO: 🪲 Viewer shows 4 but only displays 3
geometry["Joints"] = GetPointNames(shell);
var assignments = shellData.Properties.EnsureNested(ObjectPropertyCategory.ASSIGNMENTS);
assignments[CommonObjectProperty.GROUPS] = GetGroupAssigns(shell);
@@ -16,4 +16,16 @@ public class CsiToSpeckleCacheSingleton
/// A map of (section id, shell object id). Assumes the section id is the unique name of the section
/// </summary>
public Dictionary<string, List<string>> ShellSectionCache { get; set; } = [];
/// <summary>
/// A cache of cross-sectional areas used
/// </summary>
public Dictionary<string, double> FrameSectionAreaCache { get; set; } = [];
/// <summary>
/// A cache of resolved shell section properties populated by "EtabsShellPropertiesExtractor"
/// and consumed by "EtabsShellSectionPropertyExtractor".
/// This eliminates redundant section resolution API calls.
/// </summary>
public Dictionary<string, Dictionary<string, object?>> ShellSectionPropertiesCache { get; set; } = [];
}
@@ -39,7 +39,7 @@ public abstract class CsiObjectToSpeckleConverterBase : IToSpeckleTopLevelConver
public Base Convert(object target) => Convert((CsiWrapperBase)target);
public Base Convert(CsiWrapperBase wrapper)
private Base Convert(CsiWrapperBase wrapper)
{
var displayValue = _displayValueExtractor.GetDisplayValue(wrapper).ToList();
var objectData = _applicationPropertiesExtractor.ExtractProperties(wrapper);
@@ -21,8 +21,14 @@ public static class ObjectPropertyCategory
/// </summary>
public static class ObjectPropertyKey
{
public const string AREA = "Area";
public const string CROSS_SECTIONAL_AREA = "Cross-Sectional Area";
public const string DESIGN_PROCEDURE = "Design Procedure";
public const string LENGTH = "Length";
public const string MATERIAL_ID = "Material";
public const string SECTION_ID = "Section Property";
public const string THICKNESS = "Thickness";
public const string VOLUME = "Volume";
}
/// <summary>
@@ -20,6 +20,7 @@ public static class ServiceRegistration
serviceCollection.AddScoped<EtabsShellPropertiesExtractor>();
serviceCollection.AddScoped<IApplicationPropertiesExtractor, EtabsPropertiesExtractor>();
serviceCollection.AddScoped<CsiObjectToSpeckleConverterBase, EtabsObjectToSpeckleConverter>();
serviceCollection.AddScoped<EtabsShellSectionResolver>();
serviceCollection.AddMatchingInterfacesAsTransient(converterAssembly);
@@ -14,6 +14,7 @@
<Compile Include="$(MSBuildThisFileDirectory)ToSpeckle\Helpers\EtabsFramePropertiesExtractor.cs" />
<Compile Include="$(MSBuildThisFileDirectory)ToSpeckle\Helpers\EtabsJointPropertiesExtractor.cs" />
<Compile Include="$(MSBuildThisFileDirectory)ToSpeckle\Helpers\EtabsShellPropertiesExtractor.cs" />
<Compile Include="$(MSBuildThisFileDirectory)ToSpeckle\Helpers\EtabsShellSectionResolver.cs" />
<Compile Include="$(MSBuildThisFileDirectory)ToSpeckle\TopLevel\EtabsObjectToSpeckleConverter.cs" />
</ItemGroup>
</Project>
@@ -1,6 +1,5 @@
using Speckle.Converters.Common;
using Speckle.Converters.CSiShared;
using Speckle.Converters.CSiShared.ToSpeckle.Helpers;
using Speckle.Converters.CSiShared.Utils;
namespace Speckle.Converters.ETABSShared.ToSpeckle.Helpers;
@@ -8,32 +7,13 @@ namespace Speckle.Converters.ETABSShared.ToSpeckle.Helpers;
/// <summary>
/// Extracts ETABS-specific properties from frame elements using the FrameObj API calls.
/// </summary>
/// <remarks>
/// Responsibilities:
/// <list type="bullet">
/// <item><description>Extracts properties only available in ETABS (e.g., Label, Level)</description></item>
/// <item><description>Complements <see cref="CsiFramePropertiesExtractor"/> by adding product-specific data</description></item>
/// <item><description>Follows same pattern of single-purpose methods for clear API mapping</description></item>
/// </list>
///
/// Design Decisions:
/// <list type="bullet">
/// <item><description>Maintains separate methods for each property following CSI API structure</description></item>
/// <item><description>Properties are organized by their functional groups (Object ID, Assignments, Design)</description></item>
/// </list>
/// </remarks>
public sealed class EtabsFramePropertiesExtractor
{
private readonly IConverterSettingsStore<CsiConversionSettings> _settingsStore;
private readonly DatabaseTableExtractor _databaseTableExtractor;
public EtabsFramePropertiesExtractor(
IConverterSettingsStore<CsiConversionSettings> settingsStore,
DatabaseTableExtractor databaseTableExtractor
)
public EtabsFramePropertiesExtractor(IConverterSettingsStore<CsiConversionSettings> settingsStore)
{
_settingsStore = settingsStore;
_databaseTableExtractor = databaseTableExtractor;
}
public void ExtractProperties(CsiFrameWrapper frame, Dictionary<string, object?> properties)
@@ -46,11 +26,7 @@ public sealed class EtabsFramePropertiesExtractor
assignments[CommonObjectProperty.SPRING_ASSIGNMENT] = GetSpringAssignmentName(frame);
var design = properties.EnsureNested(ObjectPropertyCategory.DESIGN);
design["Design Procedure"] = GetDesignProcedure(frame);
var geometry = properties.EnsureNested(ObjectPropertyCategory.GEOMETRY);
double length = GetLength(frame);
geometry.AddWithUnits("Length", length, _settingsStore.Current.SpeckleUnits);
design[ObjectPropertyKey.DESIGN_PROCEDURE] = GetDesignProcedure(frame);
}
private (string label, string level) GetLabelAndLevel(CsiFrameWrapper frame)
@@ -90,16 +66,4 @@ public sealed class EtabsFramePropertiesExtractor
_ = _settingsStore.Current.SapModel.FrameObj.GetSpringAssignment(frame.Name, ref springPropertyName);
return springPropertyName;
}
private double GetLength(CsiFrameWrapper frame)
{
// using the DatabaseTableExtractor fetch table with key "Frame Assignments - Summary"
// limit query size to "UniqueName" and "Length" fields
string length = _databaseTableExtractor
.GetTableData("Frame Assignments - Summary", requestedColumns: ["UniqueName", "Length"])
.GetRowValue(frame.Name, "Length");
// all database data is returned as strings
return double.TryParse(length, out double result) ? result : double.NaN;
}
}
@@ -9,35 +9,24 @@ namespace Speckle.Converters.ETABSShared.ToSpeckle.Helpers;
/// <summary>
/// Extracts ETABS-specific properties from shell elements using the AreaObj API calls.
/// </summary>
/// <remarks>
/// Responsibilities:
/// <list type="bullet">
/// <item><description>Extracts properties only available in ETABS (e.g., Label, Level)</description></item>
/// <item><description>Complements <see cref="CsiShellPropertiesExtractor"/> by adding product-specific data</description></item>
/// <item><description>Follows same pattern of single-purpose methods for clear API mapping</description></item>
/// </list>
///
/// Design Decisions:
/// <list type="bullet">
/// <item><description>Maintains separate methods for each property following CSI API structure</description></item>
/// <item><description>Properties are organized by their functional groups (Object ID, Assignments, Design)</description></item>
/// </list>
/// </remarks>
public sealed class EtabsShellPropertiesExtractor
{
private readonly IConverterSettingsStore<CsiConversionSettings> _settingsStore;
private readonly CsiToSpeckleCacheSingleton _csiToSpeckleCacheSingleton;
private readonly DatabaseTableExtractor _databaseTableExtractor;
private readonly EtabsShellSectionResolver _etabsShellSectionResolver;
public EtabsShellPropertiesExtractor(
CsiToSpeckleCacheSingleton csiToSpeckleCacheSingleton,
IConverterSettingsStore<CsiConversionSettings> settingsStore,
DatabaseTableExtractor databaseTableExtractor
DatabaseTableExtractor databaseTableExtractor,
EtabsShellSectionResolver etabsShellSectionResolver
)
{
_settingsStore = settingsStore;
_csiToSpeckleCacheSingleton = csiToSpeckleCacheSingleton;
_databaseTableExtractor = databaseTableExtractor;
_etabsShellSectionResolver = etabsShellSectionResolver;
}
public void ExtractProperties(CsiShellWrapper shell, Dictionary<string, object?> properties)
@@ -62,9 +51,22 @@ public sealed class EtabsShellPropertiesExtractor
assignments[ObjectPropertyKey.SECTION_ID] = sectionId;
assignments[ObjectPropertyKey.MATERIAL_ID] = materialId;
// CNX-2725 adds more numeric props for dashboard-ing
var geometry = properties.EnsureNested(ObjectPropertyCategory.GEOMETRY);
double area = GetArea(shell, designOrientation);
geometry.AddWithUnits("Area", area, $"{_settingsStore.Current.SpeckleUnits}²");
double thickness = GetSectionThickness(sectionId);
double volume = double.NaN;
if (!double.IsNaN(area) && !double.IsNaN(thickness) && area > 0 && thickness > 0)
{
// I am paranoid about what etabs could throw our way
double computedVolume = area * thickness;
volume = (!double.IsInfinity(computedVolume) && !double.IsNaN(computedVolume)) ? computedVolume : double.NaN;
}
geometry.AddWithUnits(ObjectPropertyKey.THICKNESS, thickness, _settingsStore.Current.SpeckleUnits);
geometry.AddWithUnits(ObjectPropertyKey.AREA, area, $"{_settingsStore.Current.SpeckleUnits}²");
geometry.AddWithUnits(ObjectPropertyKey.VOLUME, volume, $"{_settingsStore.Current.SpeckleUnits}³");
// store the object, section, and material id relationships in their corresponding caches to be accessed by the connector
if (!string.IsNullOrEmpty(sectionId))
@@ -188,4 +190,66 @@ public sealed class EtabsShellPropertiesExtractor
// all database data is returned as strings
return double.TryParse(area, out var result) ? result : double.NaN;
}
/// <summary>
/// Gets section thickness, resolving and caching section properties on first encounter.
/// </summary>
/// <param name="sectionId">The section name to get thickness for</param>
/// <returns>Thickness value, or NaN if section is invalid or thickness cannot be determined</returns>
private double GetSectionThickness(string sectionId)
{
// Guard against invalid sections
if (string.IsNullOrEmpty(sectionId) || sectionId == "None")
{
return double.NaN;
}
// Check if section already resolved and cached
if (!_csiToSpeckleCacheSingleton.ShellSectionPropertiesCache.TryGetValue(sectionId, out var sectionProperties))
{
// First encounter - resolve section and cache all properties
sectionProperties = _etabsShellSectionResolver.ResolveSection(sectionId);
_csiToSpeckleCacheSingleton.ShellSectionPropertiesCache[sectionId] = sectionProperties;
}
// Extract thickness from cached properties
return ExtractThicknessFromProperties(sectionProperties);
}
/// <summary>
/// Extracts thickness value from resolved section properties dictionary structure.
/// </summary>
/// <remarks>
/// Section properties have nested structure:
/// { "Property Data" -> { "Thickness" -> { "value" -> double, "units" -> string } } }
/// </remarks>
private static double ExtractThicknessFromProperties(Dictionary<string, object?> sectionProperties)
{
if (!sectionProperties.TryGetValue(SectionPropertyCategory.PROPERTY_DATA, out object? propertyDataObj))
{
return double.NaN;
}
if (propertyDataObj is not Dictionary<string, object?> propertyData)
{
return double.NaN;
}
if (!propertyData.TryGetValue(ObjectPropertyKey.THICKNESS, out object? thicknessObj))
{
return double.NaN;
}
if (thicknessObj is not Dictionary<string, object> thicknessDict)
{
return double.NaN;
}
if (!thicknessDict.TryGetValue("value", out object? valueObj))
{
return double.NaN;
}
return valueObj is double thickness ? thickness : double.NaN;
}
}
@@ -2,7 +2,7 @@ using Speckle.Converters.Common;
using Speckle.Converters.CSiShared;
using Speckle.Converters.CSiShared.Utils;
namespace Speckle.Connectors.ETABSShared.HostApp.Helpers;
namespace Speckle.Converters.ETABSShared.ToSpeckle.Helpers;
/// <summary>
/// Attempts to resolve the section type and retrieve its properties by trying different section resolvers.
@@ -14,6 +14,17 @@ public class PropertySetDefinitionHandler
/// POC: We're storing these by property set def name atm. There is a decent change different property sets can have the same name, need to validate this.
public Dictionary<string, Dictionary<string, object?>> Definitions { get; } = new();
// Keys used for the dictionary representing a single property set definition
public const string PROP_SET_DEF_NAME_KEY = "name"; // name of the property set definition
public const string PROP_SET_PROP_DEFS_KEY = "propertyDefinitions"; // property definitions in this property set definition
// Keys used for inidividual property definitions within a single property set definition
public const string PROP_DEF_NAME_KEY = "name";
public const string PROP_DEF_DESCRIPTION_KEY = "description";
public const string PROP_DEF_ID_KEY = "id";
public const string PROP_DEF_TYPE_KEY = "dataType";
public const string PROP_DEF_DEFAULT_VALUE_KEY = "defaultValue";
/// <summary>
/// Extracts out and stores in <see cref="Definitions"/> the property set definition.
/// </summary>
@@ -29,12 +40,11 @@ public class PropertySetDefinitionHandler
propertyDefinitionNames[propertyDefinition.Id] = propertyName;
var propertyDict = new Dictionary<string, object?>()
{
["name"] = propertyName,
["description"] = propertyDefinition.Description,
["id"] = propertyDefinition.Id,
["isReadOnly"] = propertyDefinition.IsReadOnly,
["dataType"] = propertyDefinition.DataType.ToString(),
["defaultValue"] = propertyDefinition.DefaultData
[PROP_DEF_NAME_KEY] = propertyName,
[PROP_DEF_DESCRIPTION_KEY] = propertyDefinition.Description,
[PROP_DEF_ID_KEY] = propertyDefinition.Id,
[PROP_DEF_TYPE_KEY] = propertyDefinition.DataType.ToString(),
[PROP_DEF_DEFAULT_VALUE_KEY] = propertyDefinition.DefaultData
};
// accessing unit type prop can be expected to throw if it's not applicable to the definition
@@ -53,8 +63,8 @@ public class PropertySetDefinitionHandler
Definitions[name] = new Dictionary<string, object?>()
{
["name"] = name,
["propertyDefinitions"] = propertyDefinitionsDict
[PROP_SET_DEF_NAME_KEY] = name,
[PROP_SET_PROP_DEFS_KEY] = propertyDefinitionsDict
};
return propertyDefinitionNames;
@@ -91,7 +91,8 @@ public class PropertySetExtractor
? propertyDefinitionName
: data.FieldBucketId;
var value = GetValue(data);
// POC: not sure how to support graphic types atm
var value = data.DataType is AAEC.PropertyData.DataType.Graphic ? null : data.GetData(data.UnitType);
Dictionary<string, object?> propertyValueDict = new() { ["value"] = value, ["name"] = dataName };
PropertyHandler propHandler = new();
@@ -109,31 +110,4 @@ public class PropertySetExtractor
return null;
}
private object? GetValue(AAECPDB.PropertySetData data)
{
object fieldData = data.GetData(data.UnitType);
switch (data.DataType)
{
case AAEC.PropertyData.DataType.Integer:
return fieldData as int?;
case AAEC.PropertyData.DataType.Real:
return fieldData as double?;
case AAEC.PropertyData.DataType.TrueFalse:
return fieldData as bool?;
case AAEC.PropertyData.DataType.Graphic: // POC: not sure how to support atm
return null;
case AAEC.PropertyData.DataType.List:
return fieldData as List<object>;
case AAEC.PropertyData.DataType.AutoIncrement:
return fieldData as int?;
case AAEC.PropertyData.DataType.AlphaIncrement: // POC: not sure what this is
return fieldData;
case AAEC.PropertyData.DataType.Text:
return fieldData as string;
default:
return fieldData;
}
}
}
@@ -1,8 +1,10 @@
using Microsoft.Extensions.Logging;
using Speckle.Converters.Common;
using Speckle.Converters.Common.Objects;
using Speckle.Converters.Common.ToSpeckle;
using Speckle.Converters.RevitShared.Extensions;
using Speckle.Converters.RevitShared.Services;
using Speckle.Converters.RevitShared.Settings;
using Speckle.DoubleNumerics;
using Speckle.Objects;
using Speckle.Sdk;
using Speckle.Sdk.Common;
@@ -18,11 +20,11 @@ public sealed class DisplayValueExtractor
List<SOG.Mesh>
> _meshByMaterialConverter;
private readonly IScalingServiceToSpeckle _toSpeckleScalingService;
private readonly ITypedConverter<DB.Curve, ICurve> _curveConverter;
private readonly ITypedConverter<DB.PolyLine, SOG.Polyline> _polylineConverter;
private readonly ITypedConverter<DB.Point, SOG.Point> _pointConverter;
private readonly ITypedConverter<DB.PointCloudInstance, SOG.Pointcloud> _pointcloudConverter;
private readonly ILogger<DisplayValueExtractor> _logger;
private readonly IConverterSettingsStore<RevitConversionSettings> _converterSettings;
public DisplayValueExtractor(
@@ -34,8 +36,8 @@ public sealed class DisplayValueExtractor
ITypedConverter<DB.PolyLine, SOG.Polyline> polylineConverter,
ITypedConverter<DB.Point, SOG.Point> pointConverter,
ITypedConverter<DB.PointCloudInstance, SOG.Pointcloud> pointcloudConverter,
ILogger<DisplayValueExtractor> logger,
IConverterSettingsStore<RevitConversionSettings> converterSettings
IConverterSettingsStore<RevitConversionSettings> converterSettings,
IScalingServiceToSpeckle toSpeckleScalingService
)
{
_meshByMaterialConverter = meshByMaterialConverter;
@@ -43,30 +45,30 @@ public sealed class DisplayValueExtractor
_polylineConverter = polylineConverter;
_pointConverter = pointConverter;
_pointcloudConverter = pointcloudConverter;
_logger = logger;
_converterSettings = converterSettings;
_toSpeckleScalingService = toSpeckleScalingService;
}
public List<Base> GetDisplayValue(DB.Element element)
public List<DisplayValueResult> GetDisplayValue(DB.Element element)
{
switch (element)
{
// get custom (anything not using element.get_geometry) display values
case DB.PointCloudInstance pointcloud:
return new() { _pointcloudConverter.Convert(pointcloud) };
return [DisplayValueResult.WithoutTransform(_pointcloudConverter.Convert(pointcloud))];
case DB.ModelCurve modelCurve:
return new() { GetCurveDisplayValue(modelCurve.GeometryCurve) };
return [DisplayValueResult.WithoutTransform(GetCurveDisplayValue(modelCurve.GeometryCurve))];
case DB.Grid grid:
return new() { GetCurveDisplayValue(grid.Curve) };
return [DisplayValueResult.WithoutTransform(GetCurveDisplayValue(grid.Curve))];
case DB.Area area:
List<Base> areaDisplay = new();
List<DisplayValueResult> areaDisplay = new();
using (var options = new DB.SpatialElementBoundaryOptions())
{
foreach (IList<DB.BoundarySegment> boundarySegmentGroup in area.GetBoundarySegments(options))
{
foreach (DB.BoundarySegment boundarySegment in boundarySegmentGroup)
{
areaDisplay.Add(GetCurveDisplayValue(boundarySegment.GetCurve()));
areaDisplay.Add(DisplayValueResult.WithoutTransform(GetCurveDisplayValue(boundarySegment.GetCurve())));
}
}
}
@@ -87,7 +89,7 @@ public sealed class DisplayValueExtractor
return wall.CurtainGrid is not null || wall.IsStackedWall ? new() : GetGeometryDisplayValue(element);
// railings should also include toprail which need to be retrieved separately
case DBA.Railing railing:
List<Base> railingDisplay = GetGeometryDisplayValue(railing);
List<DisplayValueResult> railingDisplay = GetGeometryDisplayValue(railing);
if (railing.TopRail != DB.ElementId.InvalidElementId)
{
var topRail = _converterSettings.Current.Document.GetElement(railing.TopRail);
@@ -105,10 +107,21 @@ public sealed class DisplayValueExtractor
private Base GetCurveDisplayValue(DB.Curve curve) => (Base)_curveConverter.Convert(curve);
private List<Base> GetGeometryDisplayValue(DB.Element element, DB.Options? options = null)
private List<DisplayValueResult> GetGeometryDisplayValue(DB.Element element, DB.Options? options = null)
{
var collections = GetSortedGeometryFromElement(element, options);
return ProcessGeometryCollections(element, collections);
using DB.Transform? localToDocument = GetTransform(element);
using DB.Transform? documentToLocal = localToDocument?.Inverse;
DB.Transform? documentToWorld = _converterSettings.Current.ReferencePointTransform?.Inverse;
using DB.Transform? compoundTransform =
localToDocument is not null && documentToWorld is not null
? documentToWorld.Multiply(localToDocument)
: localToDocument; // don't want to accidentally dispose of the ReferencePointTransform
DB.Transform? localToWorld = compoundTransform ?? documentToWorld;
var collections = GetSortedGeometryFromElement(element, options, documentToLocal);
return ProcessGeometryCollections(element, collections, localToWorld);
}
/// <summary>
@@ -119,7 +132,15 @@ public sealed class DisplayValueExtractor
/// Note: Some special element types (like Rebar) cannot use this method as their
/// get_Geometry() returns null, requiring specialized extraction methods.
/// </remarks>
private GeometryCollections GetSortedGeometryFromElement(DB.Element element, DB.Options? options)
/// <param name="element"></param>
/// <param name="options"></param>
/// <param name="worldToLocal"></param>
/// <returns></returns>
private GeometryCollections GetSortedGeometryFromElement(
DB.Element element,
DB.Options? options,
DB.Transform? worldToLocal
)
{
//options = ViewSpecificOptions ?? options ?? new Options() { DetailLevel = DetailLevelSetting };
options ??= new DB.Options { DetailLevel = _detailLevelMap[_converterSettings.Current.DetailLevel] };
@@ -142,7 +163,7 @@ public sealed class DisplayValueExtractor
if (geom != null && geom.Any())
{
// retrieves all meshes and solids from a geometry element
SortGeometry(element, collections, geom);
SortGeometry(element, collections, geom, worldToLocal);
}
return collections;
@@ -155,36 +176,83 @@ public sealed class DisplayValueExtractor
/// <remarks>
/// Essentially all the ensuing steps after the common get_Geometry element method
/// </remarks>
private List<Base> ProcessGeometryCollections(DB.Element element, GeometryCollections collections)
private List<DisplayValueResult> ProcessGeometryCollections(
DB.Element element,
GeometryCollections collections,
DB.Transform? localToWorld
)
{
List<Base> displayValue = new();
// handle all solids and meshes by their material
var meshesByMaterial = GetMeshesByMaterial(collections.Meshes, collections.Solids);
List<SOG.Mesh> displayMeshes = _meshByMaterialConverter.Convert(
(meshesByMaterial, element.Id, ShouldSetElementDisplayToTransparent(element))
);
displayValue.AddRange(displayMeshes);
// add rest of geometry
List<DisplayValueResult> displayValue = new(collections.TotalCount);
Matrix4x4? matrix = localToWorld is not null ? TransformToMatrix(localToWorld) : null;
foreach (SOG.Mesh mesh in displayMeshes)
{
displayValue.Add(
matrix.HasValue
? DisplayValueResult.WithTransform(mesh, matrix.Value)
: DisplayValueResult.WithoutTransform(mesh)
);
}
// add rest of geometry (always without transform)
foreach (var curve in collections.Curves)
{
displayValue.Add(GetCurveDisplayValue(curve));
displayValue.Add(DisplayValueResult.WithoutTransform(GetCurveDisplayValue(curve)));
}
foreach (var polyline in collections.Polylines)
{
displayValue.Add(_polylineConverter.Convert(polyline));
displayValue.Add(DisplayValueResult.WithoutTransform(_polylineConverter.Convert(polyline)));
}
foreach (var point in collections.Points)
{
displayValue.Add(_pointConverter.Convert(point));
displayValue.Add(DisplayValueResult.WithoutTransform(_pointConverter.Convert(point)));
}
return displayValue;
}
private Matrix4x4 TransformToMatrix(DB.Transform transform) =>
new()
{
M11 = transform.BasisX.X,
M21 = transform.BasisX.Y,
M31 = transform.BasisX.Z,
M41 = 0,
M12 = transform.BasisY.X,
M22 = transform.BasisY.Y,
M32 = transform.BasisY.Z,
M42 = 0,
M13 = transform.BasisZ.X,
M23 = transform.BasisZ.Y,
M33 = transform.BasisZ.Z,
M43 = 0,
M14 = _toSpeckleScalingService.ScaleLength(transform.Origin.X),
M24 = _toSpeckleScalingService.ScaleLength(transform.Origin.Y),
M34 = _toSpeckleScalingService.ScaleLength(transform.Origin.Z),
M44 = 1
};
private static DB.Transform? GetTransform(DB.Element element)
{
if (element is DB.Instance i)
{
return i.GetTotalTransform();
}
return null;
}
private static Dictionary<DB.ElementId, List<DB.Mesh>> GetMeshesByMaterial(
List<DB.Mesh> meshes,
List<DB.Solid> solids
@@ -249,7 +317,12 @@ public sealed class DisplayValueExtractor
///
/// Note: this is basically a geometry unpacker for all types of geometry
/// </summary>
private void SortGeometry(DB.Element element, GeometryCollections collections, DB.GeometryElement geom)
private void SortGeometry(
DB.Element element,
GeometryCollections collections,
DB.GeometryElement geom,
DB.Transform? worldToLocal
)
{
foreach (DB.GeometryObject geomObj in geom)
{
@@ -267,13 +340,22 @@ public sealed class DisplayValueExtractor
continue;
}
if (worldToLocal is not null)
{
solid = DB.SolidUtils.CreateTransformed(solid, worldToLocal);
}
collections.Solids.Add(solid);
break;
case DB.Mesh mesh:
if (worldToLocal is not null)
{
mesh = mesh.get_Transformed(worldToLocal);
}
collections.Meshes.Add(mesh);
break;
//Note, we're not applying transforms to curves/polylines/points because ProcessGeometryCollections expects them in world coordinates
case DB.Curve curve:
collections.Curves.Add(curve);
break;
@@ -288,12 +370,19 @@ public sealed class DisplayValueExtractor
case DB.GeometryInstance instance:
// element transforms should not be carried down into nested geometryInstances.
// Nested geomInstances should have their geom retreived with GetInstanceGeom, not GetSymbolGeom
SortGeometry(element, collections, instance.GetInstanceGeometry());
// Nested geomInstances should have their geom retrieved with GetInstanceGeom, not GetSymbolGeom
if (worldToLocal == null) //see remark on method for why this is safe to do...
{
SortGeometry(element, collections, instance.GetInstanceGeometry(), null);
}
else
{
SortGeometry(element, collections, instance.GetSymbolGeometry(), null);
}
break;
case DB.GeometryElement geometryElement:
SortGeometry(element, collections, geometryElement);
SortGeometry(element, collections, geometryElement, null);
break;
}
}
@@ -424,25 +513,23 @@ public sealed class DisplayValueExtractor
/// Instead, we use GetFullGeometryForView() to obtain the geometry and then process it
/// using the standard geometry sorting and conversion.
/// </remarks>
private List<Base> GetRebarVolumetricDisplayValue(DB.Structure.Rebar rebar)
private List<DisplayValueResult> GetRebarVolumetricDisplayValue(DB.Structure.Rebar rebar)
{
var collections = new GeometryCollections();
// Regular get_Geometry() returns null for rebar, so we need to use GetFullGeometryForView
// ❗NOTE: view detail level needs to be fine in order for this to work
// Same behaviour as sending structural frame though - consistent and therefore okay.
DB.GeometryElement geometryElements = rebar.GetFullGeometryForView(_converterSettings.Current.Document.ActiveView);
SortGeometry(rebar, collections, geometryElements);
DB.GeometryElement? geometryElements = rebar.GetFullGeometryForView(_converterSettings.Current.Document.ActiveView);
if (geometryElements != null)
{
SortGeometry(rebar, collections, geometryElements);
return ProcessGeometryCollections(rebar, collections);
SortGeometry(rebar, collections, geometryElements, null);
return ProcessGeometryCollections(rebar, collections, null);
}
// Return empty list if no geometry is found - imo not critical
return new List<Base>();
return new List<DisplayValueResult>();
}
/// <summary>
@@ -451,7 +538,7 @@ public sealed class DisplayValueExtractor
/// <remarks>
/// This method extracts the centerlines of rebar elements when a simplified representation is preferred.
/// </remarks>
private List<Base> GetRebarCenterlineDisplayValue(DB.Structure.Rebar rebar)
private List<DisplayValueResult> GetRebarCenterlineDisplayValue(DB.Structure.Rebar rebar)
{
bool isSingleLayout = rebar.LayoutRule == DB.Structure.RebarLayoutRule.Single;
int numberOfBarPositions = rebar.NumberOfBarPositions;
@@ -480,10 +567,10 @@ public sealed class DisplayValueExtractor
);
}
List<Base> displayValue = new();
List<DisplayValueResult> displayValue = new();
foreach (var curve in curves)
{
displayValue.Add(GetCurveDisplayValue(curve));
displayValue.Add(DisplayValueResult.WithoutTransform(GetCurveDisplayValue(curve)));
}
return displayValue;
@@ -494,6 +581,10 @@ public sealed class DisplayValueExtractor
/// Used to pass multiple geometry collections as a single parameter to improve code readability
/// and reduce the risk of parameter ordering errors.
/// </summary>
/// <remarks>
/// <see cref="Solids"/> and <see cref="Meshes"/> potentially in local coordinate space.
/// For now, <see cref="Curves"/>, <see cref="Polylines"/>, <see cref="Points"/> will always be in world space
/// </remarks>
private sealed record GeometryCollections
{
public List<DB.Solid> Solids { get; } = new();
@@ -501,5 +592,7 @@ public sealed class DisplayValueExtractor
public List<DB.Curve> Curves { get; } = new();
public List<DB.PolyLine> Polylines { get; } = new();
public List<DB.Point> Points { get; } = new();
public int TotalCount => Solids.Count + Meshes.Count + Curves.Count + Polylines.Count + Points.Count;
}
}
@@ -18,23 +18,74 @@ public sealed class LevelExtractor
return level.Name;
}
/// <summary>
/// Gets the level associated with an element. Handles face-based family instances and hosted elements.
/// </summary>
public DB.Level? GetLevel(DB.Element element)
{
// get level, if any
DB.ElementId? levelId = null;
// try direct LevelId first
if (element.LevelId != DB.ElementId.InvalidElementId)
{
if (_levelCache.TryGetValue(element.LevelId, out DB.Level? cachedLevel))
{
return cachedLevel;
}
levelId = element.LevelId;
}
// otherwise try FamilyInstance-specific sources
else if (element is DB.FamilyInstance familyInstance)
{
levelId = TryGetFamilyInstanceLevelId(familyInstance);
if (element.Document.GetElement(element.LevelId) is DB.Level level)
// couldn't find a direct level ID - recurse to host
if (levelId == null && familyInstance.Host != null)
{
_levelCache[element.LevelId] = level;
return level;
return GetLevel(familyInstance.Host);
}
}
// okay, no valid LevelId found and we've tried A LOT!
if (levelId == null || levelId == DB.ElementId.InvalidElementId)
{
return null;
}
// check if cache has seen this Level before
if (_levelCache.TryGetValue(levelId, out DB.Level? cached))
{
return cached;
}
// add to the cache if firs occurence of this level
if (element.Document.GetElement(levelId) is DB.Level level)
{
_levelCache[levelId] = level;
return level;
}
return null;
}
/// <summary>
/// Tries to get a level ID from a FamilyInstance via parameter or host.
/// Face-based instances store their level in INSTANCE_SCHEDULE_ONLY_LEVEL_PARAM.
/// </summary>
/// <remarks>
/// See: https://forums.autodesk.com/t5/revit-api-forum/newfamilyinstance-not-setting-level-of-family-instance/td-p/11405934
/// </remarks>
private DB.ElementId? TryGetFamilyInstanceLevelId(DB.FamilyInstance familyInstance)
{
// try parameter-based level first (face-based families)
var levelId = familyInstance.get_Parameter(DB.BuiltInParameter.INSTANCE_SCHEDULE_ONLY_LEVEL_PARAM)?.AsElementId();
if (levelId != null && levelId != DB.ElementId.InvalidElementId)
{
return levelId;
}
// try host if it's directly a level
if (familyInstance.Host is DB.Level hostLevel)
{
return hostLevel.Id;
}
return null;
}
}
@@ -1,4 +1,5 @@
using Autodesk.Revit.DB;
using Speckle.DoubleNumerics;
namespace Speckle.Converters.RevitShared.Helpers;
@@ -43,6 +44,30 @@ public static class ReferencePointHelper
};
}
public static Matrix4x4 TransformToMatrix(Transform transform) =>
new()
{
M11 = transform.BasisX.X,
M21 = transform.BasisX.Y,
M31 = transform.BasisX.Z,
M41 = 0,
M12 = transform.BasisY.X,
M22 = transform.BasisY.Y,
M32 = transform.BasisY.Z,
M42 = 0,
M13 = transform.BasisZ.X,
M23 = transform.BasisZ.Y,
M33 = transform.BasisZ.Z,
M43 = 0,
M14 = transform.Origin.X,
M24 = transform.Origin.Y,
M34 = transform.Origin.Z,
M44 = 1
};
/// <summary>
/// Extracts and reconstructs a transform from the matrix data stored on root object
/// </summary>
@@ -1,4 +1,9 @@
using Microsoft.Extensions.Logging;
using Speckle.Converters.Common.ToSpeckle;
using Speckle.Objects.Other;
using Speckle.Sdk.Common;
using Speckle.Sdk.Models;
using Speckle.Sdk.Models.Instances;
namespace Speckle.Converters.RevitShared.Helpers;
@@ -11,7 +16,7 @@ namespace Speckle.Converters.RevitShared.Helpers;
/// Ask dim for more and he might start crying.
/// </para>
/// </summary>
public class RevitToSpeckleCacheSingleton
public class RevitToSpeckleCacheSingleton(ILogger<RevitToSpeckleCacheSingleton> logger)
{
/// <summary>
/// (DB.Material id, RenderMaterial). This can be generated from converting render materials or material quantities.
@@ -24,11 +29,31 @@ public class RevitToSpeckleCacheSingleton
/// </summary>
public Dictionary<string, Dictionary<string, RenderMaterialProxy>> ObjectRenderMaterialProxiesMap { get; } = new();
public Dictionary<
string,
(List<string> elementIds, InstanceDefinitionProxy definitionProxy)
> InstanceDefinitionProxiesMap { get; } = new();
public Dictionary<string, (List<string> elementIds, Base baseObj)> InstancedObjects { get; } = new();
/// <summary>
/// Returns the merged material proxy list for the given object ids. Use this to get post conversion a correct list of material proxies for setting on the root commit object.
/// Maps mesh application IDs to their material IDs for later proxy population.
/// Dictionary: elementId -> (meshAppId -> materialId)
/// </summary>
/// <param name="elementIds"></param>
/// <returns></returns>
public Dictionary<string, Dictionary<string, string>> MeshToMaterialMap { get; } = new();
/// <summary>
/// Returns the merged material proxy list for the given object IDs.
/// Use this post-conversion to get a correct list of material proxies for the root commit object.
/// </summary>
/// <returns>A deduplicated list of <see cref="RenderMaterialProxy"/> objects for all specified elements.</returns>
/// <remarks>
/// <para>
/// Material proxy objects lists should already be correctly populated at this point (with definition mesh IDs for instances
/// and individual mesh IDs for non-instances), so the merging primarily handles cross-element scenarios rather than
/// fixing incorrect data.
/// </para>
/// </remarks>
public List<RenderMaterialProxy> GetRenderMaterialProxyListForObjects(List<string> elementIds)
{
var proxiesToMerge = ObjectRenderMaterialProxiesMap
@@ -42,17 +67,134 @@ public class RevitToSpeckleCacheSingleton
{
if (!mergeTarget.TryGetValue(kvp.Key, out RenderMaterialProxy? value))
{
value = kvp.Value;
mergeTarget[kvp.Key] = value;
continue;
// first time seeing this material - add it
mergeTarget[kvp.Key] = kvp.Value;
}
else
{
// merge objects lists (should already be mostly correct now)
value.objects.AddRange(kvp.Value.objects);
}
value.objects.AddRange(kvp.Value.objects);
}
}
// final deduplication (should be minimal now)
foreach (var renderMaterialProxy in mergeTarget.Values)
{
renderMaterialProxy.objects = renderMaterialProxy.objects.Distinct().ToList();
}
return mergeTarget.Values.ToList();
}
/// <summary>
/// Gets instance definition proxies from session cache for the given element ids.
/// This is necessary because send caching only check against DB.Element since it is the managed object in Revit UI.
/// We need to filter already existant definition proxies from cache with their element id relationship.
/// Otherwise, we will end up with incomplete data in root.
/// </summary>
/// <param name="elementIds">Ids to get corresponding definition proxies that cached before.</param>
public List<InstanceDefinitionProxy> GetInstanceDefinitionProxiesForObjects(List<string> elementIds) =>
InstanceDefinitionProxiesMap
.Values.Where(v => v.elementIds.Any(id => elementIds.Contains(id)))
.Select(v => v.definitionProxy)
.ToList();
/// <summary>
/// Gets atomic objects (Base) that extracted out from display value of RevitDataObject.
/// We need to filter already existant atomic objects from cache with their element id relationship.
/// Otherwise, we will end up with incomplete data in root.
/// </summary>
/// <param name="elementIds">Element ids to get corresponding atomic objects (Base) that cached before.</param>
/// <returns></returns>
public List<Base> GetBaseObjectsForObjects(List<string> elementIds) =>
InstancedObjects.Values.Where(v => v.elementIds.Any(id => elementIds.Contains(id))).Select(v => v.baseObj).ToList();
/// <summary>
/// Adds a mesh ID to the appropriate material proxy.
/// For instances: adds the definition mesh ID.
/// For non-instances: adds the mesh's own ID.
/// </summary>
/// <remarks>
/// Cache navigation logic is encapsulated here. Failures are logged but do not throw exceptions,
/// allowing conversion to continue even if material assignment fails.
/// </remarks>
public void AddMeshToMaterialProxy(string elementId, SOG.Mesh mesh, bool isInstance)
{
// get mesh-to-material mapping
if (!MeshToMaterialMap.TryGetValue(elementId, out var meshMatMap))
{
logger.LogWarning("No mesh-to-material mapping found for element {ElementId}", elementId);
return;
}
// get material ID for this mesh
if (!meshMatMap.TryGetValue(mesh.applicationId.NotNull(), out var materialId))
{
logger.LogError(
"Cache inconsistency: Mesh {MeshId} not found in material mapping for element {ElementId}",
mesh.applicationId,
elementId
);
return;
}
// get material proxy map
if (!ObjectRenderMaterialProxiesMap.TryGetValue(elementId, out var proxyMap))
{
logger.LogError("Cache inconsistency: Material proxy map not found for element {ElementId}", elementId);
return;
}
// get specific material proxy
if (!proxyMap.TryGetValue(materialId, out var materialProxy))
{
if (materialId != DB.ElementId.InvalidElementId.ToString())
{
logger.LogError(
"Cache inconsistency: Material proxy not found for material {MaterialId} in element {ElementId}",
materialId,
elementId
);
}
return;
}
// determine which mesh ID to add
string meshIdToAdd;
if (isInstance)
{
var instanceDefinitionId = MeshInstanceIdGenerator.GenerateUntransformedMeshId(mesh);
if (!InstancedObjects.TryGetValue(instanceDefinitionId, out var instancedObject))
{
throw new InvalidOperationException(
$"Instance definition '{instanceDefinitionId}' not found in cache for mesh '{mesh.applicationId}'"
);
}
meshIdToAdd = instancedObject.baseObj.applicationId.NotNull();
}
else
{
meshIdToAdd = mesh.applicationId.NotNull();
}
// add to proxy if not already present
if (!materialProxy.objects.Contains(meshIdToAdd))
{
materialProxy.objects.Add(meshIdToAdd);
}
}
public void ClearCache()
{
ObjectRenderMaterialProxiesMap.Clear();
SpeckleRenderMaterialCache.Clear();
InstanceDefinitionProxiesMap.Clear();
InstancedObjects.Clear();
MeshToMaterialMap.Clear();
}
}
@@ -2,7 +2,6 @@ using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Speckle.Converters.Common;
using Speckle.Converters.Common.Registration;
using Speckle.Converters.Revit2023.ToSpeckle.Properties;
using Speckle.Converters.RevitShared.Helpers;
using Speckle.Converters.RevitShared.Services;
using Speckle.Converters.RevitShared.Settings;
@@ -19,7 +18,8 @@ public static class ServiceRegistration
var converterAssembly = Assembly.GetExecutingAssembly();
//register types by default
serviceCollection.AddMatchingInterfacesAsTransient(converterAssembly);
// Register single root
// register single root
serviceCollection.AddRootCommon<RevitRootToSpeckleConverter>(converterAssembly);
// register all application converters
@@ -29,7 +29,7 @@ public static class ServiceRegistration
serviceCollection.AddSingleton(new RevitContext());
serviceCollection.AddSingleton(new RevitToHostCacheSingleton());
serviceCollection.AddSingleton(new RevitToSpeckleCacheSingleton());
serviceCollection.AddSingleton<RevitToSpeckleCacheSingleton>();
// POC: do we need ToSpeckleScalingService as is, do we need to interface it out?
serviceCollection.AddScoped<ScalingServiceToSpeckle>();
@@ -73,6 +73,28 @@ public class ClassPropertiesExtractor
elementProperties.Add("worksetName", worksetName);
}
if (element is DB.FamilyInstance familyInstance)
{
try
{
// get room id if applicable (only for FamilyInstance elements)
if (familyInstance.Room is not null)
{
elementProperties.Add("roomId", familyInstance.Room.Id.ToString());
}
// get space id if applicable (only for FamilyInstance elements)
if (familyInstance.Space is not null)
{
elementProperties.Add("spaceId", familyInstance.Space.Id.ToString());
}
}
catch (Exception e) when (!e.IsFatal())
{
// silently ignore - not critical
}
}
// get group name if applicable
// TODO: in in group proxies separate issue. Below comments from PR #1081
// We're using group proxies in Rhino etc. Groups should be handled similarly in Revit, unless there's a good
@@ -4,7 +4,7 @@ using Speckle.Converters.RevitShared.Settings;
using Speckle.Sdk;
using Speckle.Sdk.Common;
namespace Speckle.Converters.Revit2023.ToSpeckle.Properties;
namespace Speckle.Converters.RevitShared.ToSpeckle.Properties;
public readonly struct StructuralAssetProperties(
string name,
@@ -43,16 +43,22 @@ public class MeshByMaterialDictionaryToSpeckle
/// <summary>
/// Converts a dictionary of Revit meshes, where key is MaterialId, into a list of Speckle meshes.
/// </summary>
/// <param name="args">A tuple consisting of (1) a dictionary with DB.ElementId keys and List of DB.Mesh values and (2) the root element id (the one generating all the meshes).</param>
/// <returns>
/// Returns a list of <see cref="SOG.Mesh"/> objects where each mesh represents one unique material in the input dictionary.
/// </returns>
/// <remarks>
/// Be aware that this method internally creates a new instance of <see cref="SOG.Mesh"/> for each unique material in the input dictionary.
/// These meshes are created with an initial capacity based on the size of the vertex and face arrays to avoid unnecessary resizing.
/// Also note that, for each unique material, the method tries to retrieve the related DB.Material from the current document and convert it. If the conversion is successful,
/// the material is added to the corresponding Speckle mesh. If the conversion fails, the operation simply continues without the material.
/// TODO: update description
/// <para>
/// This method creates a new instance of <see cref="SOG.Mesh"/> for each unique material in the input dictionary.
/// </para>
/// <para>
/// For each unique material, the method retrieves the related DB.Material from the current document and converts it to a <see cref="RenderMaterial"/>.
/// Material proxies are created but their objects lists are NOT populated at this stage. The mesh-to-material relationship is stored
/// in <see cref="RevitToSpeckleCacheSingleton.MeshToMaterialMap"/> for later population during display value processing.
/// </para>
/// <para>
/// Deferred population of the object list to ensure that instance geometry references the definition mesh ID in material proxies,
/// rather than individual instance mesh IDs. We can only do this later, because proxification hasn't happened yet.
/// </para>
/// </remarks>
public List<SOG.Mesh> Convert(
(Dictionary<DB.ElementId, List<DB.Mesh>> target, DB.ElementId parentElementId, bool makeTransparent) args
@@ -62,7 +68,7 @@ public class MeshByMaterialDictionaryToSpeckle
var objectRenderMaterialProxiesMap = _revitToSpeckleCacheSingleton.ObjectRenderMaterialProxiesMap;
var materialProxyMap = new Dictionary<string, RenderMaterialProxy>();
var key = args.parentElementId.ToString().NotNull();
// ids are same in copy pasted linked models, otherwise we reset the materialProxyMap in cache and only one of the linked model is having the render materials
if (objectRenderMaterialProxiesMap.TryGetValue(key, out var cachedMaterialProxy))
{
materialProxyMap = cachedMaterialProxy;
@@ -85,32 +91,37 @@ public class MeshByMaterialDictionaryToSpeckle
: materialId.ToString().NotNull();
List<DB.Mesh> meshes = keyValuePair.Value;
// use the meshlist converter to convert the mesh values into a single speckle mesh
SOG.Mesh speckleMesh = _meshListConverter.Convert(meshes);
speckleMesh.applicationId = Guid.NewGuid().ToString(); // NOTE: as we are composing meshes out of multiple ones for the same material, we need to generate our own application id. c'est la vie.
speckleMesh.applicationId = Guid.NewGuid().ToString();
// store mesh-to-material mapping
if (!_revitToSpeckleCacheSingleton.MeshToMaterialMap.TryGetValue(key, out var meshMatMap))
{
meshMatMap = new Dictionary<string, string>();
_revitToSpeckleCacheSingleton.MeshToMaterialMap[key] = meshMatMap;
}
meshMatMap[speckleMesh.applicationId.NotNull()] = materialIdString;
// get the speckle render material
RenderMaterial? renderMaterial = args.makeTransparent
? _transparentMaterial
: _converterSettings.Current.Document.GetElement(materialId) is DB.Material material
? _speckleRenderMaterialConverter.Convert(material)
: null;
// get the render material if any
// Create proxy but DON'T populate objects list yet
if (renderMaterial is not null)
{
if (!materialProxyMap.TryGetValue(materialIdString, out RenderMaterialProxy? renderMaterialProxy))
if (!materialProxyMap.ContainsKey(materialIdString))
{
renderMaterialProxy = new RenderMaterialProxy()
{
value = renderMaterial,
applicationId = materialId.ToString(),
objects = []
};
RenderMaterialProxy? renderMaterialProxy =
new()
{
value = renderMaterial,
applicationId = materialId.ToString(),
objects = []
};
materialProxyMap[materialIdString] = renderMaterialProxy;
}
renderMaterialProxy.objects.Add(speckleMesh.applicationId);
}
result.Add(speckleMesh);
@@ -36,12 +36,9 @@ public class MeshListConversionToSpeckle : ITypedConverter<List<DB.Mesh>, SOG.Me
foreach (DB.XYZ vert in mesh.Vertices)
{
// We need this method to take into account reference point transforms
DB.XYZ extVert = _referencePointConverter.ConvertToExternalCoordinates(vert, true);
vertices.Add(_toSpeckleScalingService.ScaleLength(extVert.X));
vertices.Add(_toSpeckleScalingService.ScaleLength(extVert.Y));
vertices.Add(_toSpeckleScalingService.ScaleLength(extVert.Z));
vertices.Add(_toSpeckleScalingService.ScaleLength(vert.X));
vertices.Add(_toSpeckleScalingService.ScaleLength(vert.Y));
vertices.Add(_toSpeckleScalingService.ScaleLength(vert.Z));
}
for (int i = 0; i < mesh.NumTriangles; i++)
@@ -1,8 +1,8 @@
using Speckle.Converters.Common;
using Speckle.Converters.Common.Objects;
using Speckle.Converters.Revit2023.ToSpeckle.Properties;
using Speckle.Converters.RevitShared.Services;
using Speckle.Converters.RevitShared.Settings;
using Speckle.Converters.RevitShared.ToSpeckle.Properties;
using Speckle.Sdk.Common.Exceptions;
using ApplicationException = Autodesk.Revit.Exceptions.ApplicationException;
@@ -1,12 +1,16 @@
using Speckle.Converters.Common;
using Speckle.Converters.Common.Objects;
using Speckle.Converters.Common.ToSpeckle;
using Speckle.Converters.RevitShared.Extensions;
using Speckle.Converters.RevitShared.Helpers;
using Speckle.Converters.RevitShared.Settings;
using Speckle.Converters.RevitShared.ToSpeckle.Properties;
using Speckle.DoubleNumerics;
using Speckle.Objects.Data;
using Speckle.Sdk.Common;
using Speckle.Sdk.Common.Exceptions;
using Speckle.Sdk.Models;
using Speckle.Sdk.Models.Instances;
namespace Speckle.Converters.RevitShared.ToSpeckle;
@@ -18,9 +22,11 @@ public class ElementTopLevelConverterToSpeckle : IToSpeckleTopLevelConverter
private readonly ITypedConverter<DB.Location, Base> _locationConverter;
private readonly LevelExtractor _levelExtractor;
private readonly IConverterSettingsStore<RevitConversionSettings> _converterSettings;
private readonly RevitToSpeckleCacheSingleton _revitToSpeckleCacheSingleton;
public ElementTopLevelConverterToSpeckle(
DisplayValueExtractor displayValueExtractor,
RevitToSpeckleCacheSingleton revitToSpeckleCacheSingleton,
PropertiesExtractor propertiesExtractor,
LevelExtractor levelExtractor,
ITypedConverter<DB.Location, Base> locationConverter,
@@ -28,6 +34,7 @@ public class ElementTopLevelConverterToSpeckle : IToSpeckleTopLevelConverter
)
{
_displayValueExtractor = displayValueExtractor;
_revitToSpeckleCacheSingleton = revitToSpeckleCacheSingleton;
_propertiesExtractor = propertiesExtractor;
_levelExtractor = levelExtractor;
_locationConverter = locationConverter;
@@ -36,7 +43,7 @@ public class ElementTopLevelConverterToSpeckle : IToSpeckleTopLevelConverter
public Base Convert(object target) => Convert((DB.Element)target);
public RevitObject Convert(DB.Element target)
private RevitObject Convert(DB.Element target)
{
string category = target.Category?.Name ?? "none";
@@ -95,7 +102,10 @@ public class ElementTopLevelConverterToSpeckle : IToSpeckleTopLevelConverter
}
// get the display value
List<Base> displayValue = _displayValueExtractor.GetDisplayValue(target);
List<DisplayValueResult> displayValuesWithTransforms = _displayValueExtractor.GetDisplayValue(target);
// process display values and create instance proxies where applicable
List<Base> proxifiedDisplayValues = ProcessDisplayValues(target.Id.ToString(), displayValuesWithTransforms);
// get level
string? level = _levelExtractor.GetLevelName(target);
@@ -117,7 +127,7 @@ public class ElementTopLevelConverterToSpeckle : IToSpeckleTopLevelConverter
category = category,
location = convertedLocation,
elements = children,
displayValue = displayValue.Cast<Base>().ToList(),
displayValue = proxifiedDisplayValues,
properties = properties,
units = _converterSettings.Current.SpeckleUnits
};
@@ -184,4 +194,128 @@ public class ElementTopLevelConverterToSpeckle : IToSpeckleTopLevelConverter
yield return Convert(_converterSettings.Current.Document.GetElement(childId));
}
}
/// <summary>
/// Processes display values with transforms and creates instance proxies for meshes that can be instanced.
/// Also populates material proxy objects lists with the appropriate mesh IDs based on whether geometry is instanced or not.
/// </summary>
/// <returns>
/// List of processed display values, with meshes replaced by instance proxies where applicable.
/// Non-instance geometry is returned as-is.
/// </returns>
/// <remarks>
/// <para>
/// This is a bit of a code smell. This method is doing to much, "this ... AND this...".
/// </para>
/// <para>
/// But, given a mesh:
/// - if it has a transform, mesh is converted to instance proxy, and the definition mesh ID is added to material proxies
/// - if it doesn't have a transform, it remains as a regular mesh, and its own ID is added to material proxies
/// - other geometry types pass through unchanged
/// </para>
/// <para>
/// This is where material proxy population occurs (deferred from <see cref="MeshByMaterialDictionaryToSpeckle.Convert"/>)
/// to ensure we use definition mesh IDs for instances rather than individual instance mesh IDs.
/// </para>
/// </remarks>
private List<Base> ProcessDisplayValues(string elementId, List<DisplayValueResult> displayValues)
{
List<Base> proxifiedDisplayValues = new();
foreach (var displayValue in displayValues)
{
// check if this is a mesh with a transform - potential instance scenario
if (displayValue.Geometry is SOG.Mesh mesh && displayValue.Transform is not null)
{
var instanceProxy = CreateOrGetInstanceProxy(elementId, mesh, displayValue.Transform.Value);
proxifiedDisplayValues.Add(instanceProxy);
// add the definition mesh ID to material proxy, not the instance mesh
// method technically is a "Try" but logs internally, so we don't have a return to check
_revitToSpeckleCacheSingleton.AddMeshToMaterialProxy(elementId, mesh, isInstance: true);
}
else if (displayValue.Geometry is SOG.Mesh nonInstanceMesh)
{
// non-instance mesh - add its own ID to material proxy
// method technically is a "Try" but logs internally, so we don't have a return to check
_revitToSpeckleCacheSingleton.AddMeshToMaterialProxy(elementId, nonInstanceMesh, isInstance: false);
proxifiedDisplayValues.Add(nonInstanceMesh);
}
else
{
proxifiedDisplayValues.Add(displayValue.Geometry);
}
}
return proxifiedDisplayValues;
}
/// <summary>
/// Creates or retrieves an instance proxy for a mesh, managing instance definitions and caching.
/// </summary>
/// <remarks>
/// <para>
/// This method generates a deterministic instance definition ID based on the untransformed mesh geometry using
/// <see cref="MeshInstanceIdGenerator.GenerateUntransformedMeshId"/>. Multiple instances with identical geometry
/// will share the same definition.
/// </para>
/// <para>
/// The method manages two caches:
/// - <see cref="RevitToSpeckleCacheSingleton.InstanceDefinitionProxiesMap"/>: Tracks instance definitions and which elements use them
/// - <see cref="RevitToSpeckleCacheSingleton.InstancedObjects"/>: Stores the actual definition meshes for later serialization
/// </para>
/// </remarks>
private InstanceProxy CreateOrGetInstanceProxy(string elementId, SOG.Mesh mesh, Matrix4x4 transform)
{
var instanceDefinitionId = MeshInstanceIdGenerator.GenerateUntransformedMeshId(mesh);
// We need to attach element id relationship to proxy singleton for send caching.
// Send caching skips whole DB.Element that turn into RevitDataObject. since we have instance proxies in RevitDataObject but
// its definitions outside of caching mechanism, this elementId helps us to filter which definition proxies should be attached to the root
if (
_revitToSpeckleCacheSingleton.InstanceDefinitionProxiesMap.TryGetValue(
instanceDefinitionId,
out var instanceDefinition
)
)
{
instanceDefinition.elementIds.Add(elementId);
}
else
{
var newInstanceDefinition = new InstanceDefinitionProxy
{
applicationId = instanceDefinitionId,
objects = new List<string> { mesh.applicationId.NotNull() },
maxDepth = 0,
name = instanceDefinitionId,
};
_revitToSpeckleCacheSingleton.InstanceDefinitionProxiesMap.Add(
instanceDefinitionId,
([elementId], newInstanceDefinition)
);
}
// some comment valid here as above if statement, since we store original meshes outside of RevitDataObject, we need to know which of them will be attached.
if (_revitToSpeckleCacheSingleton.InstancedObjects.TryGetValue(instanceDefinitionId, out var instancedObject))
{
instancedObject.elementIds.Add(elementId);
}
else
{
_revitToSpeckleCacheSingleton.InstancedObjects.Add(instanceDefinitionId, ([elementId], mesh));
}
// create and return instance proxy with transform
var instanceProxy = new InstanceProxy
{
applicationId = Guid.NewGuid().ToString(),
definitionId = instanceDefinitionId,
transform = transform,
maxDepth = 0,
units = mesh.units
};
return instanceProxy;
}
}
+1 -1
View File
@@ -16,7 +16,7 @@
<PackageVersion Include="LibTessDotNet" Version="1.1.15" />
<PackageVersion Include="Microsoft.VisualStudio.SolutionPersistence" Version="1.0.52" />
<PackageVersion Include="Moq" Version="4.20.70" />
<PackageVersion Include="Microsoft.Build" Version="17.11.4" />
<PackageVersion Include="Microsoft.Build" Version="17.11.48" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageVersion Include="Npgsql" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.9" />
@@ -0,0 +1,77 @@
# Intro
The rhino importer is functionally made from two parts:
- The `Speckle.Importers.JobProcessor`: A windows background service, designed to always be running.
- The `Speckle.Importers.Rhino`: A commandline app triggered by the JobProcessor to handle a single job.
They are deployed as a single product, and share the Innosetup installer deployment system of connectors.
> Note: this is documentation for internal purposes only.
> Despite much of the code being open-source, we do not offer any support or documentation for self hosters.
# First Time Setup and Checking For Updates
On the production/staging Windows servers, you'll find a powershell script `InstallUpdate.ps1` that
does a couple of things:
- Ensures the latest version of the importer is installed
- Defines the Postgres connection string, and how many service instances to stop/start (right now, just the 1 for prod)
`InstallUpdate.ps1` can be run to both check for updates and install updates, and to perform fresh installs and configuring the service(s)
The template for that script can be found [here](https://github.com/specklesystems/gitOps/blob/main/terraform/modules/windows-machine/templates/autoupdater/InstallUpdate.ps1.tpl) but needs to be tweaked per deployment.
There's lot more to do with the windows infra/rhino licence config that's not covered in this readme document.
# The Windows Service
You can view it from the (search "services" in search), It should be running at all times.
and can be stopped/started manually from the "services" menu
It does not display a terminal, the best place to see logs is in our seq-dev instance.
You can view error/crashes either via
1. Event viewer - there is already a "Custom View" called "Speckle Rhino Job Processor"
2. Reliability Monitor - Shows historical crash histogram
Note, the `Speckle.Importers.Rhino` crashes a lot due to Rhino.Inside behaviour, it's handled
crashes of the `Speckle.Importers.JobProcessor` are more serious.
If you need to troubleshoot, it is still possible to manually start the job processor exe as a command line app, rather than as a window service
this will give you back a terminal with all logs. But this is probably better done on the staging server...
Because it's a Windows Service, it will start up with the Windows OS.
If the process crashes (e.g. because it could not connect to the server/db)
it will automatically be restarted, with a backoff policy defined in the `InstallUpdate.ps1` script
If it's in a weird state where you need to manually restart it, this can be done from Services/task manager. Or simply by re-starting the machine.
# Tricking the InstallUpdate script into running even when there's no updates
If you want to make a change to the env vars of the service, or change the setup of the services in some way, it can be useful to run the script
without there actually being an update.
The easiest way to do this is to remove the registry key in `HKEY_CURRENT_USER\Software\Speckle\Services\InstalledConnectors`
That way, the script assumes that there is an update when ran.
# Debugging / Running Against a Local Server
If you have a local Speckle server and want to test processing Rhino jobs.
Firstly, there's a few things on the server you'll need to configure.
I'd recommend using the docker-compose files in the root of the server repo.
On speckle-frontend-2 set the envvar:
```yaml
NUXT_PUBLIC_FF_RHINO_FILE_IMPORTER_ENABLED: 'true'
```
On speckle-server set the envvars:
```yaml
FILEIMPORT_SERVICE_USE_PRIVATE_OBJECTS_SERVER_URL: 'false'
FF_RHINO_FILE_IMPORTER_ENABLED: 'true'
FILE_IMPORT_TIME_LIMIT_MIN: 30
```
Next, you can run the `Speckle.Importers.JobProcessor: Local Docker DB` launch configuration straight from your IDE.
The `launchSettings.json` should already be setup with the correct connection string.
It will run as normal CLI app, not a Windows Service, but aside from minor logging differences, this will work the same.
N.b. you will find you are not able to easily debug the `Speckle.Importers.Rhino` project like this, because it's a spawned sub process.
You have two options.
1. Capture the command line args and call it manually from your IDE, bypassing the JobProcessor.
2. Add a `Thread.Sleep(10000)` near the start of the entry point of the Rhino importer, and during the sleep time, attach your IDE to a running process.
@@ -7,27 +7,19 @@ namespace Speckle.Importers.Rhino.Internal.FileTypeConfig;
/// Creates a headless doc and imports the file
/// </summary>
/// <remarks>
/// Note: imported geometry will be converted to the default <c>mm</c> units
/// If we need to preserve the file units, a custom config needs to be created
/// Note: using OpenHeadless should preserve the original file's unit system for file types that have units
/// </remarks>
public sealed class DefaultConfig : IFileTypeConfig
internal sealed class DefaultConfig : IFileTypeConfig
{
public RhinoDoc OpenInHeadlessDocument(string filePath)
{
var doc = RhinoDoc.CreateHeadless(null);
try
RhinoDoc? doc = RhinoDoc.OpenHeadless(filePath, null);
if (doc is null)
{
if (!doc.Import(filePath, null))
{
throw new SpeckleException("Rhino could not import this file");
}
return doc;
}
catch
{
doc.Dispose();
throw;
throw new SpeckleException("Rhino could not open this file");
}
return doc;
}
public void Dispose() { }
@@ -0,0 +1,23 @@
using Rhino;
using Rhino.FileIO;
using Speckle.Sdk;
namespace Speckle.Importers.Rhino.Internal.FileTypeConfig;
internal sealed class DgnConfig : IFileTypeConfig
{
private readonly FileDgnReadOptions _readOptions = new() { ImportViews = true };
public RhinoDoc OpenInHeadlessDocument(string filePath)
{
RhinoDoc? doc = RhinoDoc.OpenHeadless(filePath, _readOptions.ToDictionary());
if (doc is null)
{
throw new SpeckleException("Rhino could not open this file");
}
return doc;
}
public void Dispose() { }
}
@@ -4,7 +4,7 @@ using Speckle.Sdk;
namespace Speckle.Importers.Rhino.Internal.FileTypeConfig;
public sealed class FbxConfig : IFileTypeConfig
internal sealed class FbxConfig : IFileTypeConfig
{
private readonly FileFbxReadOptions _readOptions =
new()
@@ -16,20 +16,12 @@ public sealed class FbxConfig : IFileTypeConfig
public RhinoDoc OpenInHeadlessDocument(string filePath)
{
var doc = RhinoDoc.CreateHeadless(null);
try
RhinoDoc? doc = RhinoDoc.OpenHeadless(filePath, _readOptions.ToDictionary());
if (doc is null)
{
if (!doc.Import(filePath, _readOptions.ToDictionary()))
{
throw new SpeckleException("Rhino could not import this file");
}
return doc;
}
catch
{
doc.Dispose();
throw;
throw new SpeckleException("Rhino could not open this file");
}
return doc;
}
public void Dispose() { }
@@ -4,7 +4,7 @@ using Speckle.Sdk;
namespace Speckle.Importers.Rhino.Internal.FileTypeConfig;
public sealed class ObjConfig : IFileTypeConfig
internal sealed class ObjConfig : IFileTypeConfig
{
private readonly FileObjReadOptions _readOptions =
new(new FileReadOptions() { OpenMode = true })
@@ -1,26 +0,0 @@
using Rhino;
using Speckle.Sdk;
namespace Speckle.Importers.Rhino.Internal.FileTypeConfig;
/// <summary>
/// <see cref="RhinoDoc.OpenHeadless(string)"/> will preserve the units defined by the file
/// </summary>
/// <remarks>
/// For this config to be safe... we need to make sure we're running Rhino 8.24.25251 or greater due to https://mcneel.myjetbrains.com/youtrack/issue/RH-89162
/// </remarks>
public sealed class Rhino3dmConfig : IFileTypeConfig
{
public RhinoDoc OpenInHeadlessDocument(string filePath)
{
RhinoDoc? doc = RhinoDoc.OpenHeadless(filePath);
if (doc is null)
{
throw new SpeckleException("Rhino could not open this file");
}
return doc;
}
public void Dispose() { }
}
@@ -4,7 +4,7 @@ using Speckle.Sdk;
namespace Speckle.Importers.Rhino.Internal.FileTypeConfig;
public sealed class SketchupConfig : IFileTypeConfig
internal sealed class SketchupConfig : IFileTypeConfig
{
private readonly FileSkpReadOptions _options =
new()
@@ -20,20 +20,12 @@ public sealed class SketchupConfig : IFileTypeConfig
public RhinoDoc OpenInHeadlessDocument(string filePath)
{
var doc = RhinoDoc.CreateHeadless(null);
try
RhinoDoc? doc = RhinoDoc.OpenHeadless(filePath, _options.ToDictionary());
if (doc is null)
{
if (!doc.Import(filePath, _options.ToDictionary()))
{
throw new SpeckleException("Rhino could not import this file");
}
return doc;
}
catch
{
doc.Dispose();
throw;
throw new SpeckleException("Rhino could not import this file");
}
return doc;
}
public void Dispose() { }
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.Contracts;
using Microsoft.Extensions.Logging;
using Rhino;
using Rhino.Runtime.InProcess;
@@ -9,55 +9,34 @@ using Version = Speckle.Sdk.Api.GraphQL.Models.Version;
namespace Speckle.Importers.Rhino.Internal;
internal sealed class ImporterInstance(Sender sender, ILogger<ImporterInstance> logger) : IDisposable
internal sealed class ImporterInstance(ImporterArgs args, Sender sender, ILogger<ImporterInstance> logger) : IDisposable
{
private readonly ILogger _logger = logger;
private readonly RhinoCore _rhinoInstance = new(["/netcore-8"], WindowStyle.NoWindow);
private RhinoDoc? _rhinoDoc;
private readonly RhinoDoc _rhinoDoc = OpenDocument(args, logger);
public async Task<ImporterResponse> Run(ImporterArgs args, CancellationToken cancellationToken)
{
using var scopeJobId = ActivityScope.SetTag("jobId", args.JobId);
// using var scopeJobType = ActivityScope.SetTag("jobType", a.JobType);
using var scopeAttempt = ActivityScope.SetTag("job.attempt", args.Attempt.ToString());
using var scopeServerUrl = ActivityScope.SetTag("serverUrl", args.Account.serverInfo.url);
using var scopeProjectId = ActivityScope.SetTag("projectId", args.Project.id);
using var scopeModelId = ActivityScope.SetTag("modelId", args.ModelId);
using var scopeBlobId = ActivityScope.SetTag("blobId", args.BlobId);
using var scopeFileType = ActivityScope.SetTag("fileType", Path.GetExtension(args.FilePath).TrimStart('.'));
UserActivityScope.AddUserScope(args.Account);
private readonly IReadOnlyList<IDisposable> _scopes =
[
ActivityScope.SetTag("jobId", args.JobId),
ActivityScope.SetTag("job.attempt", args.Attempt.ToString()),
// ActivityScope.SetTag("jobType", args.JobType),
ActivityScope.SetTag("serverUrl", args.Account.serverInfo.url),
ActivityScope.SetTag("projectId", args.Project.id),
ActivityScope.SetTag("modelId", args.ModelId),
ActivityScope.SetTag("blobId", args.BlobId),
ActivityScope.SetTag("fileType", Path.GetExtension(args.FilePath).TrimStart('.')),
UserActivityScope.AddUserScope(args.Account),
];
var result = await TryImport(args, cancellationToken);
return result;
}
[SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "IPC")]
private async Task<ImporterResponse> TryImport(ImporterArgs args, CancellationToken cancellationToken)
public async Task<Version> RunRhinoImport(CancellationToken cancellationToken)
{
try
{
var version = await RunRhinoImport(args, cancellationToken);
return new ImporterResponse { Version = version, ErrorMessage = null };
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Import attempt failed with exception");
return new ImporterResponse { ErrorMessage = ex.Message, Version = null };
}
}
private async Task<Version> RunRhinoImport(ImporterArgs args, CancellationToken cancellationToken)
{
try
{
using var config = GetConfig(Path.GetExtension(args.FilePath));
logger.LogInformation("Opening file {FilePath}", args.FilePath);
_rhinoDoc = config.OpenInHeadlessDocument(args.FilePath);
RhinoDoc.ActiveDoc = _rhinoDoc;
var version = await sender.Send(args.Project, args.ModelId, args.Account, cancellationToken);
var version = await sender
.Send(args.Project, args.ModelId, args.Account, cancellationToken)
.ConfigureAwait(false);
return version;
}
finally
@@ -66,12 +45,20 @@ internal sealed class ImporterInstance(Sender sender, ILogger<ImporterInstance>
}
}
private static RhinoDoc OpenDocument(ImporterArgs args, ILogger logger)
{
using var config = GetConfig(Path.GetExtension(args.FilePath));
logger.LogInformation("Opening file {FilePath}", args.FilePath);
return config.OpenInHeadlessDocument(args.FilePath);
}
[Pure]
private static IFileTypeConfig GetConfig(string extension) =>
extension.ToLowerInvariant() switch
{
".skp" => new SketchupConfig(),
".obj" => new ObjConfig(),
".3dm" => new Rhino3dmConfig(),
".dgn" => new DgnConfig(),
".fbx" => new FbxConfig(),
_ => new DefaultConfig(),
};
@@ -83,5 +70,9 @@ internal sealed class ImporterInstance(Sender sender, ILogger<ImporterInstance>
// https://discourse.mcneel.com/t/rhino-inside-fatal-app-crashes-when-disposing-headless-documents/208673
_rhinoDoc?.Dispose();
_rhinoInstance.Dispose();
foreach (var scope in _scopes)
{
scope.Dispose();
}
}
}
@@ -0,0 +1,8 @@
using Microsoft.Extensions.Logging;
namespace Speckle.Importers.Rhino.Internal;
internal sealed class ImporterInstanceFactory(Sender sender, ILogger<ImporterInstance> logger)
{
public ImporterInstance Create(ImporterArgs args) => new(args, sender, logger);
}
@@ -20,6 +20,7 @@ internal static class ServiceRegistration
services.AddTransient<Progress>();
services.AddTransient<Sender>();
services.AddTransient<ImporterInstance>();
services.AddTransient<ImporterInstanceFactory>();
// override default thread context
services.AddSingleton<IThreadContext>(new ImporterThreadContext());
@@ -1,9 +1,11 @@
using System.Text.Json;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using RhinoInside;
using Speckle.Importers.Rhino.Internal;
using Version = Speckle.Sdk.Api.GraphQL.Models.Version;
namespace Speckle.Importers.Rhino;
@@ -18,9 +20,12 @@ public static class Program
Console.WriteLine($"Loading Rhino @ {Resolver.RhinoSystemDirectory}");
}
[SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "IPC")]
public static async Task Main(string[] args)
{
ILogger? logger = null;
ImporterInstance? importer = null;
try
{
var importerArgs = JsonSerializer.Deserialize<ImporterArgs>(args[0], s_serializerOptions);
@@ -32,13 +37,34 @@ public static class Program
TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
logger.LogCritical(eventArgs.Exception, "Unobserved Task Exception");
var importer = serviceProvider.GetRequiredService<ImporterInstance>();
var factory = serviceProvider.GetRequiredService<ImporterInstanceFactory>();
// Error handling flow below here looks a bit of a mess, but we're having to navigate threading issues with rhino inside
// https://discourse.mcneel.com/t/rhino-inside-fatal-app-crashes-when-disposing-headless-documents/208673/7
try
{
// This needs to be called on the main thread
importer = factory.Create(importerArgs);
}
catch (Exception ex)
{
WriteResult(new() { ErrorMessage = ex.Message }, importerArgs.ResultsPath);
return;
}
// As soon as the main thread is yielded, it will be hogged by Rhino
// Task.Run ensures we run everything on a thread pool thread.
await Task.Run(async () =>
{
var result = await importer.Run(importerArgs, CancellationToken.None);
var serializedResult = JsonSerializer.Serialize(result, s_serializerOptions);
File.WriteAllLines(importerArgs.ResultsPath, [serializedResult]);
try
{
Version result = await importer.RunRhinoImport(CancellationToken.None).ConfigureAwait(false);
WriteResult(new() { Version = result }, importerArgs.ResultsPath);
}
catch (Exception ex)
{
WriteResult(new() { ErrorMessage = ex.Message }, importerArgs.ResultsPath);
}
})
.ConfigureAwait(false);
}
@@ -53,7 +79,18 @@ public static class Program
{
Console.WriteLine(MESSAGE);
}
throw;
}
finally
{
importer?.Dispose();
}
}
private static void WriteResult(ImporterResponse result, string resultsPath)
{
var serializedResult = JsonSerializer.Serialize(result, s_serializerOptions);
File.WriteAllLines(resultsPath, [serializedResult]);
}
}
@@ -8,9 +8,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="RhinoCommon" IncludeAssets="compile; build" PrivateAssets="all" VersionOverride="8.23.25251.13001"/>
<PackageReference Include="Grasshopper" IncludeAssets="compile; build" PrivateAssets="all" VersionOverride="8.23.25251.13001"/>
<PackageReference Include="RhinoWindows" IncludeAssets="compile; build" PrivateAssets="all" VersionOverride="8.23.25251.13001"/>
<PackageReference Include="RhinoCommon" IncludeAssets="compile; build" PrivateAssets="all" VersionOverride="8.24.25281.15001"/>
<PackageReference Include="Grasshopper" IncludeAssets="compile; build" PrivateAssets="all" VersionOverride="8.24.25281.15001"/>
<PackageReference Include="RhinoWindows" IncludeAssets="compile; build" PrivateAssets="all" VersionOverride="8.24.25281.15001"/>
<PackageReference Include="Rhino.Inside" />
</ItemGroup>
@@ -4,11 +4,11 @@
"net8.0-windows7.0": {
"Grasshopper": {
"type": "Direct",
"requested": "[8.23.25251.13001, )",
"resolved": "8.23.25251.13001",
"contentHash": "AoswT4QQlD21t/ywsUy2cZRuSQDg39WGIVoyc0jOPv7973WwMrGO891ZIQ0vRZ23s4NGa5lqPU53ZgoGZqSpGA==",
"requested": "[8.24.25281.15001, )",
"resolved": "8.24.25281.15001",
"contentHash": "TEtB8nElTvhMJctLhv8UC1v6jscYdTgsoRQIr31ewGZr6cpgGtQTBmUk26/9ZvQxXCgOp7Y4EZdcEZkmqCm1SQ==",
"dependencies": {
"RhinoCommon": "[8.23.25251.13001]"
"RhinoCommon": "[8.24.25281.15001]"
}
},
"Microsoft.NETFramework.ReferenceAssemblies": {
@@ -48,20 +48,20 @@
},
"RhinoCommon": {
"type": "Direct",
"requested": "[8.23.25251.13001, )",
"resolved": "8.23.25251.13001",
"contentHash": "FKkGghsK0lG7nrjweP3W009lbqMn9tAPF27gw8wognZfZ4hYkTZygOpF99p2Vl7gn+faGKEmh11LgDj6PU/YNw==",
"requested": "[8.24.25281.15001, )",
"resolved": "8.24.25281.15001",
"contentHash": "K8dd7DJvEUUYHpwkyyxr/ojK3e8swlE0STeyG+ulVWkWNHK6gIRDxMYCwB7kNyHHMgpr/vpQlMgR3SVD1GoMTA==",
"dependencies": {
"System.Drawing.Common": "7.0.0"
}
},
"RhinoWindows": {
"type": "Direct",
"requested": "[8.23.25251.13001, )",
"resolved": "8.23.25251.13001",
"contentHash": "xq30IuvvjlensoVROEIenPotvV9o7ynXlROWuZsLfWJp4NwSHc1F/cLn2Rqd0xHCZGhxlwhIYjrdU5WMtDPlxQ==",
"requested": "[8.24.25281.15001, )",
"resolved": "8.24.25281.15001",
"contentHash": "JbG98P80Hskpomzx1xhqFoz2WmVnhjda0noQyZE+dE678ZKmw+O3i/iIjaN5jydXvTu/fZXRzQmEDL7M7Ura8g==",
"dependencies": {
"RhinoCommon": "[8.23.25251.13001]"
"RhinoCommon": "[8.24.25281.15001]"
}
},
"Speckle.InterfaceGenerator": {
+14 -17
View File
@@ -23,14 +23,12 @@ This repo is the home of our next-generation Speckle .NET projects:
- [`Autocad Connector`](https://github.com/specklesystems/speckle-sharp-connectors/tree/main/Connectors/Autocad): for Autodesk AutoCAD and Civil3D 2022+
- [`Rhino Connector`](https://github.com/specklesystems/speckle-sharp-connectors/tree/main/Connectors/Rhino): for McNeel Rhino 7+
- [`Revit Connector`](https://github.com/specklesystems/speckle-sharp-connectors/tree/main/Connectors/Revit): for Autodesk Revit 2022+
- [`ArcGIS Connector`](https://github.com/specklesystems/speckle-sharp-connectors/tree/main/Connectors/ArcGIS/Speckle.Connectors.ArcGIS3): for Esri ArcGIS
- [`Tekla Connector`](https://github.com/specklesystems/speckle-sharp-connectors/tree/main/Connectors/Tekla): for Trimble Tekla 2024
- **Speckle Converters**
- [`Autocad Converter`](https://github.com/specklesystems/speckle-sharp-connectors/tree/main/Converters/Autocad): for Autodesk AutoCAD 2022+
- [`Civil3d Converter`](https://github.com/specklesystems/speckle-sharp-connectors/tree/main/Converters/Civil3d): for Autodesk Civil3D 2022+
- [`Rhino Converter`](https://github.com/specklesystems/speckle-sharp-connectors/tree/main/Converters/Rhino): for McNeel Rhino 7+
- [`Revit Converter`](https://github.com/specklesystems/speckle-sharp-connectors/tree/main/Converters/Revit): for Autodesk Revit 2023+
- [`ArcGIS Converter`](https://github.com/specklesystems/speckle-sharp-connectors/tree/main/Converters/ArcGIS/Speckle.Converters.ArcGIS3): for Esri ArcGIS
- [`Tekla Converter`](https://github.com/specklesystems/speckle-sharp-connectors/tree/main/Converters/Tekla/Speckle.Converter.Tekla2024): for Trimble Tekla 2024
- **SDK**
- [`SDK`](https://github.com/specklesystems/speckle-sharp-connectors/tree/main/Sdk): Autofac module, connector utilities, and dependency injection.
@@ -47,24 +45,15 @@ Make sure to also check and ⭐️ these other Speckle next generation repositor
# Developing and Debugging
Clone this repo. **Each section has its own readme**, so follow each readme for specific build and debug instructions.
## Developing
Issues or questions? We encourage everyone interested to debug / hack / contribute / give feedback to this project.
It is recommended that you use Jetbrains Rider (version 2024.3 or greater) or Visual Studio 2022 (version 17.13 or greater)
The project requires version 8.0.4xx of the .NET SDK.
You can download the latest version from https://dotnet.microsoft.com/en-us/download/dotnet/8.0
> **A note on Accounts:**
> The connectors themselves don't have features to manage your Speckle accounts; this functionality is delegated to the Speckle Manager desktop app. You can install it [from here](https://speckle-releases.ams3.digitaloceanspaces.com/manager/SpeckleManager%20Setup.exe).
From there you can open the main `Speckle.Connectors.sln` solution and build the project
## Local Builds
For good development experience and environment setup, run the commands below as needed.
### Switching to SLNX
SLNX was introduced with .NET 9 (in May 2024), Visual Studio 17.13 and Rider 2024.3. The older SLNs being used remain for now but will be removed when .NET 10 is introduced to the repo. SLNXs specific to certain host apps are being generated from the main SLN to allow for faster developmenet.
[https://devblogs.microsoft.com/dotnet/introducing-slnx-support-dotnet-cli/](https://devblogs.microsoft.com/dotnet/introducing-slnx-support-dotnet-cli/)
[https://devblogs.microsoft.com/visualstudio/new-simpler-solution-file-format/](https://devblogs.microsoft.com/visualstudio/new-simpler-solution-file-format/)
For good development experience and environment setup, you the commands are avaible needed.
### Formatting
We're using [CSharpier](https://github.com/belav/csharpier) to format our code. You can install Csharpier in a few ways:
@@ -109,6 +98,14 @@ This solution includes the Core and Objects projects from the speckle-sharp-sdk
> [!WARNING]
> Using `Local.sln` will modify all your package locks. **Don't check these in!** Revert with the `clean-locks` command or use the regular solution to revert once your changes are made.
## Switching to SLNX
SLNX was introduced with .NET 9 (in May 2024), Visual Studio 17.13 and Rider 2024.3. The older SLNs being used remain for now but will be removed when .NET 10 is introduced to the repo. SLNXs specific to certain host apps are being generated from the main SLN to allow for faster developmenet.
[https://devblogs.microsoft.com/dotnet/introducing-slnx-support-dotnet-cli/](https://devblogs.microsoft.com/dotnet/introducing-slnx-support-dotnet-cli/)
[https://devblogs.microsoft.com/visualstudio/new-simpler-solution-file-format/](https://devblogs.microsoft.com/visualstudio/new-simpler-solution-file-format/)
# Security and Licensing
### Security
@@ -1,5 +1,6 @@
using Speckle.DoubleNumerics;
using Speckle.InterfaceGenerator;
using Speckle.Objects.Data;
using Speckle.Sdk.Dependencies;
using Speckle.Sdk.Models;
using Speckle.Sdk.Models.GraphTraversal;
@@ -36,6 +37,19 @@ public class LocalToGlobalUnpacker : ILocalToGlobalUnpacker
{
atomicObjects.Add((objectToUnpack, objectToUnpack.Current));
}
if (objectToUnpack.Current is DataObject dataObject)
{
foreach (Base displayValue in dataObject.displayValue)
{
if (displayValue is InstanceProxy instanceProxyInDisplayValue)
{
instanceProxies.Add(
(new TraversalContext(instanceProxyInDisplayValue, parent: objectToUnpack), instanceProxyInDisplayValue)
);
}
}
}
}
var objectsAtAbsolute = new HashSet<(TraversalContext tc, Base obj)>();
@@ -1,4 +1,5 @@
using Speckle.Objects.Other;
using Speckle.Objects.Data;
using Speckle.Objects.Other;
using Speckle.Sdk.Models;
using Speckle.Sdk.Models.Collections;
using Speckle.Sdk.Models.GraphTraversal;
@@ -64,6 +65,17 @@ public class RootObjectUnpacker
{
atomicObjects.Add(tc);
}
if (tc.Current is DataObject dataObject)
{
foreach (var displayValue in dataObject.displayValue)
{
if (displayValue is IInstanceComponent)
{
instanceComponents.Add(new TraversalContext(displayValue, parent: tc));
}
}
}
}
return (atomicObjects, instanceComponents);
}
@@ -0,0 +1,29 @@
using NUnit.Framework;
using Speckle.Converters.Common.ToSpeckle;
namespace Speckle.Converters.Common.Tests.ToSpeckle;
public class MeshInstanceIdGeneratorTests
{
private static IEnumerable<List<double>> TestCases()
{
int[] testCases = [0, 1, 100, 1_000_000];
foreach (int testLength in testCases)
{
yield return Enumerable
.Range(0, testLength)
.Select(_ => TestContext.CurrentContext.Random.NextDouble(float.MinValue, float.MaxValue))
.ToList();
}
}
[Test]
[TestCaseSource(nameof(TestCases))]
public void TestEquivalentImplementations(List<double> vertices)
{
var result = MeshInstanceIdGenerator.GenerateUntransformedMeshId(vertices);
var resultSpan = MeshInstanceIdGenerator.GenerateUntransformedMeshId_Span(vertices);
Assert.That(result, Is.EqualTo(resultSpan));
}
}
@@ -4,6 +4,10 @@
<TargetFrameworks>net48;net8.0</TargetFrameworks>
<Configurations>Debug;Release;Local</Configurations>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Speckle.Converters.Common.Tests"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
@@ -0,0 +1,25 @@
using Speckle.DoubleNumerics;
using Speckle.Sdk.Models;
namespace Speckle.Converters.Common.ToSpeckle;
/// <summary>
/// Represents a display value extracted from a host app element, optionally with a transform matrix for instancing.
/// </summary>
/// <param name="Geometry">The extracted geometry as a Speckle Base object</param>
/// <param name="Transform">Optional transform matrix for instanced geometry. Null for non-instanced geometry.</param>
public readonly record struct DisplayValueResult(Base Geometry, Matrix4x4? Transform)
{
/// <summary>
/// Creates a display value result without a transform (non-instanced geometry).
/// </summary>
public static DisplayValueResult WithoutTransform(Base geometry) => new(geometry, null);
/// <summary>
/// Creates a display value result with a transform (instanced geometry).
/// </summary>
/// <remarks>
/// Seems unnecessary, but reads nicely (self-documenting) in usage in my opinion (clear intent).
/// </remarks>
public static DisplayValueResult WithTransform(Base geometry, Matrix4x4 transform) => new(geometry, transform);
}
@@ -0,0 +1,86 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.Contracts;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using Speckle.InterfaceGenerator;
using Speckle.Objects.Geometry;
using Speckle.Sdk.Common;
#if NET6_0_OR_GREATER
using System.Runtime.InteropServices;
#endif
namespace Speckle.Converters.Common.ToSpeckle;
[GenerateAutoInterface]
public static class MeshInstanceIdGenerator
{
/// <summary>
/// Generate a unique hash from the vertex data of a mesh.
/// This is a "good enough" way to compare the equality of meshes.
/// Note, does not consider other mesh data, only <see cref="Mesh.vertices"/>
/// </summary>
/// <remarks>
/// There are two implementations of this function because NET Framework lacks some of the Marshall and Span based functions.
/// However, their external behaviour is the same.
/// </remarks>
/// <param name="mesh"></param>
/// <returns></returns>
[Pure]
public static string GenerateUntransformedMeshId(Mesh mesh)
{
#if NET6_0_OR_GREATER
return GenerateUntransformedMeshId_Span(mesh.vertices);
#else
return GenerateUntransformedMeshId(mesh.vertices);
#endif
}
#if NET6_0_OR_GREATER
[Pure]
internal static string GenerateUntransformedMeshId_Span(List<double> vertices)
{
ReadOnlySpan<double> span = CollectionsMarshal.AsSpan(vertices);
ReadOnlySpan<byte> inputBytes = MemoryMarshal.AsBytes(span);
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
SHA256.HashData(inputBytes, hash);
return Convert.ToHexString(hash);
}
#endif
[Pure]
[SuppressMessage(
"Performance",
"CA1850:Prefer static \'HashData\' method over \'ComputeHash\'",
Justification = "Already another overload for .NET Core"
)]
internal static string GenerateUntransformedMeshId(List<double> vertices)
{
double[] verts = (double[])s_listItemsField.GetValue(vertices).NotNull();
int byteCount = verts.Length * sizeof(double);
byte[] inputBytes = new byte[byteCount];
Buffer.BlockCopy(verts, 0, inputBytes, 0, byteCount);
// Compute the SHA256 hash
using (SHA256 sha256 = SHA256.Create())
{
byte[] hashBytes = sha256.ComputeHash(inputBytes);
// Convert hash to hex string (uppercase, similar to Convert.ToHexString)
StringBuilder sb = new(hashBytes.Length * 2);
foreach (byte b in hashBytes)
{
sb.AppendFormat("{0:X2}", b);
}
return sb.ToString();
}
}
private static readonly FieldInfo s_listItemsField = typeof(List<double>)
.GetField("_items", BindingFlags.NonPublic | BindingFlags.Instance)
.NotNull();
}