From f7fa46b529d0b8ff06bf7a6347dd57d486a76e14 Mon Sep 17 00:00:00 2001 From: AndreyStartsev <83749987+AndreyStartsev@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:42:38 +0000 Subject: [PATCH] Seed legacy type-level material relation GlobalIds from stable keys --- CHANGELOG.md | 4 + src/RvtIO/Ifc/IfcExporter.cs | 47 +++---- .../IfcExporterTypeObjectsTests.cs | 122 +++++++++++++++++- 3 files changed, 149 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87b09a2..4ee364a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ API between minor releases. ## [Unreleased] +### Breaking changes + +- Legacy IFC profile: the `GlobalId` of a type-level `IfcRelAssociatesMaterial` relation is now seeded from the material element id list (`type-materials:{materialIds}`, as `materials:{materialIds}` already is for product-level relations, and `type-layer-set:{seed}` from the host type id or layer-set group key) instead of the STEP entity number of the relating material, material list or layer set. Type-level material relation `GlobalId`s in the legacy profile change once and no longer shift when entities are added or removed earlier in the file; product-level material relations and the current IFC profile are unchanged. (Closes #88) + ### Added - Default output, current IFC profile: every beam with a straight drive curve gets `Pset_BeamCommon.Slope`, the angle in degrees of its drive curve to the horizontal (0 for a horizontal beam, never negative), whether or not BaseQuantities are exported and whatever its body; its quantities are unchanged. A beam without a straight drive curve gets no Slope. diff --git a/src/RvtIO/Ifc/IfcExporter.cs b/src/RvtIO/Ifc/IfcExporter.cs index 8321800..eb6f89c 100644 --- a/src/RvtIO/Ifc/IfcExporter.cs +++ b/src/RvtIO/Ifc/IfcExporter.cs @@ -5834,7 +5834,7 @@ int EnsureUnnamedMaterial() => } var layerSetByKey = new Dictionary(); - var layerSetByProductEntity = new Dictionary(); + var layerSetByProductEntity = new Dictionary(); var layerUsageProductEntities = new HashSet(); var layeredProducts = exportedIfcElements .Select(pair => (ElementId: pair.Key, Product: pair.Value, @@ -5913,7 +5913,9 @@ int EnsureUnnamedMaterial() => layerSetByKey[layerSetKey] = layerSet; } - foreach (var item in group) layerSetByProductEntity[item.Product.entityId] = layerSet.LayerSetId; + string layerSetSeed = first.Native is { } nativeSeed + ? nativeSeed.TypeId.ToString(CultureInfo.InvariantCulture) : group.Key; + foreach (var item in group) layerSetByProductEntity[item.Product.entityId] = (layerSet.LayerSetId, layerSetSeed); var associated = CurrentProfile ? group.Where(item => item.Product.ifcType != "IFCWALL").ToList() : group.ToList(); if (associated.Count == 0) continue; @@ -6152,7 +6154,7 @@ int EnsureUnnamedMaterial() => for (int part = 0; part < (partMaterials?.Count ?? 1); part++) { var partIds = partMaterials == null ? materialElementIds : [partMaterials[part]]; - string materialKey = string.Join(',', partIds); + string materialKey = string.Join(',', partIds.Select(id => id.ToString(CultureInfo.InvariantCulture))); if (!directMaterialProducts.TryGetValue(materialKey, out var association)) association = (partIds, new List()); if (pieceBased && product.ifcType == "IFCSLAB" && element.CategoryId == -2001300) @@ -6166,7 +6168,7 @@ int EnsureUnnamedMaterial() => } } - var relatingMaterialByProductEntity = new Dictionary(); + var relatingMaterialByProductEntity = new Dictionary(); var directRelationByRelating = new Dictionary Products)>(); foreach (var (materialKey, association) in directMaterialProducts) { @@ -6177,9 +6179,9 @@ int EnsureUnnamedMaterial() => ? materialIds[0] : w.Add("IFCMATERIALLIST", StepWriter.List(materialIds.Select(R))); foreach (int productEntityId in association.ProductIds) - relatingMaterialByProductEntity[productEntityId] = relatingMaterialId; + relatingMaterialByProductEntity[productEntityId] = (relatingMaterialId, materialKey); foreach (int productEntityId in typeOnlyMaterialProducts.GetValueOrDefault(materialKey) ?? []) - relatingMaterialByProductEntity[productEntityId] = relatingMaterialId; + relatingMaterialByProductEntity[productEntityId] = (relatingMaterialId, materialKey); if (association.ProductIds.Count > 0) { string guidArg = S(IfcGuid.FromName($"materials:{materialKey}")); @@ -67077,7 +67079,7 @@ internal static (string? Full, string? Own) TypeObjectNameParts(Element typeElem /// private static void AssociateTypeObjectMaterials( StepWriter w, int ownerHistId, int startIndex, - IReadOnlyDictionary relatingMaterialByProductEntity, HostLayerSets? layerSets = null, + IReadOnlyDictionary relatingMaterialByProductEntity, HostLayerSets? layerSets = null, IReadOnlyDictionary Products)>? directRelations = null) { var typeClassByEntity = new Dictionary(); @@ -67096,8 +67098,8 @@ private static void AssociateTypeObjectMaterials( firstMemberByType.Add((typeEntityId, firstMember)); } - var typesByRelatingMaterial = new SortedDictionary>(); - var typesByLayerSet = new SortedDictionary>(); + var typesByRelatingMaterial = new SortedDictionary Types)>(); + var typesByLayerSet = new SortedDictionary Types)>(); foreach (var (typeEntityId, firstMember) in firstMemberByType) { if (layerSets != null && typeClassByEntity.GetValueOrDefault(typeEntityId) == "IFCSTAIRFLIGHTTYPE") continue; @@ -67106,10 +67108,11 @@ private static void AssociateTypeObjectMaterials( // usage (the host-object export with the type handle); ceilings and roofs do not. if (layerSets != null && typeClassByEntity.GetValueOrDefault(typeEntityId) is { } hostTypeClass && (hostTypeClass == "IFCWALLTYPE" || hostTypeClass == "IFCSLABTYPE" && layerSets.UsageProducts.Contains(firstMember)) - && layerSets.ByProduct.TryGetValue(firstMember, out int typeLayerSet)) + && layerSets.ByProduct.TryGetValue(firstMember, out var typeLayerSet)) { - if (!typesByLayerSet.TryGetValue(typeLayerSet, out var typed)) typesByLayerSet[typeLayerSet] = typed = new List(); - typed.Add(typeEntityId); + if (!typesByLayerSet.TryGetValue(typeLayerSet.EntityId, out var typed)) + typesByLayerSet[typeLayerSet.EntityId] = typed = (typeLayerSet.Seed, new List()); + typed.Types.Add(typeEntityId); continue; } // Host objects (walls, floors, ceilings, roofs) give their type the layer set instead. @@ -67119,13 +67122,13 @@ private static void AssociateTypeObjectMaterials( bool hostType = hostOrFamilyTypeClass is null or "IFCCURTAINWALLTYPE" or "IFCWALLTYPE" or "IFCSLABTYPE" or "IFCCOVERINGTYPE" or "IFCROOFTYPE"; if (hostType && !slabFamilyType - || !relatingMaterialByProductEntity.TryGetValue(firstMember, out int relatingMaterial)) + || !relatingMaterialByProductEntity.TryGetValue(firstMember, out var relatingMaterial)) continue; - if (!typesByRelatingMaterial.TryGetValue(relatingMaterial, out var types)) - typesByRelatingMaterial[relatingMaterial] = types = new List(); - types.Add(typeEntityId); + if (!typesByRelatingMaterial.TryGetValue(relatingMaterial.EntityId, out var types)) + typesByRelatingMaterial[relatingMaterial.EntityId] = types = (relatingMaterial.Seed, new List()); + types.Types.Add(typeEntityId); } - foreach (var (relatingMaterial, typeEntityIds) in typesByRelatingMaterial) + foreach (var (relatingMaterial, (materialSeed, typeEntityIds)) in typesByRelatingMaterial) { if (directRelations != null && directRelations.TryGetValue(relatingMaterial, out var direct)) { @@ -67134,12 +67137,12 @@ private static void AssociateTypeObjectMaterials( continue; } w.Add("IFCRELASSOCIATESMATERIAL", - $"{S(IfcGuid.FromName($"type-materials:{relatingMaterial}"))},{R(ownerHistId)},$,$," + $"{S(IfcGuid.FromName($"type-materials:{materialSeed}"))},{R(ownerHistId)},$,$," + $"{StepWriter.List(typeEntityIds.Select(R))},{R(relatingMaterial)}"); } - foreach (var (layerSet, typeEntityIds) in typesByLayerSet) + foreach (var (layerSet, (layerSetSeed, typeEntityIds)) in typesByLayerSet) w.Add("IFCRELASSOCIATESMATERIAL", - $"{S(IfcGuid.FromName($"type-layer-set:{layerSet}"))},{R(ownerHistId)},$,$," + $"{S(IfcGuid.FromName($"type-layer-set:{layerSetSeed}"))},{R(ownerHistId)},$,$," + $"{StepWriter.List(typeEntityIds.Select(R))},{R(layerSet)}"); } @@ -67186,8 +67189,8 @@ private static int WriteInsulationCoverings( /// The layer sets of the host objects of one export: by product entity, and which products got /// a usage. /// - private sealed record HostLayerSets(IReadOnlyDictionary ByProduct, IReadOnlySet UsageProducts, - IReadOnlySet TypeOnlyProducts); + private sealed record HostLayerSets(IReadOnlyDictionary ByProduct, + IReadOnlySet UsageProducts, IReadOnlySet TypeOnlyProducts); /// /// The connector export creates a distribution port for every unconnected connector of an MEP diff --git a/tests/RvtIO.Tests/IfcExporterTypeObjectsTests.cs b/tests/RvtIO.Tests/IfcExporterTypeObjectsTests.cs index 5a263b2..d4c4011 100644 --- a/tests/RvtIO.Tests/IfcExporterTypeObjectsTests.cs +++ b/tests/RvtIO.Tests/IfcExporterTypeObjectsTests.cs @@ -523,8 +523,126 @@ public void ComponentType_ReceivesTheMaterialOfItsFirstInstance() .Select(id => EntityArgs(ifc, id, "IFCRELASSOCIATESMATERIAL")).ToList(); Assert.Equal(2, relations.Count); Assert.All(relations, args => Assert.Equal($"#{material}", args[5])); - Assert.Contains(relations, args => args[4] == $"(#{memberEntity})"); - Assert.Contains(relations, args => args[4] == $"(#{typeEntity})"); + var productRel = Assert.Single(relations, args => args[4] == $"(#{memberEntity})"); + var typeRel = Assert.Single(relations, args => args[4] == $"(#{typeEntity})"); + Assert.Equal($"'{IfcGuid.FromName("materials:0")}'", productRel[0]); + Assert.Equal($"'{IfcGuid.FromName("type-materials:0")}'", typeRel[0]); + } + + [Fact] + public void LegacyTypeMaterialRelationGlobalIds_StayStableWhenEarlierStepEntitiesShift() + { + Element MaterialElement(int id, string name) => new() + { + Id = id, + TypeId = -1, + IsBlockOnly = true, + CategoryId = -2000700, + Category = "OST_Materials", + Name = name, + HasShadingAppearance = true, + Parameters = + [ + new Parameter { Id = id + 1, Definition = new Definition { Name = "MATERIAL_PARAM_COLOR" }, Value = 0x112233, StorageType = StorageType.Integer }, + new Parameter { Id = id + 2, Definition = new Definition { Name = "MATERIAL_PARAM_SHININESS" }, Value = 25, StorageType = StorageType.Integer }, + ], + }; + + Element[] BuildBaseElements() + { + var steel = MaterialElement(700, "Steel"); + var glass = MaterialElement(710, "Glass"); + var wood = MaterialElement(720, "Wood"); + + var singleType = TypeElement(500, "SingleType", "MullionFamily"); + singleType.CategoryId = -2000171; + singleType.Parameters.Add(new Parameter { Id = -1002107, StorageType = StorageType.ElementId, Value = 700 }); + var singleMullion = Product(100, -2000171, "Mullion", 500, 500, centreX: 0); + singleMullion.TypeId = 500; + foreach (var face in singleMullion.Geometry!.Faces) + face.RenderStyleId = 700; + + var multiType = TypeElement(510, "MultiType", "DoorFamily"); + multiType.CategoryId = DoorCategoryId; + var door = Product(110, DoorCategoryId, "Doors", 510, 510, centreX: 10); + door.TypeId = 510; + door.Geometry!.Faces[0].RenderStyleId = 710; + door.Geometry.Faces[1].RenderStyleId = 720; + for (int i = 2; i < door.Geometry.Faces.Count; i++) + door.Geometry.Faces[i].RenderStyleId = 710; + + var hostWall = Product(999, WallCategoryId, "Walls", elemTypeParamId: null, nativeMasterSymbolId: null, centreX: 20); + return [steel, glass, wood, hostWall, singleType, singleMullion, multiType, door]; + } + + string baselineIfc = ExportToText(IfcProfile.Legacy, BuildBaseElements()); + + // Prepend extra elements and custom properties so STEP entity numbers of materials, + // material lists, products and types all shift significantly. + var extraColumnType = TypeElement(400, "ExtraColType", "ExtraFamily"); + extraColumnType.CategoryId = -2001330; + var extraColumn = Product(50, -2001330, "Structural Columns", 400, 400, centreX: -20); + for (int p = 0; p < 12; p++) + { + extraColumn.Parameters.Add(new Parameter + { + Id = 800000 + p, + Definition = new Definition { Id = 800000 + p, Name = $"SpanExtra_{p}" }, + StorageType = StorageType.Double, + Value = 10.0 + p, + }); + } + string shiftedIfc = ExportToText(IfcProfile.Legacy, [extraColumnType, extraColumn, .. BuildBaseElements()]); + + // Verify the relating IFCMATERIAL and IFCMATERIALLIST STEP numbers actually shifted. + int baseSteelEntity = EntityIds(baselineIfc, "IFCMATERIAL") + .Single(id => EntityArgs(baselineIfc, id, "IFCMATERIAL")[0] == "'Steel'"); + int shiftedSteelEntity = EntityIds(shiftedIfc, "IFCMATERIAL") + .Single(id => EntityArgs(shiftedIfc, id, "IFCMATERIAL")[0] == "'Steel'"); + Assert.NotEqual(baseSteelEntity, shiftedSteelEntity); + + int baseMatList = SoleEntityId(baselineIfc, "IFCMATERIALLIST"); + int shiftedMatList = SoleEntityId(shiftedIfc, "IFCMATERIALLIST"); + Assert.NotEqual(baseMatList, shiftedMatList); + + string expectedSingleProductGuid = $"'{IfcGuid.FromName("materials:700")}'"; + string expectedSingleTypeGuid = $"'{IfcGuid.FromName("type-materials:700")}'"; + string expectedMultiProductGuid = $"'{IfcGuid.FromName("materials:710,720")}'"; + string expectedMultiTypeGuid = $"'{IfcGuid.FromName("type-materials:710,720")}'"; + + foreach ((string ifc, int steelEntity, int matListEntity) in new[] + { + (baselineIfc, baseSteelEntity, baseMatList), + (shiftedIfc, shiftedSteelEntity, shiftedMatList), + }) + { + var rels = EntityIds(ifc, "IFCRELASSOCIATESMATERIAL") + .Select(id => EntityArgs(ifc, id, "IFCRELASSOCIATESMATERIAL")).ToList(); + int memberTypeEntity = SoleEntityId(ifc, "IFCMEMBERTYPE"); + int doorStyleEntity = SoleEntityId(ifc, "IFCDOORSTYLE"); + + var singleTypeRel = Assert.Single(rels, a => a[4] == $"(#{memberTypeEntity})" && a[5] == $"#{steelEntity}"); + Assert.Equal(expectedSingleTypeGuid, singleTypeRel[0]); + + var multiTypeRel = Assert.Single(rels, a => a[4] == $"(#{doorStyleEntity})" && a[5] == $"#{matListEntity}"); + Assert.Equal(expectedMultiTypeGuid, multiTypeRel[0]); + + Assert.Contains(rels, a => a[0] == expectedSingleProductGuid && a[5] == $"#{steelEntity}"); + Assert.Contains(rels, a => a[0] == expectedMultiProductGuid && a[5] == $"#{matListEntity}"); + } + + // Current profile keeps its existing MD5("{project GlobalId}IfcRelAssociatesMaterial:{first related GlobalId}") seed. + string currentIfc = ExportToText(IfcProfile.Current, BuildBaseElements()); + int projectEntity = SoleEntityId(currentIfc, "IFCPROJECT"); + string projectGuid = EntityArgs(currentIfc, projectEntity, "IFCPROJECT")[0].Trim('\''); + int currentMemberType = SoleEntityId(currentIfc, "IFCMEMBERTYPE"); + string currentMemberTypeGuid = EntityArgs(currentIfc, currentMemberType, "IFCMEMBERTYPE")[0].Trim('\''); + var currentRels = EntityIds(currentIfc, "IFCRELASSOCIATESMATERIAL") + .Select(id => EntityArgs(currentIfc, id, "IFCRELASSOCIATESMATERIAL")).ToList(); + var mergedSteelRel = Assert.Single(currentRels, a => a[4].Contains($"#{currentMemberType}")); + Assert.Equal( + $"'{IfcGuid.FromMd5($"{projectGuid}IfcRelAssociatesMaterial:{currentMemberTypeGuid}")}'", + mergedSteelRel[0]); } [Theory]