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: 11 additions & 2 deletions docs/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ projects through `InternalsVisibleTo`.

| Type | Use |
|---|---|
| `RvtDocument` | Opens a file (`Open`) and gives the elements, project information, file metadata and category lookups. |
| `RvtDocument` | Opens a file (`Open`), saves a verified lossless copy (`SaveAs`), and gives the elements, project information, file metadata and category lookups. |
| `RvtFileReader` | Lower level read (`Read`) that returns the `RevitFileInfo` behind a document. |
| `RvtReaderOptions` | Options of a read, including `DiagnosticOutput`, `ResolveCurrentCopies` (keeps only the current copy of an element that several partition streams hold; default true, set it to false to decode every copy), `RaiseLimits` and `Diagnostics`. `RevitLocalizationDirectory` is `[Obsolete]` and ignored: the texts are built in. |
| `RvtReadDiagnostics`, `StreamRecord`, `ReaderLimitHit` | What a read collects when `RvtReaderOptions.Diagnostics` is set: the partition streams read and the streams cut by a limit. |
| `ExportReport`, `SkipLog` | The machine-readable report of a run (`ToJson`) and the elements an export left out; see [the export report](export-report.md). |
| `RvtExitCodes`, `RvtInputException`, `RvtLimitException`, `RvtContainerCheck` | The exit codes 0 to 4 and 6 of the command line tool (code 5 is returned only by `downgrade --check`), the exceptions `RvtFileReader.Read` throws for a file it cannot read, and the check that raises them. |
| `RvtExitCodes`, `RvtInputException`, `RvtLimitException`, `RvtContainerCheck` | The exit codes 0 to 4 and 6 of the command line tool (code 5 is returned by `downgrade --check` and `save-as` when refused), the exceptions `RvtFileReader.Read` throws for a file it cannot read, and the check that raises them. |
| `FilteredElementCollector` | Query over the elements of a document (`OfCategory`, `WhereElementIsNotElementType`, `Where`, ...). |

## Model
Expand Down Expand Up @@ -66,6 +66,15 @@ hidden Geometry nodes follow its rules: they are listed in the documentation com
that are not applied (the "export to IFC" flag, topography, walls that are not visible and others), so the meshes can include
elements that the IFC leaves out. Curved surfaces are the dense polygons the reader decodes for them.

## Writing and save-as

| Type | Use |
|---|---|
| `RvtWriter` | Inspects the write-capability matrix (`InspectCapabilities`), writes lossless copies (`SaveAs`), and persists targeted stream and partition-record edits (`Rewrite`) for local `2020` to `2027` `.rvt` and `.rfa` files; see [writing and save-as](writing.md). |
| `RvtWriteCapabilities`, `RvtOperationSupport`, `RvtFileKind`, `RvtWriteOperation` | Capability classification by file kind (`Project`, `Family`), release (`2020` to `2027`), element ID width (`4` or `8` bytes), worksharing/cloud flags, and operation (`SaveAs`, `PagedStreamRewrite`, `PartitionRecordRewrite`). |
| `RvtWriteRequest`, `RvtPagedStreamEdit`, `RvtPartitionRecordEdit`, `RvtWriteResult` | Edit specifications for `Formats/Latest`, `Global/*`, and `Partitions/<n>` records, and the verified write summary. |
| `RvtWriteRefusedException` | Thrown with a stable `Kind` code when a file state or edit request is outside the supported write scope. |

## Diagnostics

