From cc418b7b61da7080ee25ee18809edd41ada2805e Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:53:25 +0300 Subject: [PATCH 01/10] Add IfcIO.Scene adapter that converts IFC models into IOScene documents IfcSceneReader reads an IFC file or an IfcModel into a SceneDocument in metres with Z up, ported from the IOViewer.IfcIO adapter. The model key is the ModelKey option or the file name; SourceId and IfcGuid are the GlobalId. Storeys become levels and tree nodes, property and quantity sets get ifc: keys, quantities are numbers and property values text, and work schedules become scene schedules. Elements without geometry are reported as diagnostics. The project references IOScene with the range [0.1.0-preview.1, 0.2.0-0) and is not packed until IOScene 0.1.0 is released. Tests include a golden identity test on a fixture taken from the placement tests. --- Directory.Packages.props | 1 + IfcIO.slnx | 2 + src/IfcIO.Scene/IfcIO.Scene.csproj | 17 + src/IfcIO.Scene/IfcSceneOptions.cs | 20 + src/IfcIO.Scene/IfcSceneReader.cs | 354 +++++++++++++ src/IfcIO.Scene/IfcSceneResult.cs | 63 +++ .../Fixtures/placement.identity.txt | 11 + .../IfcIO.Scene.Tests/Fixtures/placement.ifc | 112 +++++ .../IfcIO.Scene.Tests.csproj | 26 + .../IfcIO.Scene.Tests/IfcSceneReaderTests.cs | 475 ++++++++++++++++++ 10 files changed, 1081 insertions(+) create mode 100644 src/IfcIO.Scene/IfcIO.Scene.csproj create mode 100644 src/IfcIO.Scene/IfcSceneOptions.cs create mode 100644 src/IfcIO.Scene/IfcSceneReader.cs create mode 100644 src/IfcIO.Scene/IfcSceneResult.cs create mode 100644 tests/IfcIO.Scene.Tests/Fixtures/placement.identity.txt create mode 100644 tests/IfcIO.Scene.Tests/Fixtures/placement.ifc create mode 100644 tests/IfcIO.Scene.Tests/IfcIO.Scene.Tests.csproj create mode 100644 tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index cd9da25..43f1c26 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ + diff --git a/IfcIO.slnx b/IfcIO.slnx index 3c6feca..6cf200b 100644 --- a/IfcIO.slnx +++ b/IfcIO.slnx @@ -2,8 +2,10 @@ + + diff --git a/src/IfcIO.Scene/IfcIO.Scene.csproj b/src/IfcIO.Scene/IfcIO.Scene.csproj new file mode 100644 index 0000000..6875541 --- /dev/null +++ b/src/IfcIO.Scene/IfcIO.Scene.csproj @@ -0,0 +1,17 @@ + + + + + false + Converts IFC models read by IfcIO into IOScene documents in metres with Z up, keeping GlobalIds, storeys, property sets, quantities and 4D schedules. + true + + $(NoWarn);NU5104 + + + + + + + + diff --git a/src/IfcIO.Scene/IfcSceneOptions.cs b/src/IfcIO.Scene/IfcSceneOptions.cs new file mode 100644 index 0000000..cb6e59d --- /dev/null +++ b/src/IfcIO.Scene/IfcSceneOptions.cs @@ -0,0 +1,20 @@ +namespace IfcIO.Scene; + +/// +/// Options for . +/// +public sealed class IfcSceneOptions +{ + /// + /// The key of the scene model (SceneModel.Key), which with each element's SourceId forms the identity + /// that may be stored outside the scene. Null uses the file name without directory, for example house.ifc. + /// Required when the model is converted from memory, where there is no file name. + /// + public string? ModelKey { get; set; } + + /// + /// Also converts spaces (IfcSpace, ), drawn translucent unless their + /// material says otherwise. False by default, because closed room volumes hide the elements inside them. + /// + public bool IncludeSpaces { get; set; } +} diff --git a/src/IfcIO.Scene/IfcSceneReader.cs b/src/IfcIO.Scene/IfcSceneReader.cs new file mode 100644 index 0000000..dfd6f32 --- /dev/null +++ b/src/IfcIO.Scene/IfcSceneReader.cs @@ -0,0 +1,354 @@ +using System.Globalization; +using System.Reflection; +using IfcIO.Model; +using IfcIO.Model.Sequence; +using IfcIO.Schema; +using IOScene; + +namespace IfcIO.Scene; + +/// +/// Converts an IFC file, or an read by IfcIO, into a . +/// +/// +/// +/// The scene has one : Key is , else the file +/// name without directory; SourceFormat is ifc. Geometry is IfcIO's world mesh of each element +/// (), scaled from to metres by an exact +/// factor; IFC is Z up like the scene, so axes are not swapped. Each mesh becomes one geometry placed once, without +/// a transform. +/// +/// +/// Each storey becomes a level (elevation in metres) and a tree node of kind . +/// Each element of the storey (and each space with ) becomes a child of +/// that node, except the type objects (for example IFCWALLTYPE, IFCDOORSTYLE) and lining and panel +/// properties that IfcIO also lists as elements. is , which +/// for a model opened from a file is the STEP entity id (#123 gives 123); +/// and are the GlobalId; is the IFC class as +/// IfcIO reports it (upper case, for example IFCWALL). IfcIO places an element in a storey only through +/// IfcRelContainedInSpatialStructure, so elements contained in the site or building, and spaces aggregated to +/// a storey, are in the first storey. +/// +/// +/// Property sets and quantity sets become property sets keyed ifc:<SetName>, their properties +/// ifc:<SetName>.<Name>; a set that IfcIO shares between elements is one set in the scene. +/// Property values are text as IfcIO reads them. Quantity values are numbers in the file's units, so their +/// is null. Colour and transparency come from ; +/// elements without one get a colour for their IFC class. Work schedules become schedules. Openings are not converted. +/// +/// +public static class IfcSceneReader +{ + private const int ModelId = 1; + + /// + /// Reads the IFC file at (.ifc or .ifczip) with + /// and converts it. + /// + /// The IFC file. + /// Conversion options; null uses the defaults. + /// The scene with a report of the conversion. + /// Two converted elements have the same . + public static IfcSceneResult Read(string path, IfcSceneOptions? options = null) + { + ArgumentException.ThrowIfNullOrEmpty(path); + options ??= new IfcSceneOptions(); + return Convert(IfcModel.Open(path), options.ModelKey ?? Path.GetFileName(path), path, options); + } + + /// + /// Converts , which has no file name, so is required. + /// + /// The model, for example from . + /// Conversion options with . + /// The scene with a report of the conversion. + /// + /// is empty, or two converted elements have the same . + /// + public static IfcSceneResult Read(IfcModel model, IfcSceneOptions options) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(options); + if (string.IsNullOrEmpty(options.ModelKey)) + { + throw new ArgumentException("A model converted from memory needs IfcSceneOptions.ModelKey.", nameof(options)); + } + return Convert(model, options.ModelKey, null, options); + } + + private static IfcSceneResult Convert(IfcModel model, string key, string? path, IfcSceneOptions options) + { + double scale = MetresPerUnit(model.LengthUnit); + var doc = new SceneDocument { Title = model.ProjectName }; + doc.Models.Add(new SceneModel + { + Id = ModelId, + Key = key, + Name = model.ProjectName, + Path = path, + SourceFormat = "ifc", + SourceLengthUnit = UnitSymbol(model.LengthUnit), + Producer = Producer, + }); + + var diagnostics = new List(); + var materials = new Dictionary(ReferenceEqualityComparer.Instance); + var sets = new Dictionary(ReferenceEqualityComparer.Instance); + var converted = new Dictionary(ReferenceEqualityComparer.Instance); + var ids = new HashSet(); + int withGeometry = 0; + + // Storey nodes take ids above every element id, which are STEP ids for a model read from a file. + long nextNodeId = model.AllElements.Concat(model.AllSpaces).Select(e => (long)e.Id).DefaultIfEmpty(0).Max() + 1; + + foreach (var storey in model.Storeys) + { + int levelId = doc.Levels.Count + 1; + doc.Levels.Add(new SceneLevel { Id = levelId, ModelId = ModelId, Name = storey.Name, Elevation = storey.Elevation * scale }); + long nodeId = nextNodeId++; + doc.Elements.Add(new SceneElement + { + Id = nodeId, + ModelId = ModelId, + SourceId = storey.GlobalId, + IfcGuid = storey.GlobalId, + Kind = SceneNodeKind.Level, + Name = storey.Name, + Category = "IFCBUILDINGSTOREY", + LevelId = levelId, + }); + + var elements = options.IncludeSpaces ? storey.Elements.Concat(storey.Spaces) : storey.Elements; + foreach (var element in elements) + { + if (IsNotProduct(element.IfcType)) continue; + + // Instances, 4D tasks and clashes all key on Id, so a repeat would merge two elements. + if (!ids.Add(element.Id)) + { + throw new ArgumentException( + $"Element id {element.Id} ('{element.Name}') is used twice. Ids are unique in models read with IfcModel.Open; " + + "a model built in memory numbers elements per storey, so save and reopen it first.", + nameof(model)); + } + + converted.Add(element, element.Id); + var issue = AddElement(doc, element, nodeId, levelId, scale, materials, sets); + if (issue is null) withGeometry++; + else diagnostics.Add(issue); + } + } + + int skippedTasks = AddSchedules(doc, model.WorkSchedules, converted); + return new IfcSceneResult(doc, diagnostics, model.LengthUnit, scale, converted.Count, withGeometry, skippedTasks); + } + + private static readonly string Producer = + "IfcIO.Scene " + (typeof(IfcSceneReader).Assembly.GetCustomAttribute()?.InformationalVersion + ?? typeof(IfcSceneReader).Assembly.GetName().Version?.ToString() + ?? "0.0.0").Split('+')[0]; + + // shortcut: IfcIO picks products by class-name prefix (IFCWALL, IFCDOOR, ...), so type objects such as IfcWallType or + // IfcDoorStyle and the door and window lining and panel properties arrive as elements without geometry; no product + // class it matches ends like these. Remove once IfcIO reads only IfcProduct subtypes. + private static bool IsNotProduct(string ifcType) => + ifcType.EndsWith("TYPE", StringComparison.OrdinalIgnoreCase) + || ifcType.EndsWith("STYLE", StringComparison.OrdinalIgnoreCase) + || ifcType.EndsWith("PROPERTIES", StringComparison.OrdinalIgnoreCase); + + // Exact lengths of the units IfcIO reports; it already converts any other file unit to metres while reading. + private static double MetresPerUnit(IfcLengthUnit unit) => unit switch + { + IfcLengthUnit.Millimetre => 0.001, + IfcLengthUnit.Metre => 1.0, + IfcLengthUnit.Foot => 0.3048, + IfcLengthUnit.Inch => 0.0254, + _ => throw new ArgumentOutOfRangeException(nameof(unit), unit, "Unknown IFC length unit."), + }; + + private static string UnitSymbol(IfcLengthUnit unit) => unit switch + { + IfcLengthUnit.Millimetre => "mm", + IfcLengthUnit.Metre => "m", + IfcLengthUnit.Foot => "ft", + IfcLengthUnit.Inch => "in", + _ => throw new ArgumentOutOfRangeException(nameof(unit), unit, "Unknown IFC length unit."), + }; + + private static SceneDiagnostic? AddElement( + SceneDocument doc, + IfcElement element, + long parentId, + int levelId, + double scale, + Dictionary materials, + Dictionary sets) + { + var sceneElement = new SceneElement + { + Id = element.Id, + ModelId = ModelId, + SourceId = element.GlobalId, + IfcGuid = element.GlobalId, + ParentId = parentId, + Kind = SceneNodeKind.Element, + Name = element.Name, + Category = element.IfcType, + TypeName = element.TypeProduct?.Name ?? string.Empty, + LevelId = levelId, + }; + foreach (var pset in element.PropertySets) + { + sceneElement.PropertySetIds.Add(SetId(doc, sets, pset, pset.Name, + pset.Properties.Select(p => (p.Name, SceneValue.FromText(p.Value))))); + } + foreach (var qset in element.QuantitySets) + { + sceneElement.PropertySetIds.Add(SetId(doc, sets, qset, qset.Name, + qset.Quantities.Select(q => (q.Name, QuantityValue(q.Value))))); + } + doc.Elements.Add(sceneElement); + + var source = element.GetEffectiveMesh(); + if (source is null) return Issue(SceneSeverity.Info, "no-geometry", "IfcIO returned no geometry", element); + if (source.Indices.Count == 0 || source.Vertices.Count == 0) return Issue(SceneSeverity.Warning, "empty-mesh", "The mesh has no triangles", element); + + var vertices = new Vector3d[source.Vertices.Count]; + for (int i = 0; i < vertices.Length; i++) + { + var v = source.Vertices[i]; + if (!double.IsFinite(v.X) || !double.IsFinite(v.Y) || !double.IsFinite(v.Z)) + { + return Issue(SceneSeverity.Warning, "invalid-mesh", "The mesh has a non-finite coordinate", element); + } + vertices[i] = new Vector3d(v.X * scale, v.Y * scale, v.Z * scale); + } + if (source.Indices.Count % 3 != 0 || source.Indices.Any(index => (uint)index >= (uint)vertices.Length)) + { + return Issue(SceneSeverity.Warning, "invalid-mesh", "The mesh has an index out of range or an index count not divisible by 3", element); + } + + int geometryId = doc.Geometries.Count + 1; + doc.Geometries.Add(new SceneGeometry { Id = geometryId, Topology = SceneTopology.Triangles, Vertices = vertices, Indices = [.. source.Indices] }); + var instance = new SceneInstance { ElementId = element.Id, GeometryId = geometryId }; + if (element.Material is { } material) instance.MaterialId = MaterialId(doc, material, materials); + else instance.Color = DefaultColor(element); + doc.Instances.Add(instance); + return null; + } + + private static SceneDiagnostic Issue(SceneSeverity severity, string code, string reason, IfcElement element) => + new(severity, code, $"{reason}; element #{element.Id} '{element.Name}' ({element.IfcType}) has no geometry in the scene.", element.GlobalId); + + // Quantities are doubles in IfcIO, in the file's units (not always IfcModel.LengthUnit), so they carry no unit. + private static SceneValue QuantityValue(double value) + { + string text = value.ToString(CultureInfo.InvariantCulture); + return double.IsFinite(value) ? SceneValue.FromNumber(value, null, text) : SceneValue.FromText(text); + } + + private static int SetId( + SceneDocument doc, Dictionary sets, object source, string name, IEnumerable<(string Name, SceneValue Value)> values) + { + if (sets.TryGetValue(source, out int id)) return id; + + id = doc.PropertySets.Count + 1; + var set = new ScenePropertySet { Id = id, Name = name, Key = "ifc:" + name }; + foreach (var (propertyName, value) in values) + { + set.Properties.Add(new SceneProperty { Name = propertyName, Key = $"ifc:{name}.{propertyName}", Value = value }); + } + doc.PropertySets.Add(set); + sets.Add(source, id); + return id; + } + + private static int MaterialId(SceneDocument doc, IfcMaterial material, Dictionary materials) + { + if (materials.TryGetValue(material, out int id)) return id; + + id = doc.Materials.Count + 1; + doc.Materials.Add(new SceneMaterial + { + Id = id, + Name = material.Name, + Color = new RgbaColor(Channel(material.R), Channel(material.G), Channel(material.B), Channel(material.Alpha)), + }); + materials.Add(material, id); + return id; + } + + private static byte Channel(double value) => (byte)Math.Round(Math.Clamp(value, 0, 1) * 255); + + // Fallback colours by IFC class family, matched on the class name prefix as IfcIO lists the classes it reads. + private static RgbaColor DefaultColor(IfcElement element) + { + string type = element.IfcType.ToUpperInvariant(); + if (element is IfcSpace) return new RgbaColor(150, 200, 235, Channel(0.3)); + if (type.StartsWith("IFCWINDOW", StringComparison.Ordinal) || type.StartsWith("IFCCURTAINWALL", StringComparison.Ordinal)) + { + return new RgbaColor(140, 190, 225, Channel(0.5)); + } + if (type.StartsWith("IFCWALL", StringComparison.Ordinal)) return new RgbaColor(215, 213, 205); + if (type.StartsWith("IFCSLAB", StringComparison.Ordinal) || type.StartsWith("IFCROOF", StringComparison.Ordinal) + || type.StartsWith("IFCFOOTING", StringComparison.Ordinal)) + { + return new RgbaColor(190, 190, 190); + } + if (type.StartsWith("IFCCOLUMN", StringComparison.Ordinal) || type.StartsWith("IFCBEAM", StringComparison.Ordinal) + || type.StartsWith("IFCMEMBER", StringComparison.Ordinal)) + { + return new RgbaColor(120, 140, 185); + } + if (type.StartsWith("IFCDOOR", StringComparison.Ordinal)) return new RgbaColor(150, 105, 60); + if (type.StartsWith("IFCPIPE", StringComparison.Ordinal) || type.StartsWith("IFCFLOW", StringComparison.Ordinal)) + { + return new RgbaColor(225, 110, 60); + } + if (type.StartsWith("IFCDUCT", StringComparison.Ordinal)) return new RgbaColor(70, 190, 110); + return new RgbaColor(180, 180, 180); + } + + private static int AddSchedules(SceneDocument doc, List schedules, Dictionary converted) + { + int skipped = 0; + foreach (var schedule in schedules) + { + var sceneSchedule = new SceneSchedule { ModelId = ModelId, Name = schedule.Name }; + foreach (var task in schedule.RootTasks) AddTask(task, sceneSchedule.Tasks); + if (sceneSchedule.Tasks.Count > 0) doc.Schedules.Add(sceneSchedule); + } + return skipped; + + void AddTask(IfcTask task, List target) + { + if (task.Time?.PlannedStartDate is not { } start || task.Time.PlannedFinishDate is not { } finish) + { + skipped++; + foreach (var sub in task.SubTasks) AddTask(sub, target); + return; + } + + var sceneTask = new SceneTask + { + Id = task.GlobalId, + Name = task.Name, + TaskType = task.TaskType == IfcTaskType.Demolition ? SceneTaskType.Demolish : SceneTaskType.Construct, + PlannedStart = Instant(start), + PlannedEnd = Instant(finish), + ActualStart = task.Time.ActualStartDate is { } actualStart ? Instant(actualStart) : null, + ActualEnd = task.Time.ActualFinishDate is { } actualEnd ? Instant(actualEnd) : null, + }; + foreach (var element in task.AssignedElements) + { + if (converted.TryGetValue(element, out long id)) sceneTask.ElementIds.Add(id); + } + target.Add(sceneTask); + foreach (var sub in task.SubTasks) AddTask(sub, sceneTask.Children); + } + } + + // IfcIO reads IFC date-times as UTC (converted to local time); a date set in memory without a kind is taken as UTC too. + private static DateTimeOffset Instant(DateTime value) => + value.Kind == DateTimeKind.Unspecified ? new DateTimeOffset(value, TimeSpan.Zero) : new DateTimeOffset(value).ToUniversalTime(); +} diff --git a/src/IfcIO.Scene/IfcSceneResult.cs b/src/IfcIO.Scene/IfcSceneResult.cs new file mode 100644 index 0000000..5820d0b --- /dev/null +++ b/src/IfcIO.Scene/IfcSceneResult.cs @@ -0,0 +1,63 @@ +using IfcIO.Schema; +using IOScene; + +namespace IfcIO.Scene; + +/// +/// The result of : the scene and what happened to the model's data. +/// +public sealed class IfcSceneResult +{ + internal IfcSceneResult( + SceneDocument document, + IReadOnlyList diagnostics, + IfcLengthUnit lengthUnit, + double metresPerUnit, + int elementCount, + int elementsWithGeometry, + int skippedTasks) + { + Document = document; + Diagnostics = diagnostics; + LengthUnit = lengthUnit; + MetresPerUnit = metresPerUnit; + ElementCount = elementCount; + ElementsWithGeometry = elementsWithGeometry; + SkippedTasks = skippedTasks; + } + + /// The scene, in metres with Z up. + public SceneDocument Document { get; } + + /// + /// Findings of the conversion. Each converted element without geometry has one entry with its GlobalId as + /// : no-geometry (information: IfcIO returned no mesh), + /// empty-mesh or invalid-mesh (warnings: a mesh without triangles, or with a non-finite coordinate, + /// an index out of range or an index count not divisible by 3). These elements stay in the scene without geometry. + /// + public IReadOnlyList Diagnostics { get; } + + /// The model's length unit () the geometry was converted from. + public IfcLengthUnit LengthUnit { get; } + + /// The exact factor applied to every coordinate and storey elevation: one in metres. + public double MetresPerUnit { get; } + + /// Number of converted IFC elements, with or without geometry. The storey nodes of the tree are not counted. + public int ElementCount { get; } + + /// Number of converted elements that have a mesh. + public int ElementsWithGeometry { get; } + + /// + /// Number of geometries. IfcIO bakes mapped items (IfcMappedItem) into each element's world mesh and does not + /// expose the shared representation, so every geometry is used by one instance, one per element with geometry. + /// + public int GeometryCount => Document.Geometries.Count; + + /// + /// Number of 4D tasks left out of the schedules because they have no planned start and finish (IfcIO reads task + /// times from IFC4 files only). Their subtasks are kept. + /// + public int SkippedTasks { get; } +} diff --git a/tests/IfcIO.Scene.Tests/Fixtures/placement.identity.txt b/tests/IfcIO.Scene.Tests/Fixtures/placement.identity.txt new file mode 100644 index 0000000..11f8899 --- /dev/null +++ b/tests/IfcIO.Scene.Tests/Fixtures/placement.identity.txt @@ -0,0 +1,11 @@ +# Golden identity of IfcIO.Scene for placement.ifc: the SceneModel.Key, then the SourceId of every scene element in +# document order. Changing what IfcIO.Scene writes here is a breaking release of IfcIO.Scene. +key placement.ifc +4YvctVUKr0kugbFTf53O9L +1YvctVUKr0kugbFTf53O9L +5YvctVUKr0kugbFTf53O9L +6YvctVUKr0kugbFTf53O9L +7YvctVUKr0kugbFTf53O9L +BYvctVUKr0kugbFTf53O9L +DYvctVUKr0kugbFTf53O9L +CYvctVUKr0kugbFTf53O9L diff --git a/tests/IfcIO.Scene.Tests/Fixtures/placement.ifc b/tests/IfcIO.Scene.Tests/Fixtures/placement.ifc new file mode 100644 index 0000000..7b660b6 --- /dev/null +++ b/tests/IfcIO.Scene.Tests/Fixtures/placement.ifc @@ -0,0 +1,112 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition [CoordinationView_V2.0]'),'2;1'); +FILE_NAME('placement.ifc','2026-01-01T00:00:00',(''),(''),'','',''); +FILE_SCHEMA(('IFC2X3')); +ENDSEC; +DATA; +#1=IFCPROJECT('0YvctVUKr0kugbFTf53O9L',$,'Placement',$,$,$,$,(#20),#10); +#10=IFCUNITASSIGNMENT((#11)); +#11=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.); +#20=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#21,$); +#21=IFCAXIS2PLACEMENT3D(#22,$,$); +#22=IFCCARTESIANPOINT((0.,0.,0.)); +#23=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#20,$,.MODEL_VIEW.,$); +#30=IFCDIRECTION((0.,0.,1.)); +#31=IFCDIRECTION((0.,1.,0.)); +#32=IFCDIRECTION((1.,0.,0.)); +#33=IFCDIRECTION((0.,1.)); +#34=IFCDIRECTION((-1.,0.,0.)); +#40=IFCSITE('2YvctVUKr0kugbFTf53O9L',$,'Site',$,$,#41,$,$,.ELEMENT.,$,$,$,$,$); +#41=IFCLOCALPLACEMENT($,#42); +#42=IFCAXIS2PLACEMENT3D(#43,$,$); +#43=IFCCARTESIANPOINT((1000.,2000.,0.)); +#50=IFCBUILDING('3YvctVUKr0kugbFTf53O9L',$,'Building',$,$,#51,$,$,.ELEMENT.,$,$,$); +#51=IFCLOCALPLACEMENT(#41,#52); +#52=IFCAXIS2PLACEMENT3D(#53,#30,#31); +#53=IFCCARTESIANPOINT((100.,0.,0.)); +#60=IFCBUILDINGSTOREY('4YvctVUKr0kugbFTf53O9L',$,'Level 1',$,$,#61,$,$,.ELEMENT.,3000.); +#61=IFCLOCALPLACEMENT(#51,#62); +#62=IFCAXIS2PLACEMENT3D(#63,$,$); +#63=IFCCARTESIANPOINT((0.,0.,3000.)); +#100=IFCBUILDINGELEMENTPROXY('1YvctVUKr0kugbFTf53O9L',$,'Panel',$,$,#101,#103,$,$); +#101=IFCLOCALPLACEMENT(#61,#102); +#102=IFCAXIS2PLACEMENT3D(#104,$,$); +#104=IFCCARTESIANPOINT((10.,20.,0.)); +#103=IFCPRODUCTDEFINITIONSHAPE($,$,(#105)); +#105=IFCSHAPEREPRESENTATION(#23,'Body','SurfaceModel',(#106)); +#106=IFCFACEBASEDSURFACEMODEL((#107)); +#107=IFCCONNECTEDFACESET((#108)); +#108=IFCFACE((#109)); +#109=IFCFACEOUTERBOUND(#110,.T.); +#110=IFCPOLYLOOP((#111,#112,#113)); +#111=IFCCARTESIANPOINT((0.,0.,0.)); +#112=IFCCARTESIANPOINT((1.,0.,0.)); +#113=IFCCARTESIANPOINT((0.,1.,0.)); +#200=IFCFLOWSEGMENT('5YvctVUKr0kugbFTf53O9L',$,'Pipe',$,$,#201,#203,$); +#201=IFCLOCALPLACEMENT(#61,#202); +#202=IFCAXIS2PLACEMENT3D(#204,$,$); +#204=IFCCARTESIANPOINT((0.,0.,0.)); +#203=IFCPRODUCTDEFINITIONSHAPE($,$,(#205)); +#205=IFCSHAPEREPRESENTATION(#23,'Body','SweptSolid',(#206)); +#206=IFCEXTRUDEDAREASOLID(#207,#209,#30,1000.); +#207=IFCRECTANGLEPROFILEDEF(.AREA.,$,#208,20.,10.); +#208=IFCAXIS2PLACEMENT2D(#210,#33); +#210=IFCCARTESIANPOINT((0.,0.)); +#209=IFCAXIS2PLACEMENT3D(#204,#32,#31); +#300=IFCREPRESENTATIONMAP(#301,#303); +#301=IFCAXIS2PLACEMENT3D(#302,$,$); +#302=IFCCARTESIANPOINT((0.,0.,100.)); +#303=IFCSHAPEREPRESENTATION(#23,'Body','Brep',(#304)); +#304=IFCFACETEDBREP(#305); +#305=IFCCLOSEDSHELL((#306)); +#306=IFCFACE((#307)); +#307=IFCFACEOUTERBOUND(#308,.T.); +#308=IFCPOLYLOOP((#111,#309,#310)); +#309=IFCCARTESIANPOINT((10.,0.,0.)); +#310=IFCCARTESIANPOINT((0.,10.,0.)); +#320=IFCFLOWFITTING('6YvctVUKr0kugbFTf53O9L',$,'Fitting A',$,$,#321,#323,$); +#321=IFCLOCALPLACEMENT($,#322); +#322=IFCAXIS2PLACEMENT3D(#204,$,$); +#323=IFCPRODUCTDEFINITIONSHAPE($,$,(#324)); +#324=IFCSHAPEREPRESENTATION(#23,'Body','MappedRepresentation',(#325)); +#325=IFCMAPPEDITEM(#300,#326); +#326=IFCCARTESIANTRANSFORMATIONOPERATOR3D(#31,#34,#327,2.,$); +#327=IFCCARTESIANPOINT((500.,0.,0.)); +#330=IFCFLOWFITTING('7YvctVUKr0kugbFTf53O9L',$,'Fitting B',$,$,#331,#333,$); +#331=IFCLOCALPLACEMENT($,#332); +#332=IFCAXIS2PLACEMENT3D(#334,$,$); +#334=IFCCARTESIANPOINT((0.,0.,50.)); +#333=IFCPRODUCTDEFINITIONSHAPE($,$,(#335)); +#335=IFCSHAPEREPRESENTATION(#23,'Body','MappedRepresentation',(#336)); +#336=IFCMAPPEDITEM(#300,#337); +#337=IFCCARTESIANTRANSFORMATIONOPERATOR3D($,$,#204,$,$); +#340=IFCFLOWFITTING('BYvctVUKr0kugbFTf53O9L',$,'Fitting C',$,$,#321,#343,$); +#343=IFCPRODUCTDEFINITIONSHAPE($,$,(#345)); +#345=IFCSHAPEREPRESENTATION(#23,'Body','MappedRepresentation',(#346)); +#346=IFCMAPPEDITEM(#300,#347); +#347=IFCCARTESIANTRANSFORMATIONOPERATOR3D(#34,$,#204,$,$); +#350=IFCFLOWFITTING('DYvctVUKr0kugbFTf53O9L',$,'Fitting D',$,$,#321,#353,$); +#353=IFCPRODUCTDEFINITIONSHAPE($,$,(#355)); +#355=IFCSHAPEREPRESENTATION(#23,'Body','MappedRepresentation',(#356)); +#356=IFCMAPPEDITEM(#300,#357); +#357=IFCCARTESIANTRANSFORMATIONOPERATOR3D($,$,#204,$,#32); +#600=IFCBEAM('CYvctVUKr0kugbFTf53O9L',$,'Beam',$,$,#321,#603,$); +#603=IFCPRODUCTDEFINITIONSHAPE($,$,(#605)); +#605=IFCSHAPEREPRESENTATION(#23,'Body','Clipping',(#606)); +#606=IFCBOOLEANCLIPPINGRESULT(.DIFFERENCE.,#607,#610); +#607=IFCEXTRUDEDAREASOLID(#608,#209,#30,1000.); +#608=IFCRECTANGLEPROFILEDEF(.AREA.,$,#609,20.,10.); +#609=IFCAXIS2PLACEMENT2D(#210,$); +#610=IFCHALFSPACESOLID(#611,.F.); +#611=IFCPLANE(#612); +#612=IFCAXIS2PLACEMENT3D(#613,$,$); +#613=IFCCARTESIANPOINT((0.,0.,5000.)); +#400=IFCDISTRIBUTIONPORT('8YvctVUKr0kugbFTf53O9L',$,'Pipe end',$,$,#401,$,.SOURCE.); +#401=IFCLOCALPLACEMENT(#201,#402); +#402=IFCAXIS2PLACEMENT3D(#403,#32,$); +#403=IFCCARTESIANPOINT((1000.,0.,0.)); +#410=IFCRELCONNECTSPORTTOELEMENT('9YvctVUKr0kugbFTf53O9L',$,$,$,#400,#200); +#500=IFCRELCONTAINEDINSPATIALSTRUCTURE('AYvctVUKr0kugbFTf53O9L',$,$,$,(#100,#200,#320,#330,#340,#350,#600),#60); +ENDSEC; +END-ISO-10303-21; diff --git a/tests/IfcIO.Scene.Tests/IfcIO.Scene.Tests.csproj b/tests/IfcIO.Scene.Tests/IfcIO.Scene.Tests.csproj new file mode 100644 index 0000000..7329a64 --- /dev/null +++ b/tests/IfcIO.Scene.Tests/IfcIO.Scene.Tests.csproj @@ -0,0 +1,26 @@ + + + + net8.0;net10.0 + false + $(NoWarn);CA1707 + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + diff --git a/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs b/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs new file mode 100644 index 0000000..a3f4548 --- /dev/null +++ b/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs @@ -0,0 +1,475 @@ +using System.Text.RegularExpressions; +using IfcIO.Geometry; +using IfcIO.Model; +using IfcIO.Schema; +using IOScene; +using Xunit; + +namespace IfcIO.Scene.Tests; + +public class IfcSceneReaderTests +{ + private const string Millimetre = "#11=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);"; + private const string Foot = """ + #11=IFCCONVERSIONBASEDUNIT(#12,.LENGTHUNIT.,'FOOT',#13); + #12=IFCDIMENSIONALEXPONENTS(1,0,0,0,0,0,0); + #13=IFCMEASUREWITHUNIT(IFCLENGTHMEASURE(0.3048),#14); + #14=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); + """; + private const string Hostile = "\">"; + + // Site far from the origin; storey 3000 up; Panel placed at (10,20,0) in the storey and styled red. + // Fitting A and B draw one representation map (origin (0,0,100)); B's mapping moves it by (0,250,0). + private static string PlacedIfc(string lengthUnit, string extra = "") => $""" + ISO-10303-21; + HEADER; + FILE_DESCRIPTION((''),'2;1'); + FILE_NAME('adapter.ifc','2026-01-01T00:00:00',(''),(''),'','',''); + FILE_SCHEMA(('IFC2X3')); + ENDSEC; + DATA; + #1=IFCPROJECT('0AdapterProject0000001',$,'Adapter',$,$,$,$,(#20),#10); + #10=IFCUNITASSIGNMENT((#11)); + {lengthUnit} + #20=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#21,$); + #21=IFCAXIS2PLACEMENT3D(#22,$,$); + #22=IFCCARTESIANPOINT((0.,0.,0.)); + #30=IFCSITE('0AdapterSite0000000001',$,'Site',$,$,#31,$,$,.ELEMENT.,$,$,$,$,$); + #31=IFCLOCALPLACEMENT($,#32); + #32=IFCAXIS2PLACEMENT3D(#33,$,$); + #33=IFCCARTESIANPOINT((412345678.,2000.,0.)); + #40=IFCBUILDINGSTOREY('0AdapterStorey00000001',$,'Level 1',$,$,#41,$,$,.ELEMENT.,3000.); + #41=IFCLOCALPLACEMENT(#31,#42); + #42=IFCAXIS2PLACEMENT3D(#43,$,$); + #43=IFCCARTESIANPOINT((0.,0.,3000.)); + #100=IFCBUILDINGELEMENTPROXY('1AdapterPanel000000001',$,'Panel',$,$,#101,#103,$,$); + #101=IFCLOCALPLACEMENT(#41,#102); + #102=IFCAXIS2PLACEMENT3D(#104,$,$); + #104=IFCCARTESIANPOINT((10.,20.,0.)); + #103=IFCPRODUCTDEFINITIONSHAPE($,$,(#105)); + #105=IFCSHAPEREPRESENTATION(#20,'Body','Brep',(#106)); + #106=IFCFACETEDBREP(#107); + #107=IFCCLOSEDSHELL((#108)); + #108=IFCFACE((#109)); + #109=IFCFACEOUTERBOUND(#110,.T.); + #110=IFCPOLYLOOP((#22,#111,#112)); + #111=IFCCARTESIANPOINT((100.,0.,0.)); + #112=IFCCARTESIANPOINT((0.,100.,0.)); + #120=IFCCOLOURRGB($,1.,0.,0.); + #121=IFCSURFACESTYLESHADING(#120); + #122=IFCSURFACESTYLE('Red',.BOTH.,(#121)); + #123=IFCSTYLEDITEM(#106,(#122),$); + #200=IFCREPRESENTATIONMAP(#201,#203); + #201=IFCAXIS2PLACEMENT3D(#202,$,$); + #202=IFCCARTESIANPOINT((0.,0.,100.)); + #203=IFCSHAPEREPRESENTATION(#20,'Body','Brep',(#204)); + #204=IFCFACETEDBREP(#107); + #300=IFCFLOWFITTING('1AdapterFittingA000001',$,'Fitting A',$,$,#301,#303,$); + #301=IFCLOCALPLACEMENT($,#302); + #302=IFCAXIS2PLACEMENT3D(#304,$,$); + #304=IFCCARTESIANPOINT((500.,0.,0.)); + #303=IFCPRODUCTDEFINITIONSHAPE($,$,(#305)); + #305=IFCSHAPEREPRESENTATION(#20,'Body','MappedRepresentation',(#306)); + #306=IFCMAPPEDITEM(#200,#307); + #307=IFCCARTESIANTRANSFORMATIONOPERATOR3D($,$,#22,$,$); + #310=IFCFLOWFITTING('1AdapterFittingB000001',$,'Fitting B',$,$,#301,#313,$); + #313=IFCPRODUCTDEFINITIONSHAPE($,$,(#315)); + #315=IFCSHAPEREPRESENTATION(#20,'Body','MappedRepresentation',(#316)); + #316=IFCMAPPEDITEM(#200,#317); + #317=IFCCARTESIANTRANSFORMATIONOPERATOR3D($,$,#318,$,$); + #318=IFCCARTESIANPOINT((0.,250.,0.)); + #400=IFCRELCONTAINEDINSPATIALSTRUCTURE('2AdapterContainment001',$,$,$,(#100,#300,#310),#40); + {extra} + ENDSEC; + END-ISO-10303-21; + """; + + private static readonly Vector3d[] Triangle = [new(0, 0, 0), new(100, 0, 0), new(0, 100, 0)]; + + private static string FixturePath(string name) => Path.Combine(AppContext.BaseDirectory, "Fixtures", name); + + [Fact] + public void Read_PlacementFixture_MatchesGoldenIdentity() + { + var expected = File.ReadAllLines(FixturePath("placement.identity.txt")).Where(l => !l.StartsWith('#')).ToList(); + + var result = Valid(IfcSceneReader.Read(FixturePath("placement.ifc"))); + + Assert.Equal(expected[0], "key " + Assert.Single(result.Document.Models).Key); + Assert.Equal(expected.Skip(1), result.Document.Elements.Select(e => e.SourceId)); + Assert.All(result.Document.Elements, e => Assert.Equal(e.SourceId, e.IfcGuid)); + } + + [Fact] + public void Read_File_DescribesTheModel() + { + string path = FixturePath("placement.ifc"); + + var model = Assert.Single(Valid(IfcSceneReader.Read(path)).Document.Models); + var keyed = Assert.Single(IfcSceneReader.Read(path, new IfcSceneOptions { ModelKey = "Tower A" }).Document.Models); + + Assert.Equal("placement.ifc", model.Key); + Assert.Equal("Tower A", keyed.Key); + Assert.Equal(path, model.Path); + Assert.Equal("Placement", model.Name); + Assert.Equal("ifc", model.SourceFormat); + Assert.Equal("mm", model.SourceLengthUnit); + Assert.Matches(@"^IfcIO\.Scene \d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$", model.Producer); + } + + [Fact] + public void Read_ModelWithoutKey_Throws() + { + var model = IfcModel.Create("No key"); + + var ex = Assert.Throws(() => IfcSceneReader.Read(model, new IfcSceneOptions())); + Assert.Contains("ModelKey", ex.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData(Millimetre, IfcLengthUnit.Millimetre, 0.001)] + [InlineData(Foot, IfcLengthUnit.Foot, 0.3048)] + public void Read_PlacedAndMappedElements_LandInWorldMetres(string lengthUnit, IfcLengthUnit expectedUnit, double metres) + { + var result = Valid(Read(OpenText(PlacedIfc(lengthUnit)))); + var doc = result.Document; + + Assert.Equal(expectedUnit, result.LengthUnit); + Assert.Equal(metres, result.MetresPerUnit); + AssertPoints(Offset(new(412345678 + 10, 2000 + 20, 3000), metres), GeometryOf(doc, 100).Vertices); + AssertPoints(Offset(new(500, 0, 100), metres), GeometryOf(doc, 300).Vertices); + AssertPoints(Offset(new(500, 250, 100), metres), GeometryOf(doc, 310).Vertices); + + // IfcIO bakes mapped items into world meshes: the two fittings are two geometries, each placed once. + Assert.Equal(3, result.GeometryCount); + Assert.Equal(3, doc.Instances.Count); + Assert.All(doc.Instances, i => Assert.Null(i.Transform)); + + var panel = Assert.Single(doc.Elements, e => e.Id == 100); + Assert.Equal("1AdapterPanel000000001", panel.SourceId); + Assert.Equal("1AdapterPanel000000001", panel.IfcGuid); + Assert.Equal("IFCBUILDINGELEMENTPROXY", panel.Category); + Assert.Equal(SceneNodeKind.Element, panel.Kind); + var red = Assert.Single(doc.Materials); + Assert.Equal(new RgbaColor(255, 0, 0), red.Color); + Assert.Equal(red.Id, InstanceOf(doc, 100).MaterialId); + } + + [Fact] + public void Read_Storeys_BecomeLevelsAndTreeNodes() + { + var doc = Valid(Read(OpenText(PlacedIfc(Millimetre)))).Document; + + var level = Assert.Single(doc.Levels); + Assert.Equal("Level 1", level.Name); + Assert.Equal(3.0, level.Elevation); + var storey = Assert.Single(doc.Elements, e => e.Kind == SceneNodeKind.Level); + Assert.Equal("0AdapterStorey00000001", storey.SourceId); + Assert.Equal("IFCBUILDINGSTOREY", storey.Category); + Assert.Null(storey.ParentId); + Assert.Equal(level.Id, storey.LevelId); + Assert.All(doc.Elements.Where(e => e != storey), e => + { + Assert.Equal(storey.Id, e.ParentId); + Assert.Equal(level.Id, e.LevelId); + }); + } + + [Fact] + public void Read_TypeObjectsAndLiningProperties_AreNotElements() + { + const string NonProducts = """ + #500=IFCWALLTYPE('1AdapterWallType000001',$,'WT',$,$,$,$,$,$,.STANDARD.); + #501=IFCDOORSTYLE('1AdapterDoorStyle00001',$,'DS',$,$,$,$,$,.SINGLE_SWING_LEFT.,.WOOD.,.F.,.F.); + #502=IFCDOORLININGPROPERTIES('1AdapterDoorLining0001',$,'DL',$,50.,$,$,$,$,$,$,$,$,$,$); + """; + var model = OpenText(PlacedIfc(Millimetre, NonProducts)); + long[] nonProducts = [500, 501, 502]; + // IfcIO lists them as elements, which is what the adapter has to undo. + Assert.All(nonProducts, id => Assert.Contains(model.AllElements, e => e.Id == id)); + + var result = Valid(Read(model)); + + Assert.Equal(new long[] { 100, 300, 310 }, ElementsOfKind(result.Document).Select(e => e.Id).Order()); + Assert.Equal(3, result.ElementCount); + Assert.Empty(result.Diagnostics); + } + + [Theory] + [InlineData(IfcLengthUnit.Millimetre, 0.001)] + [InlineData(IfcLengthUnit.Metre, 1.0)] + public void Read_SavedModel_ScalesFromItsLengthUnit(IfcLengthUnit unit, double metres) + { + var model = IfcModel.Create("Units"); + model.LengthUnit = unit; + model.AddMesh("Slab", [new(1000, 0, 0), new(2000, 0, 0), new(1000, 1500, 250)], [0, 1, 2], ifcType: "IFCSLAB"); + var opened = SaveAndOpen(model); + + var result = Valid(Read(opened)); + + Assert.Equal(unit, result.LengthUnit); + var source = Assert.Single(opened.AllElements).Mesh!.Vertices; + var vertices = Assert.Single(result.Document.Geometries).Vertices; + Assert.Equal(source.Select(v => new Vector3d(v.X * metres, v.Y * metres, v.Z * metres)), vertices); + Assert.Equal(new Vector3d(2000 * metres, 1500 * metres, 250 * metres), result.Document.ComputeBounds().Max); + } + + [Fact] + public void Read_SavedModel_KeepsPropertiesQuantitiesGlobalIdsAndReportsElementsWithoutGeometry() + { + var model = IfcModel.Create("Properties"); + var storey = model.AddStorey("Level 2", 3000); + var wall = model.AddMesh("Wall", Mm(Triangle), [0, 1, 2], new IfcMaterial { Name = "Brick", R = 1, G = 0.5, B = 0 }, storey, "IFCWALL"); + wall.GetOrCreatePropertySet("Pset_WallCommon").Add("FireRating", "EI 60"); + wall.GetOrCreateQuantitySet("Qto_WallBaseQuantities").AddLength("Length", 3000.5); + model.AddElement(new IfcElement { Id = 2, Name = "Annotation" }, storey); + var opened = SaveAndOpen(model); + var openedWall = Assert.Single(opened.AllElements, e => e.Name == "Wall"); + var openedEmpty = Assert.Single(opened.AllElements, e => e.Name == "Annotation"); + + var result = Valid(Read(opened)); + var doc = result.Document; + + var element = Assert.Single(doc.Elements, e => e.Id == openedWall.Id); + Assert.False(string.IsNullOrEmpty(openedWall.GlobalId)); + Assert.Equal(openedWall.GlobalId, element.SourceId); + Assert.Equal("IFCWALL", element.Category); + Assert.Equal("Level 2", Assert.Single(doc.Levels, l => l.Id == element.LevelId).Name); + + var sets = element.PropertySetIds.Select(id => Assert.Single(doc.PropertySets, s => s.Id == id)).ToList(); + Assert.Equal(["ifc:Pset_WallCommon", "ifc:Qto_WallBaseQuantities"], sets.Select(s => s.Key)); + Assert.Equal(["Pset_WallCommon", "Qto_WallBaseQuantities"], sets.Select(s => s.Name)); + var fireRating = Assert.Single(sets[0].Properties); + Assert.Equal("FireRating", fireRating.Name); + Assert.Equal("ifc:Pset_WallCommon.FireRating", fireRating.Key); + Assert.Equal(SceneValue.FromText("EI 60"), fireRating.Value); + var length = Assert.Single(sets[1].Properties); + Assert.Equal("ifc:Qto_WallBaseQuantities.Length", length.Key); + Assert.Equal(SceneValue.FromNumber(3000.5, null, "3000.5"), length.Value); + + var brick = Assert.Single(doc.Materials); + Assert.Equal("Brick", brick.Name); + Assert.Equal(new RgbaColor(255, 128, 0, 255), brick.Color); + + Assert.Equal(2, result.ElementCount); + Assert.Equal(1, result.ElementsWithGeometry); + var issue = Assert.Single(result.Diagnostics); + Assert.Equal((SceneSeverity.Info, "no-geometry", openedEmpty.GlobalId), (issue.Severity, issue.Code, issue.SourceId)); + Assert.DoesNotContain(doc.Instances, i => i.ElementId == openedEmpty.Id); + Assert.Contains(doc.Elements, e => e.Id == openedEmpty.Id); + } + + [Fact] + public void Read_SharedPropertySet_IsOneSceneSet() + { + const string Shared = """ + #600=IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('P-1'),$); + #601=IFCPROPERTYSET('1AdapterSharedPset0001',$,'Pset_Shared',$,(#600)); + #602=IFCRELDEFINESBYPROPERTIES('1AdapterSharedRel00001',$,$,$,(#300,#310),#601); + """; + + var doc = Valid(Read(OpenText(PlacedIfc(Millimetre, Shared)))).Document; + + var set = Assert.Single(doc.PropertySets); + Assert.Equal("ifc:Pset_Shared", set.Key); + Assert.Equal([set.Id], Assert.Single(doc.Elements, e => e.Id == 300).PropertySetIds); + Assert.Equal([set.Id], Assert.Single(doc.Elements, e => e.Id == 310).PropertySetIds); + Assert.Equal(SceneValue.FromText("P-1"), Assert.Single(set.Properties).Value); + } + + [Fact] + public void Read_HostileStrings_SurviveSceneJson() + { + var model = IfcModel.Create("Hostile"); + var wall = model.AddMesh(Hostile, Mm(Triangle), [0, 1, 2], ifcType: "IFCWALL"); + wall.GetOrCreatePropertySet(Hostile).Add(Hostile, Hostile); + var opened = SaveAndOpen(model); + + var doc = Valid(Read(opened)).Document; + var read = SceneJson.Read(SceneJson.Write(doc)); + + var element = Assert.Single(ElementsOfKind(read)); + Assert.Equal(Hostile, element.Name); + var property = Assert.Single(Assert.Single(read.PropertySets).Properties); + Assert.Equal((Hostile, $"ifc:{Hostile}.{Hostile}", Hostile), (property.Name, property.Key, property.Value.Text)); + } + + [Fact] + public void Read_MaterialAlpha_BecomesColourAlphaAndElementsWithoutMaterialGetClassColour() + { + // IfcIO does not read transparency from files yet, so the material is set in memory. + var model = IfcModel.Create("Glass"); + model.AddMesh("Pane", Mm(Triangle), [0, 1, 2], new IfcMaterial { Name = "Glass", R = 0.2, G = 0.4, B = 0.6, Alpha = 0.35 }, ifcType: "IFCPLATE"); + var window = model.AddMesh("Window", Mm(Triangle), [0, 1, 2], ifcType: "IFCWINDOW"); + + var doc = Valid(Read(model)).Document; + + var glass = Assert.Single(doc.Materials); + Assert.Equal((51, 102, 153), (glass.Color.R, glass.Color.G, glass.Color.B)); + Assert.Equal(0.35, glass.Color.Af, 0.5 / 255); + var instance = InstanceOf(doc, window.Id); + Assert.Null(instance.MaterialId); + Assert.Equal(0.5, instance.Color!.Value.Af, 0.5 / 255); + } + + [Fact] + public void Read_SpacesOnlyWhenIncluded() + { + var model = IfcModel.Create("Spaces"); + model.AddMesh("Wall", Mm(Triangle), [0, 1, 2], ifcType: "IFCWALL"); + var space = model.AddSpace("Room 1", mesh: IfcMesh.Create(1, "Room 1", Mm(Triangle), [0, 1, 2])); + + Assert.DoesNotContain(Valid(Read(model)).Document.Elements, e => e.Id == space.Id); + + var doc = Valid(Read(model, new IfcSceneOptions { ModelKey = "spaces", IncludeSpaces = true })).Document; + Assert.Equal("IFCSPACE", Assert.Single(doc.Elements, e => e.Id == space.Id).Category); + Assert.Equal(0.3, InstanceOf(doc, space.Id).Color!.Value.Af, 0.5 / 255); + } + + [Fact] + public void Read_DuplicateElementIds_Throws() + { + // A model built in memory numbers elements per storey, so two storeys both start at 1. + var model = IfcModel.Create("Duplicates"); + model.AddMesh("A", Mm(Triangle), [0, 1, 2]); + model.AddMesh("B", Mm(Triangle), [0, 1, 2], storey: model.AddStorey("Level 2", 3000)); + + var ex = Assert.Throws(() => Read(model)); + Assert.Contains("used twice", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void Read_InvalidMesh_IsReportedNotDrawn() + { + var model = IfcModel.Create("Invalid"); + model.AddMesh("Bad", Mm(Triangle), [0, 1, 7]); + model.AddMesh("Empty", Mm(Triangle), []); + + var result = Valid(Read(model)); + + Assert.Empty(result.Document.Geometries); + Assert.Empty(result.Document.Instances); + Assert.Equal( + [(SceneSeverity.Warning, "invalid-mesh", (string?)null), (SceneSeverity.Warning, "empty-mesh", null)], + result.Diagnostics.Select(d => (d.Severity, d.Code, d.SourceId))); + } + + [Fact] + public void Read_WorkSchedule_BecomesScheduleWithElements() + { + var model = IfcModel.Create("Schedule", IfcSchema.Ifc4); + var wall = model.AddMesh("Wall", Mm(Triangle), [0, 1, 2], ifcType: "IFCWALL"); + var schedule = model.AddWorkSchedule("Build"); + var start = new DateTime(2026, 3, 1, 8, 0, 0, DateTimeKind.Utc); + schedule.AddTask("Erect walls", "T1", start, start.AddDays(9)).AssignElement(wall); + schedule.AddTask("Undated", "T2"); + var opened = SaveAndOpen(model); + + var result = Valid(Read(opened)); + + var sceneSchedule = Assert.Single(result.Document.Schedules); + Assert.Equal("Build", sceneSchedule.Name); + Assert.Equal(1, sceneSchedule.ModelId); + var task = Assert.Single(sceneSchedule.Tasks); + Assert.Equal("Erect walls", task.Name); + Assert.Equal(new DateTimeOffset(start), task.PlannedStart); + Assert.Equal(new DateTimeOffset(start.AddDays(9)), task.PlannedEnd); + Assert.Equal([Assert.Single(opened.AllElements).Id], task.ElementIds); + Assert.Equal(1, result.SkippedTasks); + } + + [Fact] + public void Read_Ifc4TriangulatedFaceSet_IsReportedWithoutGeometry() + { + // Documented limit: IfcIO's reader returns no mesh for IfcTriangulatedFaceSet, even the one it writes itself. + // When this test fails, IfcIO reads it, and the limit in docs/scene-adapter.md goes. + var model = IfcModel.Create("Tessellated", IfcSchema.Ifc4); + model.AddMesh("Slab", Mm(Triangle), [0, 1, 2], ifcType: "IFCSLAB"); + using var writer = new StringWriter(); + model.Save(writer); + Assert.Contains("IFCTRIANGULATEDFACESET(", writer.ToString(), StringComparison.Ordinal); + + var result = Valid(Read(OpenText(writer.ToString()))); + + Assert.Empty(result.Document.Geometries); + Assert.Equal("no-geometry", Assert.Single(result.Diagnostics).Code); + } + + [IfcFileFact] + public void Read_ConfiguredIfcFile_ConvertsEveryElement() + { + string path = Environment.GetEnvironmentVariable(IfcFileFactAttribute.Variable)!; + var model = IfcModel.Open(path); + + var result = Valid(IfcSceneReader.Read(path)); + + Assert.Equal( + model.AllElements.Count(e => !Regex.IsMatch(e.IfcType, "(TYPE|STYLE|PROPERTIES)$", RegexOptions.IgnoreCase)), + result.ElementCount); + Assert.Equal(result.ElementsWithGeometry, result.GeometryCount); + Assert.All(ElementsOfKind(result.Document), e => Assert.False(string.IsNullOrEmpty(e.SourceId))); + Assert.NotEmpty(SceneJson.Write(result.Document)); + } + + // Every converted fixture must have no Validate() errors. Warnings are allowed: the made-up GlobalIds of + // placement.ifc start with 4 to D and draw ifc-guid warnings. + private static IfcSceneResult Valid(IfcSceneResult result) + { + Assert.DoesNotContain(result.Document.Validate(), d => d.Severity == SceneSeverity.Error); + return result; + } + + private static IfcSceneResult Read(IfcModel model, IfcSceneOptions? options = null) => + IfcSceneReader.Read(model, options ?? new IfcSceneOptions { ModelKey = "test.ifc" }); + + private static IfcModel OpenText(string ifc) + { + using var reader = new StringReader(ifc); + return IfcModel.Open(reader); + } + + // Models are saved as IFC2x3 (faceted breps) unless a test needs IFC4: IfcIO does not read back the + // IfcTriangulatedFaceSet it writes for IFC4. + private static IfcModel SaveAndOpen(IfcModel model) + { + using var writer = new StringWriter(); + model.Save(writer); + return OpenText(writer.ToString()); + } + + private static IEnumerable ElementsOfKind(SceneDocument doc) => doc.Elements.Where(e => e.Kind == SceneNodeKind.Element); + + private static IfcVector3[] Mm(Vector3d[] points) => [.. points.Select(p => new IfcVector3(p.X, p.Y, p.Z))]; + + private static SceneInstance InstanceOf(SceneDocument doc, long elementId) => Assert.Single(doc.Instances, i => i.ElementId == elementId); + + private static SceneGeometry GeometryOf(SceneDocument doc, long elementId) => + Assert.Single(doc.Geometries, g => g.Id == InstanceOf(doc, elementId).GeometryId); + + private static Vector3d[] Offset(Vector3d origin, double metres) => + [.. Triangle.Select(p => (p + origin) * metres)]; + + private static void AssertPoints(Vector3d[] expected, IReadOnlyList actual) + { + Assert.Equal(expected.Length, actual.Count); + for (int i = 0; i < expected.Length; i++) + { + // 1e-6 m: far tighter than float precision at 400 km from the origin, so doubles must be kept. + Assert.True(Vector3d.Distance(expected[i], actual[i]) <= 1e-6, $"Vertex {i}: expected {expected[i]}, actual {actual[i]}"); + } + } +} + +/// Runs only when names an IFC file; the repository ships none of real size. +public sealed class IfcFileFactAttribute : FactAttribute +{ + public const string Variable = "IFCIO_SCENE_IFC_FILE"; + + public IfcFileFactAttribute() + { + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable(Variable))) + { + Skip = $"Set {Variable} to an IFC file to run this test."; + } + } +} From d239593a5f08aab122e0e30a8f6d223d20e572fa Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:53:25 +0300 Subject: [PATCH 02/10] Document the IfcIO.Scene adapter and add it to the docs build A scene adapter page gives usage, the mapping and the limits, including that IfcIO reads no geometry from IfcTriangulatedFaceSet. docfx metadata and the docs workflow paths cover src/IfcIO.Scene; README, CHANGELOG and CONTRIBUTING mention the adapter and how to restore an IOScene preview. --- .github/workflows/docs.yml | 2 + CHANGELOG.md | 3 ++ CONTRIBUTING.md | 8 ++++ README.md | 2 + docfx.json | 3 +- docs/scene-adapter.md | 78 ++++++++++++++++++++++++++++++++++++++ docs/toc.yml | 2 + 7 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 docs/scene-adapter.md diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index adaab88..bb456ce 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -8,6 +8,7 @@ on: - 'docfx.json' - '.config/dotnet-tools.json' - 'src/IfcIO/**' + - 'src/IfcIO.Scene/**' - '.github/workflows/docs.yml' pull_request: paths: @@ -15,6 +16,7 @@ on: - 'docfx.json' - '.config/dotnet-tools.json' - 'src/IfcIO/**' + - 'src/IfcIO.Scene/**' - '.github/workflows/docs.yml' workflow_dispatch: diff --git a/CHANGELOG.md b/CHANGELOG.md index 11e60ac..76db16e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to the `IfcIO` library are documented in this file. ## [Unreleased] +### Added +- **`IfcIO.Scene`**: `IfcSceneReader` converts an IFC file or `IfcModel` into an `IOScene` `SceneDocument` in metres with Z up (GlobalIds as `SourceId` and `IfcGuid`, storeys as levels and tree nodes, property and quantity sets keyed `ifc:`, materials, 4D schedules), with diagnostics for elements without geometry. Not packed until `IOScene` 0.1.0 is released. See `docs/scene-adapter.md`. + ### Fixed - **World placement of geometry read from IFC (`IfcModel.Open`)**: element meshes are now in world coordinates. Previously object placements were ignored and every element sat at its local origin. - `IfcLocalPlacement` chains (`PlacementRelTo`) are resolved up to the root, relative to the `WorldCoordinateSystem` of the 3D `Model` representation context. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bdcc574..f9d6a63 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,14 @@ Report security vulnerabilities privately through the **Security** tab. - Keep nullable reference types enabled. - Warnings are treated as errors. +## Scene adapter + +`src/IfcIO.Scene` converts IfcIO models into `IOScene` documents and uses only the public API of `IfcIO`; the `IfcIO` +package itself keeps no dependencies. `IOScene` is referenced with the range `[0.1.0-preview.1, 0.2.0-0)`. To build +against an `IOScene` preview that is not on NuGet.org yet, restore from a local folder of packages: +`dotnet build IfcIO.slnx -p:RestoreAdditionalProjectSources=`. Changing the `SceneModel.Key` or `SourceId` +the adapter writes, or its `ifc:` key format, fails the golden identity test and is a breaking release. + ## Releases & NuGet Publishing Push a tag matching the package version, such as `v0.1.0`, to trigger the `release.yml` workflow. diff --git a/README.md b/README.md index 9e0f49b..b11c6ca 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,8 @@ model.Save("Building_4D_5D.ifc"); See the [public API](docs/public-api.md) for the supported surface and [getting started guide](docs/getting-started.md) for detailed workflows. +`IfcIO.Scene` (in this repository, not yet published) converts IFC models into `IOScene` scene documents for viewers and coordination tools; see the [scene adapter](docs/scene-adapter.md) page for usage and limits. + ## Documentation The documentation site with API references and guides is at . [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/IOExtend/IfcIO) offers an automatically generated overview of the repository. diff --git a/docfx.json b/docfx.json index af85fd5..7bcdef3 100644 --- a/docfx.json +++ b/docfx.json @@ -2,7 +2,8 @@ "metadata": [ { "src": [ - { "src": "src/IfcIO", "files": ["IfcIO.csproj"] } + { "src": "src/IfcIO", "files": ["IfcIO.csproj"] }, + { "src": "src/IfcIO.Scene", "files": ["IfcIO.Scene.csproj"] } ], "dest": "docs/api", "properties": { "TargetFramework": "net10.0" }, diff --git a/docs/scene-adapter.md b/docs/scene-adapter.md new file mode 100644 index 0000000..ad43e21 --- /dev/null +++ b/docs/scene-adapter.md @@ -0,0 +1,78 @@ +--- +title: Scene adapter +--- + +# IfcIO.Scene: IFC to IOScene + +`IfcIO.Scene` converts an IFC file read by IfcIO into an `IOScene` `SceneDocument`: one scene model in metres with +Z up, with elements, storeys, property sets, quantities, materials and 4D schedules. Viewers and coordination tools +that read scenes work from it. The scene model and its rules are specified in the +[IOScene contract](https://github.com/IOExtend/IOScene/blob/main/docs/contract.md); property keys are registered in +its [key registry](https://github.com/IOExtend/IOScene/blob/main/docs/keys.md). + +Status: preview. The project is built and tested with IfcIO but not published to NuGet until `IOScene` 0.1.0 is +released. It references `IOScene` with the range `[0.1.0-preview.1, 0.2.0-0)`. + +## Usage + +```csharp +using IfcIO.Scene; +using IOScene; + +var result = IfcSceneReader.Read("house.ifc"); +foreach (var diagnostic in result.Diagnostics) +{ + Console.WriteLine($"{diagnostic.Severity} {diagnostic.Code} {diagnostic.SourceId}: {diagnostic.Message}"); +} +SceneJson.Save(result.Document, "house.ioscene.json"); +``` + +`IfcSceneReader.Read(path, options)` opens `.ifc` and `.ifczip` files with `IfcModel.Open`. A model already in memory +is converted with `IfcSceneReader.Read(model, options)`; it has no file name, so `IfcSceneOptions.ModelKey` is +required. `IfcSceneOptions.IncludeSpaces` also converts spaces (off by default, because closed room volumes hide the +elements inside them). + +`IfcSceneResult` holds the `Document`, the `Diagnostics` of the conversion, the model's `LengthUnit` and the exact +`MetresPerUnit` factor, and the counts `ElementCount`, `ElementsWithGeometry`, `GeometryCount` and `SkippedTasks`. + +## Mapping + +| IFC, as IfcIO reads it | Scene | +|---|---| +| the file | one `SceneModel`: `Key` = `ModelKey`, else the file name without directory (`house.ifc`); `SourceFormat` `ifc`; `SourceLengthUnit` `mm`, `m`, `ft` or `in`; `Producer` `IfcIO.Scene ` | +| storey | a `SceneLevel` (elevation in metres) and a tree node of kind `Level` with the storey's GlobalId | +| element (`IfcModel.AllElements`) | a `SceneElement` of kind `Element`, child of its storey node: `Id` = STEP id, `SourceId` = `IfcGuid` = GlobalId, `Category` = IFC class in upper case, `TypeName` = type product name | +| property set, quantity set | a `ScenePropertySet` with key `ifc:`, properties `ifc:.`; a set shared by elements in the file is one scene set | +| property value | `Text`, as IfcIO reads it | +| quantity value | `Number` with `Unit` null: the value is in the file's units, not converted | +| element mesh | one `SceneGeometry` (triangles, metres, world coordinates) placed once by a `SceneInstance` without transform | +| surface style colour | a `SceneMaterial` (alpha is the opacity); elements without one get a colour for their IFC class on the instance | +| work schedule | a `SceneSchedule` per work schedule with tasks; task `Id` = task GlobalId, elements by scene id | + +`(SceneModel.Key, SourceId)` is the identity that may be stored outside the scene. How this adapter derives both is +frozen and covered by a golden identity test (`tests/IfcIO.Scene.Tests/Fixtures/placement.identity.txt`); changing it +is a breaking release. + +## Limits + +- **IFC4 tessellated geometry is not read.** IfcIO's reader returns no mesh for `IfcTriangulatedFaceSet`, including + the one IfcIO itself writes for IFC4 models: the point and index lists lose their inner brackets while being parsed, + so no triangle is found. Such elements are in the scene without geometry and reported as `no-geometry`. Verified + with an IFC4 model saved by IfcIO and opened again; a test pins this behaviour. +- Mapped items (`IfcMappedItem`) are baked into each element's world mesh, so shared geometry is not shared in the + scene. +- IfcIO places an element in a storey only through `IfcRelContainedInSpatialStructure`. Elements contained in the site + or building, and spaces aggregated to a storey (`IfcRelAggregates`), are in the first storey. A file without storeys + gets IfcIO's default storey `Level 1`, which has no GlobalId. Site, building and project are not tree nodes. +- IfcIO lists type objects (`IfcWallType`, `IfcDoorStyle`) and door and window lining and panel properties among the + elements; the adapter skips them. Type property sets are not converted. +- Property values are text even when the IFC value is a number or a boolean: IfcIO keeps only the text. +- Quantities are numbers without a unit. For a file whose length unit IfcIO converts to metres while reading (for + example centimetres), geometry is in metres but quantities stay in file units. +- An element without a GlobalId in the file gets a new random GlobalId from IfcIO each time it is read, so its + `SourceId` is not stable. +- Transparency is not read from files by IfcIO; materials built in memory keep their alpha. +- Tasks without planned start and finish are left out (IfcIO reads task times from IFC4 files only); their subtasks + are kept. Task costs and progress are not converted. Openings, ports, grids, zones, systems and cost schedules are + not converted. +- The application, author and creation date of the file are not read by IfcIO, so the scene model leaves them empty. diff --git a/docs/toc.yml b/docs/toc.yml index 9382dcb..ece455a 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -5,6 +5,8 @@ items: - name: Public API href: public-api.md + - name: Scene adapter + href: scene-adapter.md - name: API reference href: api/ - name: GitHub From aecdfe20673f61a6e939ef80d17c035c76a6b317 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:53:57 +0300 Subject: [PATCH 03/10] Publish NuGet packages before creating the GitHub release The NuGet login and push now run above "Create or update release", so a refused first publication of a package id can be fixed and the job run again. The release notes are extracted before anything is published, and a tag with a hyphen creates a prerelease. --- .github/workflows/release.yml | 50 +++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index daf36a4..c61568e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,17 @@ jobs: if ($env:GITHUB_REF_NAME -and $env:GITHUB_REF_NAME.StartsWith("v") -and $env:GITHUB_REF_NAME -cne "v$($version.Trim())") { throw "The release tag must match the package version v$($version.Trim())." } + # Before anything is published: a commit without a changelog section for the version fails here. + - name: Extract release notes + shell: pwsh + run: | + $version = if ($env:GITHUB_REF_NAME -and $env:GITHUB_REF_NAME.StartsWith("v")) { $env:GITHUB_REF_NAME.Substring(1) } else { (dotnet msbuild src/IfcIO/IfcIO.csproj -getProperty:Version -nologo).Trim() } + $changelog = Get-Content -LiteralPath CHANGELOG.md -Encoding utf8 + $start = [array]::FindIndex($changelog, [Predicate[string]] { param($line) $line.StartsWith("## [$version]") }) + if ($start -lt 0) { throw "CHANGELOG.md has no entry for $version" } + $notes = @() + for ($i = $start + 1; $i -lt $changelog.Count -and -not $changelog[$i].StartsWith('## ['); $i++) { $notes += $changelog[$i] } + $notes | Set-Content -LiteralPath release-notes.md -Encoding utf8 - run: dotnet restore IfcIO.slnx - run: dotnet build IfcIO.slnx -c Release --no-restore - run: dotnet test IfcIO.slnx -c Release --no-build @@ -37,39 +48,38 @@ jobs: with: name: packages path: artifacts/* + # The push comes before the GitHub release: a refused first publication of a package id can be fixed and the + # job run again, because --skip-duplicate makes the push repeatable. + # Trusted publishing: the NuGet policy for this repository and workflow file exchanges the GitHub OIDC token for a short-lived key. + - uses: NuGet/login@v1 + id: nuget-login + with: + user: mahach + - name: Publish NuGet packages + shell: pwsh + run: | + foreach ($package in Get-ChildItem artifacts -Filter *.nupkg) { + dotnet nuget push $package.FullName --api-key '${{ steps.nuget-login.outputs.NUGET_API_KEY }}' --source https://api.nuget.org/v3/index.json --skip-duplicate + if ($LASTEXITCODE -ne 0) { throw "dotnet nuget push failed for $($package.Name) with exit code $LASTEXITCODE" } + } - name: Create or update release shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | $version = if ($env:GITHUB_REF_NAME -and $env:GITHUB_REF_NAME.StartsWith("v")) { $env:GITHUB_REF_NAME.Substring(1) } else { (dotnet msbuild src/IfcIO/IfcIO.csproj -getProperty:Version -nologo).Trim() } - $changelog = Get-Content -LiteralPath CHANGELOG.md -Encoding utf8 - $start = [array]::FindIndex($changelog, [Predicate[string]] { param($line) $line.StartsWith("## [$version]") }) - if ($start -lt 0) { throw "CHANGELOG.md has no entry for $version" } - $notes = @() - for ($i = $start + 1; $i -lt $changelog.Count -and -not $changelog[$i].StartsWith('## ['); $i++) { $notes += $changelog[$i] } - $notes | Set-Content -LiteralPath release-notes.md -Encoding utf8 $files = @(Get-ChildItem artifacts -File | ForEach-Object { $_.FullName }) $tagName = if ($env:GITHUB_REF_NAME -and $env:GITHUB_REF_NAME.StartsWith("v")) { $env:GITHUB_REF_NAME } else { "v$version" } + # A tag with a hyphen is a prerelease. + $flags = @() + if ($tagName.Contains('-')) { $flags += '--prerelease' } $exists = gh release view $tagName 2>&1 if ($LASTEXITCODE -eq 0) { Write-Host "Updating existing release $tagName..." - gh release edit $tagName --notes-file release-notes.md + gh release edit $tagName --notes-file release-notes.md @flags gh release upload $tagName @files --clobber } else { Write-Host "Creating release $tagName..." - gh release create $tagName --title "IfcIO $tagName" --notes-file release-notes.md @files - } - # Trusted publishing: the NuGet policy for this repository and workflow file exchanges the GitHub OIDC token for a short-lived key. - - uses: NuGet/login@v1 - id: nuget-login - with: - user: mahach - - name: Publish NuGet packages - shell: pwsh - run: | - foreach ($package in Get-ChildItem artifacts -Filter *.nupkg) { - dotnet nuget push $package.FullName --api-key '${{ steps.nuget-login.outputs.NUGET_API_KEY }}' --source https://api.nuget.org/v3/index.json --skip-duplicate - if ($LASTEXITCODE -ne 0) { throw "dotnet nuget push failed for $($package.Name) with exit code $LASTEXITCODE" } + gh release create $tagName --title "IfcIO $tagName" --notes-file release-notes.md @flags @files } From ee012934bb0d65811e512fca4535a874aa906ca2 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:59:43 +0300 Subject: [PATCH 04/10] Give the scene fixture valid GlobalIds and require an empty Validate() The GlobalIds of placement.ifc started with 4 to D, which is not a valid IFC GlobalId, so the golden identity froze invalid values and Validate() reported ifc-guid warnings. Every converted fixture now has to pass Validate() with no entries; only the test on a configured external file still allows warnings. --- .../Fixtures/placement.identity.txt | 16 +++++------ .../IfcIO.Scene.Tests/Fixtures/placement.ifc | 28 +++++++++---------- .../IfcIO.Scene.Tests/IfcSceneReaderTests.cs | 9 +++--- 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/tests/IfcIO.Scene.Tests/Fixtures/placement.identity.txt b/tests/IfcIO.Scene.Tests/Fixtures/placement.identity.txt index 11f8899..ea4015e 100644 --- a/tests/IfcIO.Scene.Tests/Fixtures/placement.identity.txt +++ b/tests/IfcIO.Scene.Tests/Fixtures/placement.identity.txt @@ -1,11 +1,11 @@ # Golden identity of IfcIO.Scene for placement.ifc: the SceneModel.Key, then the SourceId of every scene element in # document order. Changing what IfcIO.Scene writes here is a breaking release of IfcIO.Scene. key placement.ifc -4YvctVUKr0kugbFTf53O9L -1YvctVUKr0kugbFTf53O9L -5YvctVUKr0kugbFTf53O9L -6YvctVUKr0kugbFTf53O9L -7YvctVUKr0kugbFTf53O9L -BYvctVUKr0kugbFTf53O9L -DYvctVUKr0kugbFTf53O9L -CYvctVUKr0kugbFTf53O9L +04vctVUKr0kugbFTf53O9L +01vctVUKr0kugbFTf53O9L +05vctVUKr0kugbFTf53O9L +06vctVUKr0kugbFTf53O9L +07vctVUKr0kugbFTf53O9L +0BvctVUKr0kugbFTf53O9L +0DvctVUKr0kugbFTf53O9L +0CvctVUKr0kugbFTf53O9L diff --git a/tests/IfcIO.Scene.Tests/Fixtures/placement.ifc b/tests/IfcIO.Scene.Tests/Fixtures/placement.ifc index 7b660b6..b61e39c 100644 --- a/tests/IfcIO.Scene.Tests/Fixtures/placement.ifc +++ b/tests/IfcIO.Scene.Tests/Fixtures/placement.ifc @@ -5,7 +5,7 @@ FILE_NAME('placement.ifc','2026-01-01T00:00:00',(''),(''),'','',''); FILE_SCHEMA(('IFC2X3')); ENDSEC; DATA; -#1=IFCPROJECT('0YvctVUKr0kugbFTf53O9L',$,'Placement',$,$,$,$,(#20),#10); +#1=IFCPROJECT('00vctVUKr0kugbFTf53O9L',$,'Placement',$,$,$,$,(#20),#10); #10=IFCUNITASSIGNMENT((#11)); #11=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.); #20=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#21,$); @@ -17,19 +17,19 @@ DATA; #32=IFCDIRECTION((1.,0.,0.)); #33=IFCDIRECTION((0.,1.)); #34=IFCDIRECTION((-1.,0.,0.)); -#40=IFCSITE('2YvctVUKr0kugbFTf53O9L',$,'Site',$,$,#41,$,$,.ELEMENT.,$,$,$,$,$); +#40=IFCSITE('02vctVUKr0kugbFTf53O9L',$,'Site',$,$,#41,$,$,.ELEMENT.,$,$,$,$,$); #41=IFCLOCALPLACEMENT($,#42); #42=IFCAXIS2PLACEMENT3D(#43,$,$); #43=IFCCARTESIANPOINT((1000.,2000.,0.)); -#50=IFCBUILDING('3YvctVUKr0kugbFTf53O9L',$,'Building',$,$,#51,$,$,.ELEMENT.,$,$,$); +#50=IFCBUILDING('03vctVUKr0kugbFTf53O9L',$,'Building',$,$,#51,$,$,.ELEMENT.,$,$,$); #51=IFCLOCALPLACEMENT(#41,#52); #52=IFCAXIS2PLACEMENT3D(#53,#30,#31); #53=IFCCARTESIANPOINT((100.,0.,0.)); -#60=IFCBUILDINGSTOREY('4YvctVUKr0kugbFTf53O9L',$,'Level 1',$,$,#61,$,$,.ELEMENT.,3000.); +#60=IFCBUILDINGSTOREY('04vctVUKr0kugbFTf53O9L',$,'Level 1',$,$,#61,$,$,.ELEMENT.,3000.); #61=IFCLOCALPLACEMENT(#51,#62); #62=IFCAXIS2PLACEMENT3D(#63,$,$); #63=IFCCARTESIANPOINT((0.,0.,3000.)); -#100=IFCBUILDINGELEMENTPROXY('1YvctVUKr0kugbFTf53O9L',$,'Panel',$,$,#101,#103,$,$); +#100=IFCBUILDINGELEMENTPROXY('01vctVUKr0kugbFTf53O9L',$,'Panel',$,$,#101,#103,$,$); #101=IFCLOCALPLACEMENT(#61,#102); #102=IFCAXIS2PLACEMENT3D(#104,$,$); #104=IFCCARTESIANPOINT((10.,20.,0.)); @@ -43,7 +43,7 @@ DATA; #111=IFCCARTESIANPOINT((0.,0.,0.)); #112=IFCCARTESIANPOINT((1.,0.,0.)); #113=IFCCARTESIANPOINT((0.,1.,0.)); -#200=IFCFLOWSEGMENT('5YvctVUKr0kugbFTf53O9L',$,'Pipe',$,$,#201,#203,$); +#200=IFCFLOWSEGMENT('05vctVUKr0kugbFTf53O9L',$,'Pipe',$,$,#201,#203,$); #201=IFCLOCALPLACEMENT(#61,#202); #202=IFCAXIS2PLACEMENT3D(#204,$,$); #204=IFCCARTESIANPOINT((0.,0.,0.)); @@ -65,7 +65,7 @@ DATA; #308=IFCPOLYLOOP((#111,#309,#310)); #309=IFCCARTESIANPOINT((10.,0.,0.)); #310=IFCCARTESIANPOINT((0.,10.,0.)); -#320=IFCFLOWFITTING('6YvctVUKr0kugbFTf53O9L',$,'Fitting A',$,$,#321,#323,$); +#320=IFCFLOWFITTING('06vctVUKr0kugbFTf53O9L',$,'Fitting A',$,$,#321,#323,$); #321=IFCLOCALPLACEMENT($,#322); #322=IFCAXIS2PLACEMENT3D(#204,$,$); #323=IFCPRODUCTDEFINITIONSHAPE($,$,(#324)); @@ -73,7 +73,7 @@ DATA; #325=IFCMAPPEDITEM(#300,#326); #326=IFCCARTESIANTRANSFORMATIONOPERATOR3D(#31,#34,#327,2.,$); #327=IFCCARTESIANPOINT((500.,0.,0.)); -#330=IFCFLOWFITTING('7YvctVUKr0kugbFTf53O9L',$,'Fitting B',$,$,#331,#333,$); +#330=IFCFLOWFITTING('07vctVUKr0kugbFTf53O9L',$,'Fitting B',$,$,#331,#333,$); #331=IFCLOCALPLACEMENT($,#332); #332=IFCAXIS2PLACEMENT3D(#334,$,$); #334=IFCCARTESIANPOINT((0.,0.,50.)); @@ -81,17 +81,17 @@ DATA; #335=IFCSHAPEREPRESENTATION(#23,'Body','MappedRepresentation',(#336)); #336=IFCMAPPEDITEM(#300,#337); #337=IFCCARTESIANTRANSFORMATIONOPERATOR3D($,$,#204,$,$); -#340=IFCFLOWFITTING('BYvctVUKr0kugbFTf53O9L',$,'Fitting C',$,$,#321,#343,$); +#340=IFCFLOWFITTING('0BvctVUKr0kugbFTf53O9L',$,'Fitting C',$,$,#321,#343,$); #343=IFCPRODUCTDEFINITIONSHAPE($,$,(#345)); #345=IFCSHAPEREPRESENTATION(#23,'Body','MappedRepresentation',(#346)); #346=IFCMAPPEDITEM(#300,#347); #347=IFCCARTESIANTRANSFORMATIONOPERATOR3D(#34,$,#204,$,$); -#350=IFCFLOWFITTING('DYvctVUKr0kugbFTf53O9L',$,'Fitting D',$,$,#321,#353,$); +#350=IFCFLOWFITTING('0DvctVUKr0kugbFTf53O9L',$,'Fitting D',$,$,#321,#353,$); #353=IFCPRODUCTDEFINITIONSHAPE($,$,(#355)); #355=IFCSHAPEREPRESENTATION(#23,'Body','MappedRepresentation',(#356)); #356=IFCMAPPEDITEM(#300,#357); #357=IFCCARTESIANTRANSFORMATIONOPERATOR3D($,$,#204,$,#32); -#600=IFCBEAM('CYvctVUKr0kugbFTf53O9L',$,'Beam',$,$,#321,#603,$); +#600=IFCBEAM('0CvctVUKr0kugbFTf53O9L',$,'Beam',$,$,#321,#603,$); #603=IFCPRODUCTDEFINITIONSHAPE($,$,(#605)); #605=IFCSHAPEREPRESENTATION(#23,'Body','Clipping',(#606)); #606=IFCBOOLEANCLIPPINGRESULT(.DIFFERENCE.,#607,#610); @@ -102,11 +102,11 @@ DATA; #611=IFCPLANE(#612); #612=IFCAXIS2PLACEMENT3D(#613,$,$); #613=IFCCARTESIANPOINT((0.,0.,5000.)); -#400=IFCDISTRIBUTIONPORT('8YvctVUKr0kugbFTf53O9L',$,'Pipe end',$,$,#401,$,.SOURCE.); +#400=IFCDISTRIBUTIONPORT('08vctVUKr0kugbFTf53O9L',$,'Pipe end',$,$,#401,$,.SOURCE.); #401=IFCLOCALPLACEMENT(#201,#402); #402=IFCAXIS2PLACEMENT3D(#403,#32,$); #403=IFCCARTESIANPOINT((1000.,0.,0.)); -#410=IFCRELCONNECTSPORTTOELEMENT('9YvctVUKr0kugbFTf53O9L',$,$,$,#400,#200); -#500=IFCRELCONTAINEDINSPATIALSTRUCTURE('AYvctVUKr0kugbFTf53O9L',$,$,$,(#100,#200,#320,#330,#340,#350,#600),#60); +#410=IFCRELCONNECTSPORTTOELEMENT('09vctVUKr0kugbFTf53O9L',$,$,$,#400,#200); +#500=IFCRELCONTAINEDINSPATIALSTRUCTURE('0AvctVUKr0kugbFTf53O9L',$,$,$,(#100,#200,#320,#330,#340,#350,#600),#60); ENDSEC; END-ISO-10303-21; diff --git a/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs b/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs index a3f4548..461aea4 100644 --- a/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs +++ b/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs @@ -401,8 +401,10 @@ public void Read_ConfiguredIfcFile_ConvertsEveryElement() string path = Environment.GetEnvironmentVariable(IfcFileFactAttribute.Variable)!; var model = IfcModel.Open(path); - var result = Valid(IfcSceneReader.Read(path)); + var result = IfcSceneReader.Read(path); + // A real file may draw warnings (for example a malformed GlobalId), but never errors. + Assert.DoesNotContain(result.Document.Validate(), d => d.Severity == SceneSeverity.Error); Assert.Equal( model.AllElements.Count(e => !Regex.IsMatch(e.IfcType, "(TYPE|STYLE|PROPERTIES)$", RegexOptions.IgnoreCase)), result.ElementCount); @@ -411,11 +413,10 @@ public void Read_ConfiguredIfcFile_ConvertsEveryElement() Assert.NotEmpty(SceneJson.Write(result.Document)); } - // Every converted fixture must have no Validate() errors. Warnings are allowed: the made-up GlobalIds of - // placement.ifc start with 4 to D and draw ifc-guid warnings. + // Every converted fixture must pass Validate() without errors or warnings. private static IfcSceneResult Valid(IfcSceneResult result) { - Assert.DoesNotContain(result.Document.Validate(), d => d.Severity == SceneSeverity.Error); + Assert.Empty(result.Document.Validate()); return result; } From e84bdf27038f2f32f14cc4e547a33f3d4862c399 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:00:42 +0300 Subject: [PATCH 05/10] Reject an empty ModelKey when reading an IFC file Read(path) kept an empty ModelKey as the model key and produced a scene that Validate() rejects, while Read(model) already threw for it. Both overloads now throw ArgumentException; null still falls back to the file name. --- docs/scene-adapter.md | 2 +- src/IfcIO.Scene/IfcSceneOptions.cs | 3 ++- src/IfcIO.Scene/IfcSceneReader.cs | 8 +++++++- tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs | 12 ++++++++++++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/docs/scene-adapter.md b/docs/scene-adapter.md index ad43e21..23ad513 100644 --- a/docs/scene-adapter.md +++ b/docs/scene-adapter.md @@ -29,7 +29,7 @@ SceneJson.Save(result.Document, "house.ioscene.json"); `IfcSceneReader.Read(path, options)` opens `.ifc` and `.ifczip` files with `IfcModel.Open`. A model already in memory is converted with `IfcSceneReader.Read(model, options)`; it has no file name, so `IfcSceneOptions.ModelKey` is -required. `IfcSceneOptions.IncludeSpaces` also converts spaces (off by default, because closed room volumes hide the +required. An empty `ModelKey` throws `ArgumentException` in both methods. `IfcSceneOptions.IncludeSpaces` also converts spaces (off by default, because closed room volumes hide the elements inside them). `IfcSceneResult` holds the `Document`, the `Diagnostics` of the conversion, the model's `LengthUnit` and the exact diff --git a/src/IfcIO.Scene/IfcSceneOptions.cs b/src/IfcIO.Scene/IfcSceneOptions.cs index cb6e59d..beeefa1 100644 --- a/src/IfcIO.Scene/IfcSceneOptions.cs +++ b/src/IfcIO.Scene/IfcSceneOptions.cs @@ -8,7 +8,8 @@ public sealed class IfcSceneOptions /// /// The key of the scene model (SceneModel.Key), which with each element's SourceId forms the identity /// that may be stored outside the scene. Null uses the file name without directory, for example house.ifc. - /// Required when the model is converted from memory, where there is no file name. + /// Required when the model is converted from memory, where there is no file name. An empty key is invalid, and + /// throws for it. /// public string? ModelKey { get; set; } diff --git a/src/IfcIO.Scene/IfcSceneReader.cs b/src/IfcIO.Scene/IfcSceneReader.cs index dfd6f32..66dec89 100644 --- a/src/IfcIO.Scene/IfcSceneReader.cs +++ b/src/IfcIO.Scene/IfcSceneReader.cs @@ -48,11 +48,17 @@ public static class IfcSceneReader /// The IFC file. /// Conversion options; null uses the defaults. /// The scene with a report of the conversion. - /// Two converted elements have the same . + /// + /// is empty, or two converted elements have the same . + /// public static IfcSceneResult Read(string path, IfcSceneOptions? options = null) { ArgumentException.ThrowIfNullOrEmpty(path); options ??= new IfcSceneOptions(); + if (options.ModelKey is "") + { + throw new ArgumentException("IfcSceneOptions.ModelKey must not be empty; leave it null to use the file name.", nameof(options)); + } return Convert(IfcModel.Open(path), options.ModelKey ?? Path.GetFileName(path), path, options); } diff --git a/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs b/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs index 461aea4..bba5e52 100644 --- a/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs +++ b/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs @@ -126,6 +126,18 @@ public void Read_ModelWithoutKey_Throws() Assert.Contains("ModelKey", ex.Message, StringComparison.Ordinal); } + [Fact] + public void Read_EmptyModelKey_Throws() + { + var empty = new IfcSceneOptions { ModelKey = "" }; + + var fromFile = Assert.Throws(() => IfcSceneReader.Read(FixturePath("placement.ifc"), empty)); + var fromModel = Assert.Throws(() => IfcSceneReader.Read(IfcModel.Create("Empty key"), empty)); + + Assert.Contains("ModelKey", fromFile.Message, StringComparison.Ordinal); + Assert.Contains("ModelKey", fromModel.Message, StringComparison.Ordinal); + } + [Theory] [InlineData(Millimetre, IfcLengthUnit.Millimetre, 0.001)] [InlineData(Foot, IfcLengthUnit.Foot, 0.3048)] From 0f7f9f27fb28e7c426d284cbd1bc8b1333e444ac Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:01:43 +0300 Subject: [PATCH 06/10] Write dimensioned quantities as text and require IOScene 0.1.0-preview.2 A number with a null Unit means dimensionless, and the adapter converts no quantity to SI, so length, area, volume and weight quantities are now Kind Text with the same text as before; a count stays a number with a null unit. The scene adapter docs describe the value rule, the order of occurrence and type property sets, and world coordinates that consumers rebase. The test on a configured IFC file now requires an empty Validate(). --- CONTRIBUTING.md | 2 +- Directory.Packages.props | 2 +- docs/scene-adapter.md | 36 +++++++++++++++---- src/IfcIO.Scene/IfcSceneReader.cs | 18 ++++++---- .../IfcIO.Scene.Tests/IfcSceneReaderTests.cs | 24 +++++++++---- 5 files changed, 62 insertions(+), 20 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f9d6a63..4a630ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ Report security vulnerabilities privately through the **Security** tab. ## Scene adapter `src/IfcIO.Scene` converts IfcIO models into `IOScene` documents and uses only the public API of `IfcIO`; the `IfcIO` -package itself keeps no dependencies. `IOScene` is referenced with the range `[0.1.0-preview.1, 0.2.0-0)`. To build +package itself keeps no dependencies. `IOScene` is referenced with the range `[0.1.0-preview.2, 0.2.0-0)`. To build against an `IOScene` preview that is not on NuGet.org yet, restore from a local folder of packages: `dotnet build IfcIO.slnx -p:RestoreAdditionalProjectSources=`. Changing the `SceneModel.Key` or `SourceId` the adapter writes, or its `ifc:` key format, fails the golden identity test and is a breaking release. diff --git a/Directory.Packages.props b/Directory.Packages.props index 43f1c26..7d7db8e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,7 +5,7 @@ - + diff --git a/docs/scene-adapter.md b/docs/scene-adapter.md index 23ad513..0dd4793 100644 --- a/docs/scene-adapter.md +++ b/docs/scene-adapter.md @@ -11,7 +11,7 @@ that read scenes work from it. The scene model and its rules are specified in th its [key registry](https://github.com/IOExtend/IOScene/blob/main/docs/keys.md). Status: preview. The project is built and tested with IfcIO but not published to NuGet until `IOScene` 0.1.0 is -released. It references `IOScene` with the range `[0.1.0-preview.1, 0.2.0-0)`. +released. It references `IOScene` with the range `[0.1.0-preview.2, 0.2.0-0)`. ## Usage @@ -42,9 +42,10 @@ elements inside them). | the file | one `SceneModel`: `Key` = `ModelKey`, else the file name without directory (`house.ifc`); `SourceFormat` `ifc`; `SourceLengthUnit` `mm`, `m`, `ft` or `in`; `Producer` `IfcIO.Scene ` | | storey | a `SceneLevel` (elevation in metres) and a tree node of kind `Level` with the storey's GlobalId | | element (`IfcModel.AllElements`) | a `SceneElement` of kind `Element`, child of its storey node: `Id` = STEP id, `SourceId` = `IfcGuid` = GlobalId, `Category` = IFC class in upper case, `TypeName` = type product name | -| property set, quantity set | a `ScenePropertySet` with key `ifc:`, properties `ifc:.`; a set shared by elements in the file is one scene set | +| property set, quantity set | a `ScenePropertySet` with key `ifc:`, properties `ifc:.`; a set shared by elements in the file is one scene set. `PropertySetIds` lists the element's property sets, then its quantity sets | | property value | `Text`, as IfcIO reads it | -| quantity value | `Number` with `Unit` null: the value is in the file's units, not converted | +| count quantity | `Number` with `Unit` null (dimensionless) | +| length, area, volume, weight quantity | `Text` only: the number as IfcIO reads it, in the file's units, not converted | | element mesh | one `SceneGeometry` (triangles, metres, world coordinates) placed once by a `SceneInstance` without transform | | surface style colour | a `SceneMaterial` (alpha is the opacity); elements without one get a colour for their IFC class on the instance | | work schedule | a `SceneSchedule` per work schedule with tasks; task `Id` = task GlobalId, elements by scene id | @@ -53,6 +54,27 @@ elements inside them). frozen and covered by a golden identity test (`tests/IfcIO.Scene.Tests/Fixtures/placement.identity.txt`); changing it is a breaking release. +### Values + +When a `SceneValue` has a `Number`, its `Unit` is authoritative and a null `Unit` means dimensionless. The adapter +converts no property or quantity value to SI, so it writes a number only when the value is dimensionless: + +- A count quantity (`IfcQuantityCount`) is `Number` with `Unit` null; `Text` is the same number. +- Length, area, volume and weight quantities are in the file's units, which IfcIO does not report for quantities, so + they are `Kind = Text` with the number only in `Text` (invariant culture, as IfcIO reads it). IfcIO does not read + time quantities. +- Property values are `Text` as IfcIO reads them. IfcIO keeps a value read from a file as text without its measure + type (`IfcLengthMeasure`, `IfcInteger`, ...), so the adapter cannot tell a dimensionless value from a dimensioned + one and does not type it. + +The value form written under each key (`Text`, `Kind`, `Unit`) is frozen with the key format. + +### Coordinates + +Vertices are in world coordinates, in metres, and every instance has no transform, which the contract allows. A file +placed far from the origin (for example at map coordinates) therefore gives large vertex coordinates; a consumer +rebases them, for example on `ComputeBounds().Center`, before narrowing them to float32. + ## Limits - **IFC4 tessellated geometry is not read.** IfcIO's reader returns no mesh for `IfcTriangulatedFaceSet`, including @@ -65,10 +87,12 @@ is a breaking release. or building, and spaces aggregated to a storey (`IfcRelAggregates`), are in the first storey. A file without storeys gets IfcIO's default storey `Level 1`, which has no GlobalId. Site, building and project are not tree nodes. - IfcIO lists type objects (`IfcWallType`, `IfcDoorStyle`) and door and window lining and panel properties among the - elements; the adapter skips them. Type property sets are not converted. + elements; the adapter skips them. Type property sets are not read yet; when they are, they will use the same + `ifc:` keys and come after the occurrence sets in `PropertySetIds`, so a lookup by key finds the occurrence value + first. - Property values are text even when the IFC value is a number or a boolean: IfcIO keeps only the text. -- Quantities are numbers without a unit. For a file whose length unit IfcIO converts to metres while reading (for - example centimetres), geometry is in metres but quantities stay in file units. +- Length, area, volume and weight quantities are text in file units. For a file whose length unit IfcIO converts to + metres while reading (for example centimetres), geometry is in metres but quantities stay in file units. - An element without a GlobalId in the file gets a new random GlobalId from IfcIO each time it is read, so its `SourceId` is not stable. - Transparency is not read from files by IfcIO; materials built in memory keep their alpha. diff --git a/src/IfcIO.Scene/IfcSceneReader.cs b/src/IfcIO.Scene/IfcSceneReader.cs index 66dec89..605d95a 100644 --- a/src/IfcIO.Scene/IfcSceneReader.cs +++ b/src/IfcIO.Scene/IfcSceneReader.cs @@ -32,8 +32,12 @@ namespace IfcIO.Scene; /// /// Property sets and quantity sets become property sets keyed ifc:<SetName>, their properties /// ifc:<SetName>.<Name>; a set that IfcIO shares between elements is one set in the scene. -/// Property values are text as IfcIO reads them. Quantity values are numbers in the file's units, so their -/// is null. Colour and transparency come from ; +/// Occurrence sets come first in , property sets before quantity sets; type +/// property sets are not read. A number is written only when it is dimensionless, since a null +/// means dimensionless and the adapter converts no value to SI. Property values are text +/// as IfcIO reads them, because IfcIO does not report the measure of a value read from a file. A count quantity is a +/// number with a null unit; length, area, volume and weight quantities are in the file's units, so they are text with +/// the number as IfcIO reads it. Colour and transparency come from ; /// elements without one get a colour for their IFC class. Work schedules become schedules. Openings are not converted. /// /// @@ -211,7 +215,7 @@ private static bool IsNotProduct(string ifcType) => foreach (var qset in element.QuantitySets) { sceneElement.PropertySetIds.Add(SetId(doc, sets, qset, qset.Name, - qset.Quantities.Select(q => (q.Name, QuantityValue(q.Value))))); + qset.Quantities.Select(q => (q.Name, QuantityValue(q))))); } doc.Elements.Add(sceneElement); @@ -246,11 +250,13 @@ private static bool IsNotProduct(string ifcType) => private static SceneDiagnostic Issue(SceneSeverity severity, string code, string reason, IfcElement element) => new(severity, code, $"{reason}; element #{element.Id} '{element.Name}' ({element.IfcType}) has no geometry in the scene.", element.GlobalId); - // Quantities are doubles in IfcIO, in the file's units (not always IfcModel.LengthUnit), so they carry no unit. - private static SceneValue QuantityValue(double value) + // Quantities are doubles in IfcIO, in the file's units (not always IfcModel.LengthUnit), which IfcIO does not report. + // A number with a null Unit means dimensionless, so only a count is a number; every other quantity is text. + private static SceneValue QuantityValue(IfcPhysicalQuantity quantity) { + double value = quantity.Value; string text = value.ToString(CultureInfo.InvariantCulture); - return double.IsFinite(value) ? SceneValue.FromNumber(value, null, text) : SceneValue.FromText(text); + return quantity is IfcQuantityCount && double.IsFinite(value) ? SceneValue.FromNumber(value, null, text) : SceneValue.FromText(text); } private static int SetId( diff --git a/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs b/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs index bba5e52..9f426ae 100644 --- a/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs +++ b/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs @@ -233,7 +233,12 @@ public void Read_SavedModel_KeepsPropertiesQuantitiesGlobalIdsAndReportsElements var storey = model.AddStorey("Level 2", 3000); var wall = model.AddMesh("Wall", Mm(Triangle), [0, 1, 2], new IfcMaterial { Name = "Brick", R = 1, G = 0.5, B = 0 }, storey, "IFCWALL"); wall.GetOrCreatePropertySet("Pset_WallCommon").Add("FireRating", "EI 60"); - wall.GetOrCreateQuantitySet("Qto_WallBaseQuantities").AddLength("Length", 3000.5); + var quantities = wall.GetOrCreateQuantitySet("Qto_WallBaseQuantities"); + quantities.AddLength("Length", 3000.5); + quantities.AddArea("NetSideArea", 9.25); + quantities.AddVolume("NetVolume", 2.5); + quantities.AddWeight("GrossWeight", 1200); + quantities.AddCount("Count", 4); model.AddElement(new IfcElement { Id = 2, Name = "Annotation" }, storey); var opened = SaveAndOpen(model); var openedWall = Assert.Single(opened.AllElements, e => e.Name == "Wall"); @@ -255,9 +260,17 @@ public void Read_SavedModel_KeepsPropertiesQuantitiesGlobalIdsAndReportsElements Assert.Equal("FireRating", fireRating.Name); Assert.Equal("ifc:Pset_WallCommon.FireRating", fireRating.Key); Assert.Equal(SceneValue.FromText("EI 60"), fireRating.Value); - var length = Assert.Single(sets[1].Properties); - Assert.Equal("ifc:Qto_WallBaseQuantities.Length", length.Key); - Assert.Equal(SceneValue.FromNumber(3000.5, null, "3000.5"), length.Value); + // Dimensioned quantities are in file units, so only their text is written; a count is a dimensionless number. + Assert.Equal( + [ + ("ifc:Qto_WallBaseQuantities.Length", SceneValue.FromText("3000.5")), + ("ifc:Qto_WallBaseQuantities.NetSideArea", SceneValue.FromText("9.25")), + ("ifc:Qto_WallBaseQuantities.NetVolume", SceneValue.FromText("2.5")), + ("ifc:Qto_WallBaseQuantities.GrossWeight", SceneValue.FromText("1200")), + ("ifc:Qto_WallBaseQuantities.Count", SceneValue.FromNumber(4, null, "4")), + ], + sets[1].Properties.Select(p => (p.Key!, p.Value))); + Assert.Null(sets[1].Properties[^1].Value.Unit); var brick = Assert.Single(doc.Materials); Assert.Equal("Brick", brick.Name); @@ -415,8 +428,7 @@ public void Read_ConfiguredIfcFile_ConvertsEveryElement() var result = IfcSceneReader.Read(path); - // A real file may draw warnings (for example a malformed GlobalId), but never errors. - Assert.DoesNotContain(result.Document.Validate(), d => d.Severity == SceneSeverity.Error); + Assert.Empty(result.Document.Validate()); Assert.Equal( model.AllElements.Count(e => !Regex.IsMatch(e.IfcType, "(TYPE|STYLE|PROPERTIES)$", RegexOptions.IgnoreCase)), result.ElementCount); From 681f109e3d9adb3e8763774698d4ea5e02ca9f1d Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:07:38 +0300 Subject: [PATCH 07/10] Allow warnings again in the test on a configured IFC file A real file may carry a malformed GlobalId, which the adapter copies as it is and Validate() reports as an ifc-guid warning; the test still fails on any error. --- tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs b/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs index 9f426ae..90c597a 100644 --- a/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs +++ b/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs @@ -428,7 +428,8 @@ public void Read_ConfiguredIfcFile_ConvertsEveryElement() var result = IfcSceneReader.Read(path); - Assert.Empty(result.Document.Validate()); + // A real file may draw warnings (for example a malformed GlobalId, copied as it is), but never errors. + Assert.DoesNotContain(result.Document.Validate(), d => d.Severity == SceneSeverity.Error); Assert.Equal( model.AllElements.Count(e => !Regex.IsMatch(e.IfcType, "(TYPE|STYLE|PROPERTIES)$", RegexOptions.IgnoreCase)), result.ElementCount); From 07cbd4c320da3ace7420e1d4627bbecde716e06f Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:07:39 +0300 Subject: [PATCH 08/10] Pin the ifc: key format and value form in the golden identity test placement.ifc gains a property set and a quantity set (a length and a count), and placement.identity.txt records every set key and the key, Kind, Unit and Text of each value. CONTRIBUTING and the scene adapter docs now list IfcGuid, the key format and the value form as frozen by that test. --- CONTRIBUTING.md | 5 +++-- docs/scene-adapter.md | 5 +++-- .../Fixtures/placement.identity.txt | 9 ++++++++- tests/IfcIO.Scene.Tests/Fixtures/placement.ifc | 8 ++++++++ tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs | 13 ++++++++++--- 5 files changed, 32 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a630ab..8e9ca8c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,8 +22,9 @@ Report security vulnerabilities privately through the **Security** tab. `src/IfcIO.Scene` converts IfcIO models into `IOScene` documents and uses only the public API of `IfcIO`; the `IfcIO` package itself keeps no dependencies. `IOScene` is referenced with the range `[0.1.0-preview.2, 0.2.0-0)`. To build against an `IOScene` preview that is not on NuGet.org yet, restore from a local folder of packages: -`dotnet build IfcIO.slnx -p:RestoreAdditionalProjectSources=`. Changing the `SceneModel.Key` or `SourceId` -the adapter writes, or its `ifc:` key format, fails the golden identity test and is a breaking release. +`dotnet build IfcIO.slnx -p:RestoreAdditionalProjectSources=`. Changing the `SceneModel.Key`, `SourceId` or +`IfcGuid` the adapter writes, its `ifc:` key format, or the value form (`Text`, `Kind`, `Unit`) it writes under a key +fails the golden identity test and is a breaking release. ## Releases & NuGet Publishing diff --git a/docs/scene-adapter.md b/docs/scene-adapter.md index 0dd4793..ec6a357 100644 --- a/docs/scene-adapter.md +++ b/docs/scene-adapter.md @@ -50,8 +50,9 @@ elements inside them). | surface style colour | a `SceneMaterial` (alpha is the opacity); elements without one get a colour for their IFC class on the instance | | work schedule | a `SceneSchedule` per work schedule with tasks; task `Id` = task GlobalId, elements by scene id | -`(SceneModel.Key, SourceId)` is the identity that may be stored outside the scene. How this adapter derives both is -frozen and covered by a golden identity test (`tests/IfcIO.Scene.Tests/Fixtures/placement.identity.txt`); changing it +`(SceneModel.Key, SourceId)` is the identity that may be stored outside the scene. How this adapter derives both, and +`IfcGuid`, the `ifc:` key format and the value form (`Text`, `Kind`, `Unit`) it writes under each key, are frozen and +covered by a golden identity test (`tests/IfcIO.Scene.Tests/Fixtures/placement.identity.txt`); changing any of them is a breaking release. ### Values diff --git a/tests/IfcIO.Scene.Tests/Fixtures/placement.identity.txt b/tests/IfcIO.Scene.Tests/Fixtures/placement.identity.txt index ea4015e..6fc41b3 100644 --- a/tests/IfcIO.Scene.Tests/Fixtures/placement.identity.txt +++ b/tests/IfcIO.Scene.Tests/Fixtures/placement.identity.txt @@ -1,5 +1,6 @@ # Golden identity of IfcIO.Scene for placement.ifc: the SceneModel.Key, then the SourceId of every scene element in -# document order. Changing what IfcIO.Scene writes here is a breaking release of IfcIO.Scene. +# document order (IfcGuid equals it), then every property set key with the key, Kind, Unit (- for none) and Text of +# each value under it. Changing what IfcIO.Scene writes here is a breaking release of IfcIO.Scene. key placement.ifc 04vctVUKr0kugbFTf53O9L 01vctVUKr0kugbFTf53O9L @@ -9,3 +10,9 @@ key placement.ifc 0BvctVUKr0kugbFTf53O9L 0DvctVUKr0kugbFTf53O9L 0CvctVUKr0kugbFTf53O9L +set ifc:Pset_BuildingElementProxyCommon +value ifc:Pset_BuildingElementProxyCommon.Reference Text - P-1 +value ifc:Pset_BuildingElementProxyCommon.Width Text - 2500. +set ifc:BaseQuantities +value ifc:BaseQuantities.Length Text - 1500.5 +value ifc:BaseQuantities.Count Number - 4 diff --git a/tests/IfcIO.Scene.Tests/Fixtures/placement.ifc b/tests/IfcIO.Scene.Tests/Fixtures/placement.ifc index b61e39c..8945ed7 100644 --- a/tests/IfcIO.Scene.Tests/Fixtures/placement.ifc +++ b/tests/IfcIO.Scene.Tests/Fixtures/placement.ifc @@ -108,5 +108,13 @@ DATA; #403=IFCCARTESIANPOINT((1000.,0.,0.)); #410=IFCRELCONNECTSPORTTOELEMENT('09vctVUKr0kugbFTf53O9L',$,$,$,#400,#200); #500=IFCRELCONTAINEDINSPATIALSTRUCTURE('0AvctVUKr0kugbFTf53O9L',$,$,$,(#100,#200,#320,#330,#340,#350,#600),#60); +#700=IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('P-1'),$); +#701=IFCPROPERTYSINGLEVALUE('Width',$,IFCLENGTHMEASURE(2500.),$); +#702=IFCPROPERTYSET('0EvctVUKr0kugbFTf53O9L',$,'Pset_BuildingElementProxyCommon',$,(#700,#701)); +#703=IFCRELDEFINESBYPROPERTIES('0FvctVUKr0kugbFTf53O9L',$,$,$,(#100),#702); +#710=IFCQUANTITYLENGTH('Length',$,$,1500.5); +#711=IFCQUANTITYCOUNT('Count',$,$,4.); +#712=IFCELEMENTQUANTITY('0GvctVUKr0kugbFTf53O9L',$,'BaseQuantities',$,$,(#710,#711)); +#713=IFCRELDEFINESBYPROPERTIES('0HvctVUKr0kugbFTf53O9L',$,$,$,(#100),#712); ENDSEC; END-ISO-10303-21; diff --git a/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs b/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs index 90c597a..e63ad04 100644 --- a/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs +++ b/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs @@ -94,10 +94,17 @@ public void Read_PlacementFixture_MatchesGoldenIdentity() var expected = File.ReadAllLines(FixturePath("placement.identity.txt")).Where(l => !l.StartsWith('#')).ToList(); var result = Valid(IfcSceneReader.Read(FixturePath("placement.ifc"))); + var doc = result.Document; + bool IsSet(string line) => line.StartsWith("set ", StringComparison.Ordinal) || line.StartsWith("value ", StringComparison.Ordinal); - Assert.Equal(expected[0], "key " + Assert.Single(result.Document.Models).Key); - Assert.Equal(expected.Skip(1), result.Document.Elements.Select(e => e.SourceId)); - Assert.All(result.Document.Elements, e => Assert.Equal(e.SourceId, e.IfcGuid)); + Assert.Equal(expected[0], "key " + Assert.Single(doc.Models).Key); + Assert.Equal(expected.Skip(1).Where(l => !IsSet(l)), doc.Elements.Select(e => e.SourceId)); + Assert.All(doc.Elements, e => Assert.Equal(e.SourceId, e.IfcGuid)); + Assert.Equal( + expected.Where(IsSet), + doc.PropertySets.SelectMany(s => s.Properties + .Select(p => $"value {p.Key} {p.Value.Kind} {p.Value.Unit ?? "-"} {p.Value.Text}") + .Prepend("set " + s.Key))); } [Fact] From 4f45d3a9312dd2893f992c36970dc550fd2c38d6 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:09:13 +0300 Subject: [PATCH 09/10] Document all parameters of ParseSweptSolid --- src/IfcIO/IfcModel.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/IfcIO/IfcModel.cs b/src/IfcIO/IfcModel.cs index 17a445e..bca8896 100644 --- a/src/IfcIO/IfcModel.cs +++ b/src/IfcIO/IfcModel.cs @@ -2967,6 +2967,11 @@ public void ChargeVertices(int count) /// Meshes an IfcExtrudedAreaSolid or IfcRevolvedAreaSolid in the coordinates of its representation and returns /// its parametric form, which keeps only the Position location (as IfcModel stores and writes it). /// + /// The STEP file that holds the solid and the entities it references. + /// The IfcExtrudedAreaSolid or IfcRevolvedAreaSolid entity. + /// The name given to the returned mesh. + /// The parametric extrusion, or null when the entity is not an extrusion or cannot be read. + /// The parametric revolution, or null when the entity is not a revolution or cannot be read. /// True when Position or the profile Position rotates, so the parametric form does not describe the mesh. private static IfcMesh? ParseSweptSolid( StepFile step, From 225446d3719e7bc60eafae5af765871b2a2a9a5e Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:09:13 +0300 Subject: [PATCH 10/10] Pack IfcIO.Scene on the IfcIO release tag with an exact engine pin --- CHANGELOG.md | 3 ++- README.md | 2 +- docs/scene-adapter.md | 5 +++-- src/IfcIO.Scene/IfcIO.Scene.csproj | 17 ++++++++++++++-- tools/pack-release.ps1 | 32 ++++++++++++++++++++++++++++++ 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76db16e..f63748c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,8 @@ All notable changes to the `IfcIO` library are documented in this file. ## [Unreleased] ### Added -- **`IfcIO.Scene`**: `IfcSceneReader` converts an IFC file or `IfcModel` into an `IOScene` `SceneDocument` in metres with Z up (GlobalIds as `SourceId` and `IfcGuid`, storeys as levels and tree nodes, property and quantity sets keyed `ifc:`, materials, 4D schedules), with diagnostics for elements without geometry. Not packed until `IOScene` 0.1.0 is released. See `docs/scene-adapter.md`. +- **`IfcIO.Scene`**: `IfcSceneReader` converts an IFC file or `IfcModel` into an `IOScene` `SceneDocument` in metres with Z up (GlobalIds as `SourceId` and `IfcGuid`, storeys as levels and tree nodes, property and quantity sets keyed `ifc:`, materials, 4D schedules), with diagnostics for elements without geometry. See `docs/scene-adapter.md`. +- **`IfcIO.Scene` NuGet package**: published on the `IfcIO` release tag and depends on exactly the same `IfcIO` version (`[X.Y.Z]`). It needs the released `IOScene` 0.1.0; the release fails while it would depend on an `IOScene` preview. ### Fixed - **World placement of geometry read from IFC (`IfcModel.Open`)**: element meshes are now in world coordinates. Previously object placements were ignored and every element sat at its local origin. diff --git a/README.md b/README.md index b11c6ca..c9eae5c 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ model.Save("Building_4D_5D.ifc"); See the [public API](docs/public-api.md) for the supported surface and [getting started guide](docs/getting-started.md) for detailed workflows. -`IfcIO.Scene` (in this repository, not yet published) converts IFC models into `IOScene` scene documents for viewers and coordination tools; see the [scene adapter](docs/scene-adapter.md) page for usage and limits. +`IfcIO.Scene` (a separate package released with `IfcIO`, from `IOScene` 0.1.0 on) converts IFC models into `IOScene` scene documents for viewers and coordination tools; see the [scene adapter](docs/scene-adapter.md) page for usage and limits. ## Documentation diff --git a/docs/scene-adapter.md b/docs/scene-adapter.md index ec6a357..34a2b58 100644 --- a/docs/scene-adapter.md +++ b/docs/scene-adapter.md @@ -10,8 +10,9 @@ that read scenes work from it. The scene model and its rules are specified in th [IOScene contract](https://github.com/IOExtend/IOScene/blob/main/docs/contract.md); property keys are registered in its [key registry](https://github.com/IOExtend/IOScene/blob/main/docs/keys.md). -Status: preview. The project is built and tested with IfcIO but not published to NuGet until `IOScene` 0.1.0 is -released. It references `IOScene` with the range `[0.1.0-preview.2, 0.2.0-0)`. +Status: preview. The `IfcIO.Scene` package is released with IfcIO, on the same tag, and depends on exactly the same +`IfcIO` version. Its first release needs `IOScene` 0.1.0: until then it references `IOScene` with the range +`[0.1.0-preview.2, 0.2.0-0)` and a release fails on purpose. ## Usage diff --git a/src/IfcIO.Scene/IfcIO.Scene.csproj b/src/IfcIO.Scene/IfcIO.Scene.csproj index 6875541..3e837a5 100644 --- a/src/IfcIO.Scene/IfcIO.Scene.csproj +++ b/src/IfcIO.Scene/IfcIO.Scene.csproj @@ -1,8 +1,6 @@ - - false Converts IFC models read by IfcIO into IOScene documents in metres with Z up, keeping GlobalIds, storeys, property sets, quantities and 4D schedules. true @@ -14,4 +12,19 @@ + + + + + + + + + + <_Pinned Include="@(_ProjectReferencesWithVersions)">[%(_ProjectReferencesWithVersions.ProjectVersion)] + <_ProjectReferencesWithVersions Remove="@(_ProjectReferencesWithVersions)" /> + <_ProjectReferencesWithVersions Include="@(_Pinned)" /> + + + diff --git a/tools/pack-release.ps1 b/tools/pack-release.ps1 index f80586b..ecc498b 100644 --- a/tools/pack-release.ps1 +++ b/tools/pack-release.ps1 @@ -29,6 +29,27 @@ function Invoke-DotNet { if ($LASTEXITCODE -ne 0) { throw "dotnet $($args -join ' ') failed with exit code $LASTEXITCODE" } } +function Assert-AdapterNuspec([string]$NuspecPath, [string]$EngineId, [string]$Version) { + $dependencies = @(([xml](Get-Content -LiteralPath $NuspecPath -Raw)).SelectNodes("//*[local-name()='dependency']")) + # (a) Guards the PinProjectReferencesExactly target, which hooks a private NuGet target and would fail silently if an SDK renamed it. + $engine = @($dependencies | Where-Object { $_.GetAttribute('id') -eq $EngineId }) + if ($engine.Count -eq 0) { throw "$NuspecPath has no dependency on $EngineId." } + foreach ($dependency in $engine) { + if ($dependency.GetAttribute('version') -cne "[$Version]") { + throw "$NuspecPath depends on $EngineId '$($dependency.GetAttribute('version'))'; it must be exactly '[$Version]'." + } + } + # (b) Replaces the NU5104 protection that the IOScene range switches off: no adapter built on a preview contract reaches nuget.org. + $scene = @($dependencies | Where-Object { $_.GetAttribute('id') -eq 'IOScene' }) + if ($scene.Count -eq 0) { throw "$NuspecPath has no dependency on IOScene." } + foreach ($dependency in $scene) { + $range = $dependency.GetAttribute('version') + if ($range.TrimStart('[', '(').Split(',')[0].Contains('-')) { + throw "$NuspecPath depends on IOScene '$range', whose lower bound is a prerelease; release IOScene first and require its stable version." + } + } +} + function New-Zip([string]$SourceDir, [string]$ZipPath) { if (Test-Path -LiteralPath $ZipPath) { Remove-Item -LiteralPath $ZipPath } Compress-Archive -Path (Join-Path $SourceDir '*') -DestinationPath $ZipPath @@ -76,6 +97,17 @@ New-Zip $cli10Stage (Join-Path $Output "IfcIO-Cli-$version-net10.0.zip") Write-Host "Packing NuGet packages..." Invoke-DotNet pack (Join-Path $root 'IfcIO.slnx') -c Release --no-build -o $Output +Write-Host "Checking IfcIO.Scene dependencies..." +Add-Type -AssemblyName System.IO.Compression.FileSystem +$adapterNuspec = Join-Path $stage 'IfcIO.Scene.nuspec' +$adapterPackage = [System.IO.Compression.ZipFile]::OpenRead((Join-Path $Output "IfcIO.Scene.$version.nupkg")) +try { + $entry = @($adapterPackage.Entries | Where-Object { $_.FullName -notlike '*/*' -and $_.Name -like '*.nuspec' })[0] + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $adapterNuspec, $true) +} +finally { $adapterPackage.Dispose() } +Assert-AdapterNuspec $adapterNuspec 'IfcIO' $version + # 5. Source code archive Write-Host "Creating source code archive..." $sourceZip = Join-Path $Output "IfcIO-$version-source.zip"