Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 1 addition & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [windows-latest]
os: [windows-latest, ubuntu-latest, macos-latest]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Resolve the macOS test failures before merging the expanded matrix

The approved macOS job fails the same three cases on both target frameworks:

  • CompactValueFormatterTests.DegenerateAccuraciesPrintWhatPrints: at accuracy = 0, value = 1234.5678 expects -NaN m² but gets NaN m²; value = -0.0929 expects NaN m² but gets -NaN m².
  • UnitDisplayEvaluatorTests.SlopeCloneClearsDimensionsAndUsesNativeNearVerticalSnapIncludingNegative: the near-vertical conversion differs by one bit (4663021752216772328 expected, 4663021752216772329 actual).

These assertions and their formatting/conversion code predate this PR; the new matrix exposes the platform assumptions. Nevertheless, its macOS job remains red and #39 explicitly requires tests to pass on all three operating systems. Resolve these differences without removing the macOS job or weakening the Windows reference checks, then rerun the full matrix. This run did not show a mesher regression or a fuzz timing failure.

runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
Expand All @@ -31,14 +31,3 @@ jobs:
name: packages-${{ matrix.os }}
path: artifacts/*.nupkg
if-no-files-found: error

build-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
dotnet-version: |
8.0.x
- run: dotnet build src/RvtIO.Cli/RvtIO.Cli.csproj -c Release
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ API between minor releases.
- IFC export, current profile: the room of a family instance that stores none is looked up at one test point per instance instead of at the centre of its box. An instance of a family with a room calculation point is tested at that point, placed with the instance, whatever its host. Otherwise a wall-hosted instance is tested one foot outside the side face of its wall that it faces (the face with the higher handle, the lower one when the instance is flipped across the wall), a work-plane-based instance one foot from its placed origin along its own Z axis, an instance of a hosted family on a floor or a ceiling one foot from its placed origin against its own Z axis (along it when the instance is flipped in Y), and any other instance at the centre of the box of its own geometry (the placed box of its symbol when it has none), moved up to half a foot towards its level. Only the rooms of the phase the instance was created in are considered (the phase id as the room record stores it when the room has no validated phase, 0 included), and the first room by id whose vertical extent and solid hold the point wins; a point on the boundary is inside. A room stored with the instance is kept, as before. Measured against the reference by the container of the products on nine sample files: 376 containers now agree (239, 31, 31, 29, 16, 14, 12, 3 and 1), and no slot regressed in eight of the files. In the ninth, 10 instances (4 building element proxies and 6 furnishing elements) moved from the storey to a space where the reference keeps the storey. They were then compared with the room Revit itself reports: the 4 proxies have no host and Revit gives them that room (the reference moves their test point to a level elevation counted from the project base point, which is 100 mm off in that model), so they stay in the space; the 6 furnishing elements are hosted by a floor and Revit gives them no room, which the floor rule above now follows. With the floor rule that file has 6 more containers that agree with the reference and 1 that no longer does (a floor-hosted furnishing element the reference puts in a space and Revit does not); the IFC of five other of the nine files does not change, the remaining three were not exported again. Three more rules come from the same comparison with Revit. Of the 504 ceiling-hosted fixtures of that model Revit gives the room under the ceiling to the 396 that are not flipped and no room to the 108 that are flipped in Y, which the ceiling rule above follows; the reference puts all of them in a space. Two furnishing elements whose symbol stores a box wider than its geometry are in the room that holds the centre of the geometry, where the centre of the stored box lies 1.4 mm outside it; the reference keeps them in the storey. An instance of a family with a room calculation point is in the room that holds that point, for every kind of host: the point is read from the symbol and agrees with the one Revit reports for the 453 instances of that model that have one, and 2 level-based fixtures that stood in a space are in the storey, as in Revit. These three rules change 112 containers of that file (108, 2 and 2), which now differ from the reference; the IFC of five other files and the JSON output of four do not change. Against the room Revit reports for the family instances of that model in its Revit 2022 version, measured again on the current main branch, the container agrees for 1408 of 1530 contained instances (1274 on the main branch, 1291 for the reference); in four synthetic files made for the floor, the mirrored, the calculation point and the boundary cases for 80 of 80, 8 of 8, 32 of 32 and 76 of 78. Not followed: Revit counts a point 0.05 mm outside the plan boundary of a room as inside (the 2 of 78). An instance hosted by an arc wall gets no wall-side point and is tested as an instance without a host. The JSON output of all measured files is unchanged; the IFC of a tenth, synthetic file is identical to before. The legacy profile was not measured.
- Adds `rvtio coverage <file> [--report report.json] [--top n]`, which counts how many records of the partition streams of one file the class schema of that file walks to their last byte, per block kind (project or loaded family), channel and class, with the reason and the field at which a walk stops, and lists every stream of the file with its kind and whether it frames or decodes. The count is strict: no byte scan, no resynchronisation, no recovery; a partition stream whose framing does not hold adds no record and is named in the report. It writes nothing but the JSON report (`rvtio-coverage/1`). Exit code 0 when the file was measured, 1 for a wrong command line, 2 when the file cannot be read. See [the coverage report](docs/coverage-report.md).

- Adds `--ifc-timestamp <timestamp>` (an ISO-8601 timestamp or Unix epoch seconds; `IfcExporter.Timestamp` in the library) to write a fixed UTC timestamp into the STEP `FILE_NAME` header so repeated IFC exports of the same input are byte-for-byte identical, and adds CI-runnable corruption and fuzz tests over synthetic `.rvt` containers and inner streams with execution-time and memory-allocation bounds alongside full Windows, Linux and macOS CI test matrices. (Closes #39)

### Changed

- Reading: in a file without its own class schema (Revit 2020 and 2022) whose partition streams are read by their framing, the root object of each element, from which the reader takes the class of the element and the stair and railing facts it keeps per root, is the channel-102 record of the element in the project's own block, in its current copy. Before, the stream walk counted every block it met for the id, including copies of the id in the documents of loaded families, and missed roots it did not reach; an element with two counted roots or none had no class. On 23 such files 59 elements in 7 files now get their class (a schedule, a view, a wall, a rebar, a sketch plane and others): in the compact JSON they carry the name of their class and their usual properties instead of an empty name, and the export report names their class instead of `unknown`; the IFC of all 23 files is unchanged. Refs #47.
Expand All @@ -66,6 +68,7 @@ API between minor releases.

### Fixed

- Geometry meshing: scales the pole Jacobian degeneracy threshold in `RevolvedMesher.NormalAt` by the profile tangent magnitude so 1-ULP trigonometric differences at sphere poles fall through to the pole limit normal on Linux and macOS as on Windows instead of leaving duplicate pole vertices unmerged. (Refs #39)
- IFC export, current profile: a project whose length format is "metres and centimetres" declares metres, and a project in US survey feet declares feet; both were written in millimetres before. The IFC export of Revit itself declares the same for these two formats. In a file of the Revit 2020 layout US survey feet are now read as a length format too (not checked on such a file). A project in any other format (centimetres, decimetres, inches) is still written in millimetres, with every length scaled to them, and the diagnostics now say that its stored unit has no unit family of its own instead of presenting millimetres as that unit. The README and the API reference name the rule and the default. Measured with Revit 2022 on one model under ten length formats, declared unit and the formats that give it: metres (metres; metres and centimetres), centimetres (centimetres), millimetres (millimetres), feet (feet; feet and fractional inches; US survey feet; decimetres), inches (inches; fractional inches). Four small files saved by Revit 2022 in metres and centimetres, US survey feet, centimetres and inches export as described. (Closes #36)
- Export report: `export.skippedByClass` did not list every element the IFC export left out. Three exits of the product loop dropped a product without an entry: a product without bounds (`product-without-bounds`), one with invalid bounds (`invalid-bounds`) and one whose box is under 1 cm in every direction (`bounds-under-one-centimetre`). A curtain wall or roof that is written after its parts and has no written part is now listed as `container-without-written-parts`, and the elements the export leaves out before that loop as `non-primary-design-option` and `outside-export-phase`. The list of reasons in docs/export-report.md gains these six and the four the exporter already wrote without naming them there (`export-to-ifc-flag-no`, `reveal-sweep`, `product-without-body`, `wall-without-solid`); a test keeps the list complete, and the documentation says that levels, rooms and the site topography are listed although their spatial entity is written. The IFC output does not change; the counts `considered` and `skipped` of a report grow by the elements that were not listed: on two samples measured, 1 product without bounds in one and 1137 elements of non-primary design options in the other, with the same `exported` count as before. New tests start the command line tool as a process on an input that is no compound file, on an empty one and on a compound file cut in half, and check exit code 2 and the failed report; exit code 3 for a reader limit is still tested in the library only. (Closes #37)
- A `Global` or `Formats` stream whose gzip member has a corrupt deflate header is treated as a stream that does not decode: a class table damaged that way is reported as unreadable input (exit code 2 of `downgrade` and `coverage`) instead of ending in an unhandled exception.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Add the library to a .NET project:
dotnet add package RvtIO
```

The library targets .NET 8 and .NET 10. It uses managed code without native dependencies or Windows-only APIs and supports Windows, Linux and macOS. Tests currently run on Windows.
The library targets .NET 8 and .NET 10. It uses managed code without native dependencies or Windows-only APIs and is tested on Windows, Linux and macOS.

## Command line

Expand Down Expand Up @@ -79,7 +79,7 @@ rvtio project.rvt project.ifc --ifc-opening-legacy-axes

`--ifc-opening-host-axes`, the opt-in of the earlier release for the new reading, is still accepted and does nothing. In the library the option is `IfcExporter.OpeningQuantitiesSwapped`; `IfcExporter.OpeningQuantitiesInHostAxes` is kept and is the opposite of it.

Use `--ifc-revit-parameter-groups` for one property set per parameter group. A file saved in several increments can hold an element in more than one partition stream; the tool keeps only its current copy (every copy is kept when the file's increment tables cannot be decoded). Use `--keep-all-copies` to decode every copy instead (the earlier behavior; `--resolve-current-copies` is still accepted and does nothing). Run `rvtio --help` for all options. Unknown options exit with code 1; options starting with `--dev-` are not a stable interface.
Use `--ifc-timestamp <timestamp>` (an ISO-8601 timestamp or Unix epoch seconds; `IfcExporter.Timestamp` in the library) to write a fixed UTC timestamp into the STEP `FILE_NAME` header so repeated exports of the same input are byte-for-byte identical. Use `--ifc-revit-parameter-groups` for one property set per parameter group. A file saved in several increments can hold an element in more than one partition stream; the tool keeps only its current copy (every copy is kept when the file's increment tables cannot be decoded). Use `--keep-all-copies` to decode every copy instead (the earlier behavior; `--resolve-current-copies` is still accepted and does nothing). Run `rvtio --help` for all options. Unknown options exit with code 1; options starting with `--dev-` are not a stable interface.

### Exit codes and the export report

Expand Down
2 changes: 1 addition & 1 deletion docs/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ extension and label helpers carry the ids and names stored in the file format.

| Type | Use |
|---|---|
| `IfcExporter` | Writes IFC from a `RevitFileInfo` (`Export`). Properties: `Profile`, `BaseQuantities`, `OpeningQuantitiesSwapped` (true writes the legacy Width and Height of door and window openings; default false), `OpeningQuantitiesInHostAxes` (the opposite of it, kept for compatibility), `ExportPhaseId`, `ExportAllPhases`, `RoomExportPhaseId`, `ExportRoomsOfAllPhases`, `RevitParameterGroups`, `SkipLog`, `ExportedProducts`, `DiagnosticOutput`. |
| `IfcExporter` | Writes IFC from a `RevitFileInfo` (`Export`). Properties: `Profile`, `BaseQuantities`, `OpeningQuantitiesSwapped` (true writes the legacy Width and Height of door and window openings; default false), `OpeningQuantitiesInHostAxes` (the opposite of it, kept for compatibility), `ExportPhaseId`, `ExportAllPhases`, `RoomExportPhaseId`, `ExportRoomsOfAllPhases`, `RevitParameterGroups`, `Timestamp`, `SkipLog`, `ExportedProducts`, `DiagnosticOutput`. |
| `IfcProfile` | `Legacy` or `Current` (the default) output profile. |
| `IfcUnitFamily` | The length unit family an export of the current profile declares, by the length format of the project: `Metre` for metres and for metres and centimetres, `Foot` for feet, feet and fractional inches and US survey feet, `Millimetre` for millimetres, for every other format and for an unreadable one. The legacy profile always declares millimetres. |
| `CompactJsonExporter` | `Export(rvtPath, outputPath, readerOptions, options)` reads a file and writes the compact JSON. |
Expand Down
6 changes: 3 additions & 3 deletions src/RvtIO.Cli/ExportCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ internal static partial class Program
/// <summary>The values of the command line that decide the export.</summary>
private sealed record ExportArguments(string[] Args, string RvtPath, string OutputPath, string? ExternalDumpPath, string? ExternalDumpClassPrefix,
IfcProfile Profile, bool IsIfcOutput, string? ExportPhaseOption, string? RoomPhaseOption, bool IncludeIfcRevitParameters, bool RevitParameterGroups, bool BaseQuantities,
string JsonClassPrefix, bool CompactJson, bool OpeningLegacyAxes);
string JsonClassPrefix, bool CompactJson, bool OpeningLegacyAxes, DateTimeOffset? IfcTimestamp);

// The export: reads the file and writes the IFC, the compact JSON or the full JSON dump.
private static int Export(ExportArguments arguments)
{
var (args, rvtPath, outputPath, externalDumpPath, externalDumpClassPrefix, profile, isIfcOutput, exportPhaseOption, roomPhaseOption, includeIfcRevitParameters, revitParameterGroups, baseQuantities, jsonClassPrefix, compactJson, openingLegacyAxes) = arguments;
var (args, rvtPath, outputPath, externalDumpPath, externalDumpClassPrefix, profile, isIfcOutput, exportPhaseOption, roomPhaseOption, includeIfcRevitParameters, revitParameterGroups, baseQuantities, jsonClassPrefix, compactJson, openingLegacyAxes, ifcTimestamp) = arguments;
WarnIfRevitLocalization(args);
var options = new RvtReaderOptions
{
Expand Down Expand Up @@ -114,7 +114,7 @@ private static int Export(ExportArguments arguments)
RvtIO.Parsing.ValidatedElementNames.Apply(rvtInfo,
reader.LastParsingState ?? throw new InvalidOperationException("The reader kept no parsing state."));
var skipLog = new SkipLog();
var exporter = new IfcExporter(Console.Error, includeIfcRevitParameters) { SkipLog = skipLog, ExportPhaseId = exportPhase, ExportAllPhases = exportPhaseOption == "all", RoomExportPhaseId = roomPhase, ExportRoomsOfAllPhases = roomPhaseOption == "all", RevitParameterGroups = revitParameterGroups, Profile = profile, BaseQuantities = baseQuantities, OpeningQuantitiesSwapped = openingLegacyAxes };
var exporter = new IfcExporter(Console.Error, includeIfcRevitParameters) { SkipLog = skipLog, ExportPhaseId = exportPhase, ExportAllPhases = exportPhaseOption == "all", RoomExportPhaseId = roomPhase, ExportRoomsOfAllPhases = roomPhaseOption == "all", RevitParameterGroups = revitParameterGroups, Profile = profile, BaseQuantities = baseQuantities, OpeningQuantitiesSwapped = openingLegacyAxes, Timestamp = ifcTimestamp };
var swExport = System.Diagnostics.Stopwatch.StartNew();
exporter.Export(rvtInfo, outputPath);
Console.Error.WriteLine($"Timing: export {swExport.Elapsed.TotalSeconds:F1}s");
Expand Down
74 changes: 74 additions & 0 deletions src/RvtIO.Cli/IfcTimestampOption.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using System.Globalization;

namespace RvtIO.Cli;

/// <summary>The <c>--ifc-timestamp &lt;timestamp&gt;</c> option: fixes the UTC timestamp written into the STEP <c>FILE_NAME</c> header so
/// repeated IFC exports of the same input are byte-for-byte identical.</summary>
internal static class IfcTimestampOption
{
public const string Name = "--ifc-timestamp";

private static readonly string[] ExactFormats =
[
"yyyy-MM-dd'T'HH:mm:ssK",
"yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK",
"yyyy-MM-dd'T'HH:mm:ss",
"yyyy-MM-dd HH:mm:ssK",
"yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd",
];

/// <summary>Reads <c>--ifc-timestamp</c> from <paramref name="args"/>. Without the option <paramref name="timestamp"/> is null.
/// Accepts ISO-8601 timestamps (UTC when no offset is given) or non-negative Unix epoch seconds. Returns false with
/// <paramref name="error"/> set when the option has no value, is given twice or cannot be parsed.</summary>
public static bool TryParse(string[] args, out DateTimeOffset? timestamp, out string? error)
{
timestamp = null;
error = null;
bool seen = false;
for (int i = 0; i < args.Length; i++)
{
if (args[i] != Name) continue;
if (seen || i + 1 >= args.Length || args[i + 1].StartsWith("--", StringComparison.Ordinal))
{
error = $"{Name} requires one ISO-8601 timestamp (for example 2026-01-15T12:30:45Z) or Unix epoch seconds.";
timestamp = null;
return false;
}
seen = true;
string raw = args[++i];
if (!TryParseValue(raw, out var parsed))
{
error = $"{Name} requires one ISO-8601 timestamp (for example 2026-01-15T12:30:45Z) or Unix epoch seconds.";
timestamp = null;
return false;
}
timestamp = parsed;
}
return true;
}

private static bool TryParseValue(string raw, out DateTimeOffset result)
{
if (string.IsNullOrWhiteSpace(raw))
{
result = default;
return false;
}
if (long.TryParse(raw, NumberStyles.None, CultureInfo.InvariantCulture, out long epochSeconds))
{
try
{
result = DateTimeOffset.FromUnixTimeSeconds(epochSeconds);
return true;
}
catch (ArgumentOutOfRangeException)
{
result = default;
return false;
}
}
return DateTimeOffset.TryParseExact(raw, ExactFormats, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out result);
}
}
Loading
Loading