The library never writes to the console. A diagnostic message is written only when the caller gives a `TextWriter`:
Expand Down
11 changes: 8 additions & 3 deletions docs/test-fixtures.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ A file may lie directly in the folder or in one level of subfolders (`2020`, `20
`ContainerRewriterSampleTests` copies every `.rvt` file in the folder and in one level of subfolders to a temporary file (deleted after
each file; it needs free disk space for the largest file) and compares the compact JSON of the copy with that of the source for the ten largest files of up to 8 MB.

`RvtWriterSampleTests` runs `RvtWriter.SaveAs` and targeted partition rewrites on every `.rvt` and `.rfa` file in the folder and in one
level of subfolders, checking stream hashes, element identities, references, geometry and framing checksums (or refusal on unsupported
containers and cross-directory relative references).

`SchemaStreamEditorSampleTests` reads the `Formats/Latest` stream of every `.rvt` file in the folder and in one level of subfolders,
whatever its name, and tests one stream per distinct schema, so one project per release is enough.

Expand All @@ -45,8 +49,8 @@ folder (`TestFiles.Scratch`); no test uses a fixed absolute path.

## What runs without the private files

With no private file at all, 10450 of the 10894 test cases run and pass on net8.0 and 444 are skipped (net10.0: 10454 of
10898, 444 skipped): 438 need private files (listed below; 17 of them only `bim_dump.txt` or `formats_latest_decompressed.bin`)
With no private file at all, 10483 of the 10929 test cases run and pass on net8.0 and 446 are skipped (net10.0: 10487 of
10933, 446 skipped): 440 need private files (listed below; 17 of them only `bim_dump.txt` or `formats_latest_decompressed.bin`)
and 6 need other inputs (two need symbolic links, three
need `RVTIO_FREEFORM_FULL`, `RVTIO_CYL_FULL` or `RVTIO_MESH_FULL`, one is a known gap). The tests that run use synthetic data built
in the tests and the small fixtures committed under `tests/RvtIO.Tests/Fixtures`. With the full private fixture folder the
Expand All @@ -56,7 +60,7 @@ Run with the private files: `RVTIO_TEST_FILES=<folder> dotnet test` (PowerShell:

## Tests that need the private files

295 test methods (438 cases, theory rows counted) are skipped without the files:
297 test methods (440 cases, theory rows counted) are skipped without the files:

| Class | Methods | Cases | Files |
|---|---|---|---|
Expand Down Expand Up @@ -109,6 +113,7 @@ Run with the private files: `RVTIO_TEST_FILES=<folder> dotnet test` (PowerShell:
| `RecordRoutingTests` | 1 | 1 | TESTFILE.rvt |
| `ReferenceApiTests` | 18 | 18 | TESTFILE.rvt, bim_dump.txt |
| `RvtSceneIdentitySampleTests` | 1 | 1 | TESTFILE.rvt |
| `RvtWriterSampleTests` | 2 | 2 | any `.rvt` or `.rfa` files |
| `SchemaModelTests` | 1 | 1 | Test_2022.rvt |
| `SchemaReleaseTests` | 2 | 7 | Test_2020.rvt, Test_2022.rvt, Test_2025.rvt, TESTFILE.rvt |
| `SchemaStreamEditorSampleTests` | 2 | 2 | any `.rvt` project files |
Expand Down
2 changes: 2 additions & 0 deletions docs/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
href: public-api.md
- name: Scene adapter
href: scene.md
- name: Writing and save-as
href: writing.md
- name: Export report and exit codes
href: export-report.md
- name: Coverage report
Expand Down
100 changes: 100 additions & 0 deletions docs/writing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Writing and save-as

`RvtWriter` and `RvtDocument.SaveAs` provide lossless container round-trips and targeted paged-stream and partition-record
rewrites for local Revit projects (`.rvt`) and families (`.rfa`), without requiring Revit or the Revit API.

```csharp
using RvtIO;

RvtWriteCapabilities capabilities = RvtWriter.InspectCapabilities("project.rvt");
if (capabilities.Supports(RvtWriteOperation.SaveAs))
{
RvtWriteResult saved = RvtWriter.SaveAs("project.rvt", "project.copy.rvt");
}
```

From the command line:

```
rvtio save-as project.rvt --check
rvtio save-as project.rvt project.copy.rvt --report save-as.report.json
```

## Write-capability matrix

Every write call inspects the source file first and checks the requested operation against the capability matrix:

| Dimension | Supported | Refused |
| --- | --- | --- |
| File kind | Local project (`.rvt`, `.rte`) and family (`.rfa`, `.rft`) containers (`RvtFileKind.Project`, `RvtFileKind.Family`) | Containers whose kind cannot be identified (`unknown-file-kind`) |
| Sharing and hosting | Non-workshared local files (`Worksharing: Not enabled`, `IsSingleUserCloudModel: False`) | Workshared central or local copies (`workshared-model`), cloud models (`cloud-model`), or missing/unrecognised `BasicFileInfo` sharing/hosting/release fields (`missing-metadata`) |
| Release and ID width | Releases `2020` to `2023` (4-byte element IDs) and `2024` to `2027` (8-byte element IDs) when `BasicFileInfo` and `Formats/Latest` agree | Releases outside `2020` to `2027` (`unsupported-release`), unrecognised schemas (`unrecognised-release`), or saved/schema mismatches (`release-mismatch`) |
| `SaveAs` | All streams, storages, storage directory-entry metadata (`CLSID`, `StateBits`, timestamps), external references, preview images, and unknown payloads copied with sector size (`512` or `4,096` bytes) preserved | Truncated containers (`RvtInputException`), corrupt page parity (`corrupt-page-parity`), or cross-directory relocation that changes relative external reference targets (`relative-reference-relocation`, `unreadable-transmission-data`) |
| `PagedStreamRewrite` | Replacing the uncompressed payload of `Formats/Latest` or `Global/<Name>` while preserving stream prefixes, gzip framing, page parity, and container compatibility | Unreadable `Global/*` streams (`unreadable-paged-stream`) or malformed/incompatible replacement `Formats/Latest` schemas (`invalid-schema-payload`, `release-mismatch`, `incompatible-schema-payload`) |
| `PartitionRecordRewrite` | Replacing targeted record bodies inside `Partitions/<n>` (`101`, `102`, `103`) while preserving untouched blocks, loaded families, channels, and unknown class payloads | Missing framing marker classes (`missing-framing-markers`), missing partition streams (`missing-partitions`), framing errors (`partition-framing-failed`), or mismatched input Adler-32 checksums (`partition-checksum-failed`) |

## Framing, checksums, and preservation invariants

Cross-stream tables in a Revit container (`Global/ElemTable`, `Global/DocumentIncrementTable`, `Global/PartitionTable`) store
element identifiers, episode numbers, and partition identifiers, never byte offsets or compressed stream lengths. Changing the
byte length of a record inside `Partitions/<n>` therefore requires no cross-stream offset table edits.

When rewriting data, `RvtWriter` enforces the following invariants:

1. **Untouched streams and storage metadata stay identical.** `SaveAs` decodes nothing; every stream (`BasicFileInfo`,
`TransmissionData`, `ProjectInformation`, `PartAtom`, `RevitPreview4.0`, `Formats/*`, `Global/*`, `Partitions/*`, and any
unrecognised streams) is copied verbatim, and root/child storage directory-entry metadata (`HeaderCLSID`, `CLSID`, `StateBits`,
and creation/modified FILETIMEs) is preserved and verified on read-back.
2. **Paged stream prefixes and parity are preserved.** `Formats/Latest` starts directly with its gzip member; `Global/*` streams
keep their exact 8-byte header prefix before the gzip member. Every rewritten paged stream is sealed with cyclic Hamming
parity pages (`64,896` payload bytes + `353` parity bytes per full page) and read back through both the parity verifier and
gzip decoder before being written.
3. **Untouched blocks, chains, and opaque payloads stay byte-for-byte identical.** Inside an edited `Partitions/<n>` stream:
- Untouched content blocks (including every keyed block belonging to a loaded family document) are copied verbatim with their
original deflate payloads and `SignatureMarker` intact.
- Within an edited block, untouched segment chains (including untouched chains on the same channel) are copied verbatim.
- Within an edited chain, untouched records (including records whose class tag has no known field layout or carries opaque
trailing bytes) are copied verbatim in file order.
- Edited records receive updated `[size][body][size]` framing and fresh Adler-32 checksums on channels `102` and `103`
(channel `101` carries no record checksum).
- Edited chains respect Revit's `131,072`-byte (`128 KB`) segment body bound, emitting standalone segments (`flags & 3 == 0`)
for batches up to `131,072` bytes and multi-segment chains (`2 -> 3 -> 1`) for larger record bodies while preserving the
source chain's upper flag bits and opaque dword.
- Edited blocks write an empty `SignatureMarker` (`n = 0, m = 0`) because their bytes are no longer signed by Revit.
4. **Post-encode verification.** Every rewritten partition stream is verified in memory before container assembly: page parity
must pass, full `PartitionFraming` must walk every block and segment without error, Adler-32 checksums must match on every
record, every unedited record must match the input byte-for-byte, and every edited record must match its requested body.
5. **Atomic persistence and source immutability.** Output is written to a sibling `.tmp` file, re-inspected against the capability
matrix when edits were applied, read back from disk to verify every storage's directory-entry metadata and every stream's
declared length and SHA-256 hash, and moved to the destination with `overwrite: false`. Existing files (including the source
file itself) are never overwritten, and any failure deletes the temporary file while leaving the source untouched.

## Refusal codes

`RvtWriteRefusedException.Kind` and `RvtOperationSupport.RefusalCode` report stable machine-readable strings:

| Code | Meaning |
| --- | --- |
| `unknown-file-kind` | Container has neither `PartAtom` nor `ProjectInformation` and its path extension is not `.rvt`/`.rte`/`.rfa`/`.rft`. |
| `cloud-model` | `BasicFileInfo` marks a single-user or workshared cloud model (`IsSingleUserCloudModel: True` or cloud central path). |
| `workshared-model` | `BasicFileInfo` reports worksharing enabled. |
| `missing-metadata` | `BasicFileInfo` is missing, unreadable, or lacks a recognised `Worksharing`, `IsSingleUserCloudModel`, or saving-release field, or `TransmissionData` is missing when relocating a file with `RvtLinkSymbol` records. |
| `relative-reference-relocation` | Saving to a different directory would change the resolved target of a relative external file reference (or an unverified `PointCloudType` path). |
| `unreadable-transmission-data` | `TransmissionData` cannot be parsed cleanly when saving to a different directory. |
| `corrupt-page-parity` | A `Formats/*`, `Global/*`, or `Partitions/*` stream fails Hamming page parity verification. |
| `missing-schema` | `Formats/Latest` is missing, unreadable, or cannot be parsed. |
| `unrecognised-release` | `Formats/Latest` does not match a known Revit schema release. |
| `release-mismatch` | `BasicFileInfo` saving release differs from the `Formats/Latest` schema release (on source inspection or after replacing `Formats/Latest`). |
| `unsupported-release` | Effective release is outside `2020` to `2027`. |
| `unreadable-paged-stream` | A `Global/*` stream fails decompression or prefix verification. |
| `missing-framing-markers` | `Formats/Latest` lacks one or more partition framing marker classes. |
| `missing-partitions` | No numbered stream exists under `Partitions/`. |
| `partition-framing-failed` | A `Partitions/<n>` stream fails strict block, segment, gzip, or owner-census framing. |
| `partition-checksum-failed` | An input record on channel `102` or `103` has a mismatched Adler-32 checksum. |
| `invalid-stream-target` | A `RvtPagedStreamEdit` names a missing or non-paged stream. |
| `invalid-schema-payload` | A replacement `Formats/Latest` payload does not parse as a valid class schema. |
| `incompatible-schema-payload` | A replacement `Formats/Latest` payload changes element ID width or framing marker class identifiers required by existing partition streams. |
| `duplicate-edit` | The same stream or `(PartitionNumber, Block, Channel, OwnerId)` record is targeted twice in one request. |
| `missing-partition` | A `RvtPartitionRecordEdit` names a `Partitions/<n>` stream that does not exist in the source container. |
| `missing-record` | A `RvtPartitionRecordEdit` names a `(Block, Channel, OwnerId)` record that does not exist in the target partition. |
| `invalid-record-target` | A `RvtPartitionRecordEdit` uses an invalid partition number, block index, channel, `-1` terminator owner, 32-bit overflow owner, or body shorter than 2 bytes. |
4 changes: 4 additions & 0 deletions src/RvtIO.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ Developer options (measurement and comparison dumps, not a stable interface):
(first argument) plan the class table edit that would let an older release load a newer
project and report it; the profile is a project or family saved by that older release;
writes nothing but the report; exit code 5 when the releases are out of scope
save-as <source.rvt|source.rfa> [<output.rvt|output.rfa>] [--check] [--report <report.json>]
(first argument) inspect the write-capability matrix (--check) or write a verified lossless
copy of a local 2020-2027 project or family; exit code 5 when refused by the matrix
coverage <file.rvt> [--report <report.json>] [--top <n>]
(first argument) count the records of the partition streams that the class schema of the
file walks to their last byte, per channel and class; writes nothing but the report
Expand Down Expand Up @@ -134,6 +137,7 @@ private static int Main(string[] args)
// --schema-dump / --schema-check / --schema-diff: the class schema embedded in a Revit file (Formats/Latest), see TfSchemaTools.
if (args[0].StartsWith("--schema-")) return RvtIO.Parsing.TfSchemaTools.Run(args, Console.Out, Console.Error);
if (args[0] == "downgrade") return RvtIO.Cli.DowngradeCommand.Run(args[1..], Console.Out, Console.Error);
if (args[0] == "save-as") return RvtIO.Cli.SaveAsCommand.Run(args[1..], Console.Out, Console.Error);
if (args[0] == "coverage") return RvtIO.Cli.CoverageCommand.Run(args[1..], Console.Out, Console.Error);
if (args[0] == "--rebar-dump") return RvtIO.RebarDump.Run(args[1..]);
if (args[0] == "--parse-block") return ParseBlock(args);
Expand Down
Loading
Loading