diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
index c119f0e..6698f16 100644
--- a/.github/workflows/cd.yml
+++ b/.github/workflows/cd.yml
@@ -2,8 +2,8 @@ name: CD
on:
push:
- tags:
- - "v*.*.*"
+ branches:
+ - main
jobs:
pack-and-publish:
diff --git a/CLAUDE.md b/CLAUDE.md
index 925b1c3..3ec9f41 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -96,3 +96,76 @@ Example in `PointExtensions.cs`:
- `PointExtensions` tests require a live Revit process — marked `[Fact(Skip = ...)]`.
- `ElementExtensions` tests require `Document`/`UIDocument` — use Moq or test inside Revit.
- Revit API package: `Revit_All_Main_Versions_API_x64` from nuget.org, versioned by `$(RevitVersion).*`.
+
+## DrawSession — transient geometry via DirectContext3D
+
+`DrawSession` renders geometry directly into the Revit graphics pipeline without creating model elements and without requiring a `Transaction`.
+
+### Registration (critical)
+
+Registration **must** happen in `IExternalApplication.OnStartup` using `RegisterDrawSession()`:
+
+```csharp
+// App.cs
+public static DrawSession? ActiveDrawSession { get; private set; }
+
+public Result OnStartup(UIControlledApplication application)
+{
+ ActiveDrawSession = application.RegisterDrawSession(); // one line
+ // ...
+}
+
+public Result OnShutdown(UIControlledApplication application)
+{
+ ActiveDrawSession?.Dispose();
+ ActiveDrawSession = null;
+ return Result.Succeeded;
+}
+```
+
+`RegisterDrawSession()` is an extension on `UIControlledApplication` in `UIControlledApplicationExtensions.cs`. Internally it:
+1. Calls `service.AddServer(session)` first
+2. Calls `service.GetActiveServerIds()` **after** AddServer (per Revit API docs)
+3. Checks `Contains` before adding to avoid duplicates
+
+`DrawSession` does **NOT** auto-register in its constructor — registration must be explicit.
+
+### UIApplication for view refresh
+
+`DrawSession` needs `UIApplication` to call `RefreshActiveView()` after drawing. Supply it via `SetUIApplication()` from the first command:
+
+```csharp
+App.ActiveDrawSession?.SetUIApplication(commandData.Application);
+```
+
+### Fluent drawing API
+
+```csharp
+session
+ .DrawLine(from, to, DrawExtensions.Blue)
+ .DrawCross(center, radius: 0.5, DrawExtensions.Red)
+ .DrawPoint(center, radius: 0.3, DrawExtensions.Green)
+ .DrawPolygon(new[] { p0, p1, p2 }, DrawExtensions.Orange)
+ .DrawBoundingBox(bbox, DrawExtensions.Cyan);
+
+session.Clear(); // remove all geometry (session stays registered)
+session.Dispose(); // unregister and release GPU buffers
+```
+
+### Known bugs fixed
+
+- `VertexBuffer.Map(0)` and `IndexBuffer.Map(0)` — `Map()` takes **size in floats/shorts**, not an offset. Passing 0 maps nothing → no vertices written → geometry invisible. Fixed to pass actual buffer sizes.
+- Auto-registration in constructor caused double-registration when App.cs also registered manually. Removed auto-registration; use `RegisterDrawSession()` instead.
+
+### CanExecute conditions
+
+`DrawSession.CanExecute(view)` returns `true` only when:
+- Session is not disposed
+- At least one line has been added (`_lines.Count > 0`)
+- The view is a `View3D`
+
+### Threading
+
+- `_lines` list and `Invalidate()` → main/UI thread only
+- `GetBoundingBox()` → may be called from render thread → reads `_cachedOutline` (volatile)
+- `RenderScene()` → render thread → calls `RebuildBuffers()` when `_isDirty`
diff --git a/README.md b/README.md
index 8468b0a..50b30de 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-

+

# Revit.Extensions
@@ -10,7 +10,7 @@
-A collection of extension methods for the **Autodesk Revit API**, designed to reduce boilerplate and make Revit add-in development more expressive. Supports **Revit 2020 – 2027**.
+A collection of extension methods for the **Autodesk Revit API**, designed to reduce boilerplate and make Revit add-in development more expressive. Supports **Revit 2020 – 2026**.
---
@@ -35,7 +35,6 @@ Or search for **`Revit.Extensions`** in the NuGet Package Manager inside Visual
| 2024 | net48 | 2024.0.\* |
| 2025 | net8.0-windows | 2025.0.\* |
| 2026 | net8.0-windows | 2026.0.\* |
-| 2027 | net8.0-windows | 2027.0.\* |
The package automatically targets the correct framework — no manual configuration needed.
@@ -43,6 +42,263 @@ The package automatically targets the correct framework — no manual configurat
## API reference
+### `DrawSession` — transient geometry (no transaction required)
+
+`DrawSession` renders geometry directly into Revit's 3D views via the **DirectContext3D** API.
+No model elements are created and no `Transaction` is needed.
+All geometry disappears when the session is disposed or `Clear()` is called.
+
+> All distances are in **Revit internal units (feet)**. Use `UnitUtils.ConvertToInternalUnits` to convert from meters or millimeters.
+
+#### Setup in `App.cs`
+
+Registration **must** happen in `IExternalApplication.OnStartup` via `RegisterDrawSession()`:
+
+```csharp
+using Revit.Extensions;
+
+public class App : IExternalApplication
+{
+ public static DrawSession? DrawSession { get; private set; }
+
+ public Result OnStartup(UIControlledApplication application)
+ {
+ // Registers the session with Revit's DirectContext3D service.
+ DrawSession = application.RegisterDrawSession();
+ return Result.Succeeded;
+ }
+
+ public Result OnShutdown(UIControlledApplication application)
+ {
+ DrawSession?.Dispose(); // unregisters the server and releases GPU buffers
+ DrawSession = null;
+ return Result.Succeeded;
+ }
+}
+```
+
+#### Supply `UIApplication` from your command
+
+`DrawSession` needs `UIApplication` to call `RefreshActiveView()` after drawing. Pass it once from the first command that uses the session:
+
+```csharp
+public Result Execute(ExternalCommandData commandData, ref string message, ElementSet elements)
+{
+ var session = App.DrawSession;
+ if (session is null) return Result.Failed;
+
+ // Supply once — idempotent, safe to call every time.
+ session.SetUIApplication(commandData.Application);
+
+ // ... drawing calls ...
+ return Result.Succeeded;
+}
+```
+
+> **Important:** Do **not** wrap `DrawSession` in a `using` statement inside a command. Revit renders the 3D view *after* `Execute()` returns, so the session must stay alive. Store it at application scope and call `Dispose()` only in `OnShutdown`.
+
+---
+
+#### Lines and wireframes
+
+```csharp
+var session = App.DrawSession;
+session.SetUIApplication(commandData.Application);
+
+// Line between two points
+session.DrawLine(new XYZ(0, 0, 0), new XYZ(10, 0, 0), DrawExtensions.Blue);
+
+// Revit Line object
+session.DrawLine(line, DrawExtensions.Red);
+
+// 3-axis cross marker (radius in feet; default ≈ 0.1 m)
+session.DrawCross(new XYZ(5, 5, 0), radius: 0.328, DrawExtensions.Red);
+
+// Point marker (alias for DrawCross)
+session.DrawPoint(new XYZ(5, 5, 0), radius: 0.328, DrawExtensions.Green);
+
+// Point marker with a text label
+session.DrawPoint(new XYZ(5, 5, 0), label: "P0", radius: 0.328, DrawExtensions.Orange);
+
+// Closed polygon (edges only)
+session.DrawPolygon(new[] { new XYZ(0,0,0), new XYZ(10,0,0), new XYZ(5,10,0) }, DrawExtensions.Orange);
+
+// Wireframe bounding box (12 edges)
+session.DrawBoundingBox(element.get_BoundingBox(null), DrawExtensions.Cyan);
+
+// Calls are fluent — chain them
+session
+ .DrawLine(p0, p1, DrawExtensions.Blue)
+ .DrawCross(p0, color: DrawExtensions.Red)
+ .DrawBoundingBox(bbox, DrawExtensions.Cyan);
+```
+
+---
+
+#### Solids and meshes
+
+`DrawSolid` and `DrawMesh` render shaded geometry with optional transparency.
+Face triangulation is performed on the **main thread** (Revit geometry API requirement); GPU buffers are built lazily on the render thread.
+
+```csharp
+// Revit Solid — shaded faces + tessellated edges
+session.DrawSolid(solid,
+ faceColor: DrawExtensions.Blue,
+ edgeColor: DrawExtensions.DarkBlue,
+ transparency: 0.5); // 0.0 = opaque, 1.0 = invisible
+
+// Revit Mesh — flat-shaded triangles
+session.DrawMesh(mesh, DrawExtensions.Green);
+
+// Extract geometry from an element and draw it
+var opts = new Options { ComputeReferences = false };
+foreach (GeometryObject obj in element.get_Geometry(opts))
+{
+ if (obj is Solid solid && solid.Volume > 0)
+ session.DrawSolid(solid, DrawExtensions.Blue, transparency: 0.3);
+}
+```
+
+---
+
+#### Box and sphere primitives
+
+Filled, shaded primitives that do not require a `Solid` — useful for debug markers:
+
+```csharp
+// Filled box from corner points
+session.DrawBox(
+ min: new XYZ(0, 0, 0),
+ max: new XYZ(3.28, 3.28, 3.28), // 1 m × 1 m × 1 m
+ faceColor: DrawExtensions.Blue,
+ edgeColor: DrawExtensions.DarkBlue,
+ transparency: 0.4);
+
+// Filled box from a BoundingBoxXYZ
+session.DrawBox(element.get_BoundingBox(null), DrawExtensions.Blue, transparency: 0.3);
+
+// Filled box from an Outline
+session.DrawBox(outline, DrawExtensions.Cyan);
+
+// UV sphere
+session.DrawSphere(
+ center: new XYZ(5, 5, 3),
+ radius: 1.64, // 0.5 m
+ faceColor: DrawExtensions.Red,
+ edgeColor: DrawExtensions.DarkBlue, // null = no equator lines
+ transparency: 0.0);
+```
+
+---
+
+#### Text labels
+
+Text is tessellated from a real system font via `System.Drawing.GraphicsPath` + `LibTessDotNet`.
+Results are cached per `(text, fontFamily)`, so repeated calls are cheap.
+Inner glyph holes (letters like **O A B D**) are handled correctly via EvenOdd winding.
+
+```csharp
+// Basic label at a position (height in feet; default 0.5 ft ≈ 15 cm)
+session.DrawText("Hello, Revit!", origin: new XYZ(0, 0, 0));
+
+// Custom height, color and font
+session.DrawText(
+ text: "Column C1",
+ origin: new XYZ(10, 5, 0),
+ height: 1.0, // ~30 cm
+ color: DrawExtensions.Red,
+ fontFamily: "Consolas");
+
+// Text on a vertical face (supply the face normal)
+session.DrawText(
+ text: "Front",
+ origin: new XYZ(0, 0, 5),
+ height: 0.5,
+ normal: XYZ.BasisY); // text lies in the XZ plane
+
+// Semi-transparent label
+session.DrawText("Ghost", origin, transparency: 0.5);
+```
+
+---
+
+#### Clearing geometry
+
+```csharp
+// Remove all drawn primitives; the session stays registered and alive.
+session.Clear();
+```
+
+---
+
+#### Available colors
+
+`DrawExtensions` provides ten predefined `Autodesk.Revit.DB.Color` values:
+
+| Property | RGB |
+| ------------- | -------------- |
+| `White` | 255, 255, 255 |
+| `Black` | 0, 0, 0 |
+| `Blue` | 0, 0, 255 |
+| `DarkBlue` | 0, 0, 102 |
+| `Red` | 255, 0, 0 |
+| `Green` | 0, 255, 0 |
+| `DarkGreen` | 0, 128, 0 |
+| `Purple` | 255, 0, 255 |
+| `Cyan` | 0, 255, 255 |
+| `Orange` | 255, 153, 76 |
+
+Pass `null` to any color parameter to use the default (`DarkBlue` for lines, `Blue` for faces).
+You can also pass any `new Color(r, g, b)` directly.
+
+---
+
+#### `DrawSession` method reference
+
+| Method | Description |
+|--------|-------------|
+| `DrawLine(from, to, color?)` | Line between two `XYZ` points |
+| `DrawLine(line, color?)` | Revit `Line` object |
+| `DrawCross(point, radius?, color?)` | 3-axis cross marker |
+| `DrawPoint(point, radius?, color?)` | Point marker (alias for `DrawCross`) |
+| `DrawPoint(point, label, radius?, color?, normal?)` | Point marker with a text label |
+| `DrawPolygon(points, color?)` | Closed polygon (wireframe) |
+| `DrawBoundingBox(bbox, color?)` | Wireframe `BoundingBoxXYZ` |
+| `DrawBoundingBox(outline, color?)` | Wireframe `Outline` |
+| `DrawSolid(solid, faceColor?, edgeColor?, transparency?)` | Shaded `Solid` with edges |
+| `DrawMesh(mesh, color?)` | Flat-shaded `Mesh` |
+| `DrawBox(min, max, faceColor?, edgeColor?, transparency?)` | Filled axis-aligned box |
+| `DrawBox(bbox, faceColor?, edgeColor?, transparency?)` | Filled box from `BoundingBoxXYZ` |
+| `DrawBox(outline, faceColor?, edgeColor?, transparency?)` | Filled box from `Outline` |
+| `DrawSphere(center, radius, faceColor?, edgeColor?, transparency?)` | Filled UV sphere |
+| `DrawText(text, origin, height?, color?, fontFamily?, transparency?, normal?)` | Tessellated text label |
+| `Clear()` | Remove all geometry (session stays alive) |
+| `SetUIApplication(uiApp)` | Supply `UIApplication` for view refresh |
+| `Dispose()` | Unregister from DirectContext3D and release GPU buffers |
+
+---
+
+### `DrawExtensions` — persistent geometry (requires transaction)
+
+Creates `DirectShape` elements and text notes permanently in the model.
+All methods must be called inside an active `Transaction`.
+
+```csharp
+using var t = new Transaction(doc, "Debug draw");
+t.Start();
+
+doc.DrawLine(pt1, pt2, DrawExtensions.Red);
+doc.DrawCross(center, radius: 0.5, DrawExtensions.Blue);
+doc.DrawPoint(center, label: "P0", radius: 0.3, DrawExtensions.Green);
+doc.DrawText("Hello", position, DrawExtensions.Purple);
+doc.DrawPolygon(new[] { p0, p1, p2 }, DrawExtensions.Orange);
+bbox.Draw(doc, DrawExtensions.Cyan, drawPoints: true);
+
+t.Commit();
+```
+
+---
+
### `GeometryExtensions`
Helpers for working with Revit geometry types (`XYZ`, `Outline`, `BoundingBoxXYZ`).
@@ -55,9 +311,9 @@ XYZ point = new XYZ(1.0, 2.0, 3.0);
(double X, double Y, double Z) vec = point.ToVector(); // (1.0, 2.0, 3.0)
XYZ restored = vec.ToXYZ(); // XYZ(1.0, 2.0, 3.0)
-// Expand an Outline uniformly (default: 10 ft)
+// Expand an Outline uniformly
Outline outline = new Outline(new XYZ(0, 0, 0), new XYZ(5, 5, 5));
-Outline expanded = outline.Extend(size: 2); // min −2, max +2 in every axis
+Outline expanded = outline.Extend(size: 2); // min −2, max +2 on every axis
// Transform a BoundingBoxXYZ from local to world space
BoundingBoxXYZ bbox = element.get_BoundingBox(view);
@@ -74,8 +330,8 @@ Unit conversion for `XYZ` coordinates using Revit's `UnitUtils`.
using Autodesk.Revit.DB;
using Revit.Extensions;
-// Revit stores coordinates in feet internally.
-// Convert a point to meters (Revit 2021+):
+// Revit stores coordinates internally in feet.
+// Convert a point from feet to meters (Revit 2021+):
XYZ pointInFeet = new XYZ(3.28084, 0, 0);
XYZ pointInMeters = pointInFeet.Recalculate(UnitTypeId.Meters);
// → XYZ(1.0, 0, 0)
@@ -136,8 +392,6 @@ Contributions are welcome! Please open an issue or submit a pull request.
3. Add your extension in `Revit.Extensions/Extensions/` with a corresponding test in `tests/Extensions/`
4. Open a pull request against `main`
-See [CLAUDE.md](CLAUDE.md) for build commands and project conventions.
-
---
## License
diff --git a/Revit.Extensions.Tests/Extensions/DoubleExtensionsTests.cs b/Revit.Extensions.Tests/Extensions/DoubleExtensionsTests.cs
new file mode 100644
index 0000000..95d1cd7
--- /dev/null
+++ b/Revit.Extensions.Tests/Extensions/DoubleExtensionsTests.cs
@@ -0,0 +1,66 @@
+using FluentAssertions;
+using Xunit;
+
+namespace Revit.Extensions.Tests;
+
+public class DoubleExtensionsTests
+{
+ // -------------------------------------------------------------------------
+ // IsAlmostEqual
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void IsAlmostEqual_EqualValues_ReturnsTrue()
+ {
+ 1.0.IsAlmostEqual(1.0).Should().BeTrue();
+ }
+
+ [Fact]
+ public void IsAlmostEqual_DifferenceWithinDefaultTolerance_ReturnsTrue()
+ {
+ 1.0.IsAlmostEqual(1.0 + 1e-10).Should().BeTrue();
+ }
+
+ [Fact]
+ public void IsAlmostEqual_DifferenceExceedsDefaultTolerance_ReturnsFalse()
+ {
+ 1.0.IsAlmostEqual(1.0 + 1e-8).Should().BeFalse();
+ }
+
+ [Fact]
+ public void IsAlmostEqual_CustomTolerance_UsesProvidedTolerance()
+ {
+ 1.0.IsAlmostEqual(1.05, tolerance: 0.1).Should().BeTrue();
+ 1.0.IsAlmostEqual(1.2, tolerance: 0.1).Should().BeFalse();
+ }
+
+ [Fact]
+ public void IsAlmostEqual_NegativeValues_WorksCorrectly()
+ {
+ (-3.0).IsAlmostEqual(-3.0 + 1e-10).Should().BeTrue();
+ (-3.0).IsAlmostEqual(-3.0 + 1e-8).Should().BeFalse();
+ }
+
+ // -------------------------------------------------------------------------
+ // Round
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void Round_DefaultDigits_RoundsToNineDecimalPlaces()
+ {
+ double value = 1.1234567891234;
+ value.Round().Should().Be(Math.Round(value, 9));
+ }
+
+ [Fact]
+ public void Round_CustomDigits_RoundsToGivenPlaces()
+ {
+ 1.23456789.Round(3).Should().BeApproximately(1.235, 1e-9);
+ }
+
+ [Fact]
+ public void Round_ZeroDigits_ReturnsInteger()
+ {
+ 1.7.Round(0).Should().Be(2.0);
+ }
+}
diff --git a/Revit.Extensions.Tests/Extensions/ElementIdExtensionsTests.cs b/Revit.Extensions.Tests/Extensions/ElementIdExtensionsTests.cs
new file mode 100644
index 0000000..b3e3e15
--- /dev/null
+++ b/Revit.Extensions.Tests/Extensions/ElementIdExtensionsTests.cs
@@ -0,0 +1,111 @@
+using Autodesk.Revit.DB;
+using FluentAssertions;
+using Xunit;
+
+namespace Revit.Extensions.Tests;
+
+///
+/// Tests for .
+/// All tests require RevitAPI.dll at runtime and are skipped in headless CI.
+///
+public class ElementIdExtensionsTests
+{
+ private const string RevitRequired = "Requires RevitAPI.dll at runtime (Revit installation).";
+
+ // -------------------------------------------------------------------------
+ // IsValid / IsInvalid
+ // -------------------------------------------------------------------------
+
+ [Fact(Skip = RevitRequired)]
+ public void IsValid_InvalidElementId_ReturnsFalse() =>
+ ElementId.InvalidElementId.IsValid().Should().BeFalse();
+
+ [Fact(Skip = RevitRequired)]
+ public void IsValid_NullElementId_ReturnsFalse() =>
+ ((ElementId)null!).IsValid().Should().BeFalse();
+
+ [Fact(Skip = RevitRequired)]
+ public void IsValid_PositiveId_ReturnsTrue() =>
+ new ElementId(1).IsValid().Should().BeTrue();
+
+ [Fact(Skip = RevitRequired)]
+ public void IsInvalid_InvalidElementId_ReturnsTrue() =>
+ ElementId.InvalidElementId.IsInvalid().Should().BeTrue();
+
+ // -------------------------------------------------------------------------
+ // IsUserCreated / IsBuiltIn
+ // -------------------------------------------------------------------------
+
+ [Fact(Skip = RevitRequired)]
+ public void IsUserCreated_PositiveId_ReturnsTrue() =>
+ new ElementId(42).IsUserCreated().Should().BeTrue();
+
+ [Fact(Skip = RevitRequired)]
+ public void IsUserCreated_InvalidElementId_ReturnsFalse() =>
+ ElementId.InvalidElementId.IsUserCreated().Should().BeFalse();
+
+ [Fact(Skip = RevitRequired)]
+ public void IsBuiltIn_WallCategory_ReturnsTrue() =>
+ new ElementId(BuiltInCategory.OST_Walls).IsBuiltIn().Should().BeTrue();
+
+ [Fact(Skip = RevitRequired)]
+ public void IsBuiltIn_PositiveId_ReturnsFalse() =>
+ new ElementId(1).IsBuiltIn().Should().BeFalse();
+
+ // -------------------------------------------------------------------------
+ // ToBuiltInCategory / ToBuiltInParameter
+ // -------------------------------------------------------------------------
+
+ [Fact(Skip = RevitRequired)]
+ public void ToBuiltInCategory_WallsCategoryId_ReturnsOstWalls()
+ {
+ var id = new ElementId(BuiltInCategory.OST_Walls);
+ id.ToBuiltInCategory().Should().Be(BuiltInCategory.OST_Walls);
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void ToBuiltInParameter_CommentsParamId_ReturnsAllModelInstanceComments()
+ {
+ var id = new ElementId(BuiltInParameter.ALL_MODEL_INSTANCE_COMMENTS);
+ id.ToBuiltInParameter().Should().Be(BuiltInParameter.ALL_MODEL_INSTANCE_COMMENTS);
+ }
+
+ // -------------------------------------------------------------------------
+ // ToElementId (factory direction)
+ // -------------------------------------------------------------------------
+
+ [Fact(Skip = RevitRequired)]
+ public void ToElementId_BuiltInCategory_RoundTrips()
+ {
+ const BuiltInCategory bic = BuiltInCategory.OST_Doors;
+ bic.ToElementId().ToBuiltInCategory().Should().Be(bic);
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void ToElementId_BuiltInParameter_RoundTrips()
+ {
+ const BuiltInParameter bip = BuiltInParameter.ELEM_FAMILY_AND_TYPE_PARAM;
+ bip.ToElementId().ToBuiltInParameter().Should().Be(bip);
+ }
+
+ // -------------------------------------------------------------------------
+ // GetElement / TryGetElement
+ // -------------------------------------------------------------------------
+
+ [Fact(Skip = RevitRequired)]
+ public void GetElement_NullId_ThrowsArgumentNullException()
+ {
+ ElementId id = null!;
+ var act = () => id.GetElement(null!);
+ act.Should().Throw();
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void TryGetElement_InvalidId_ReturnsFalse()
+ {
+ // Requires a real Document — wire up in Revit Test Runner.
+ // Document doc = ...;
+ // ElementId.InvalidElementId.TryGetElement(doc, out _).Should().BeFalse();
+ throw new NotImplementedException("Wire up with a real Revit document.");
+ }
+}
diff --git a/Revit.Extensions.Tests/Extensions/ElementTransformExtensionsTests.cs b/Revit.Extensions.Tests/Extensions/ElementTransformExtensionsTests.cs
new file mode 100644
index 0000000..becb792
--- /dev/null
+++ b/Revit.Extensions.Tests/Extensions/ElementTransformExtensionsTests.cs
@@ -0,0 +1,63 @@
+using Autodesk.Revit.DB;
+using FluentAssertions;
+using Xunit;
+
+namespace Revit.Extensions.Tests;
+
+///
+/// Tests for .
+/// All tests require a live Revit process and an open document with elements.
+///
+public class ElementTransformExtensionsTests
+{
+ private const string RevitRequired = "Requires a live Revit process with an open document.";
+
+ [Fact(Skip = RevitRequired)]
+ public void Move_XyzOverload_ReturnsSameElement()
+ {
+ // Arrange: get a wall element from an open document (via Revit Test Runner)
+ Element element = null!; // replace with real element in runner
+ var originalId = element.Id;
+
+ // Act
+ var result = element.Move(1.0, 0.0, 0.0);
+
+ // Assert: fluent return is the same element
+ result.Id.Should().Be(originalId);
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void Move_VectorOverload_ReturnsSameElement()
+ {
+ Element element = null!;
+ var result = element.Move(new XYZ(0, 1, 0));
+ result.Id.Should().Be(element.Id);
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void Rotate_ReturnsSameElement()
+ {
+ Element element = null!;
+ Line axis = Line.CreateUnbound(XYZ.Zero, XYZ.BasisZ);
+ var result = element.Rotate(axis, Math.PI / 4);
+ result.Id.Should().Be(element.Id);
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void Copy_ReturnsNewElement()
+ {
+ Element element = null!;
+ Element? copy = element.Copy(new XYZ(5, 0, 0));
+ copy.Should().NotBeNull();
+ copy!.Id.Should().NotBe(element.Id);
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void Copy_XyzComponentsOverload_ReturnsNewElement()
+ {
+ Element element = null!;
+ Element? copy = element.Copy(5.0, 0.0, 0.0);
+ copy.Should().NotBeNull();
+ copy!.Id.Should().NotBe(element.Id);
+ }
+}
diff --git a/Revit.Extensions.Tests/Extensions/GeometryExtensionsTests.cs b/Revit.Extensions.Tests/Extensions/GeometryExtensionsTests.cs
index b351e5c..4a40f4f 100644
--- a/Revit.Extensions.Tests/Extensions/GeometryExtensionsTests.cs
+++ b/Revit.Extensions.Tests/Extensions/GeometryExtensionsTests.cs
@@ -99,4 +99,286 @@ public void ToXYZ_ConvertsTupleToXyz()
result.Y.Should().BeApproximately(2.0, 1e-9);
result.Z.Should().BeApproximately(3.0, 1e-9);
}
+
+ [Fact(Skip = RevitRequired)]
+ public void SignedDistanceTo_PointAbovePlane_ReturnsPositiveDistance()
+ {
+ Plane plane = Plane.CreateByNormalAndOrigin(XYZ.BasisZ, XYZ.Zero);
+ XYZ point = new XYZ(0, 0, 5);
+ plane.SignedDistanceTo(point).Should().BeApproximately(5.0, 1e-9);
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void SignedDistanceTo_PointBelowPlane_ReturnsNegativeDistance()
+ {
+ Plane plane = Plane.CreateByNormalAndOrigin(XYZ.BasisZ, XYZ.Zero);
+ XYZ point = new XYZ(0, 0, -3);
+ plane.SignedDistanceTo(point).Should().BeApproximately(-3.0, 1e-9);
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void ProjectOnto_PointAbovePlane_ReturnsFootOfPerpendicular()
+ {
+ Plane plane = Plane.CreateByNormalAndOrigin(XYZ.BasisZ, XYZ.Zero);
+ XYZ point = new XYZ(3, 4, 7);
+ XYZ projected = plane.ProjectOnto(point);
+ projected.X.Should().BeApproximately(3.0, 1e-9);
+ projected.Y.Should().BeApproximately(4.0, 1e-9);
+ projected.Z.Should().BeApproximately(0.0, 1e-9);
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void ProjectOnto_PointOnPlane_ReturnsSamePoint()
+ {
+ Plane plane = Plane.CreateByNormalAndOrigin(XYZ.BasisZ, XYZ.Zero);
+ XYZ point = new XYZ(1, 2, 0);
+ XYZ projected = plane.ProjectOnto(point);
+ projected.X.Should().BeApproximately(1.0, 1e-9);
+ projected.Y.Should().BeApproximately(2.0, 1e-9);
+ projected.Z.Should().BeApproximately(0.0, 1e-9);
+ }
+
+ // -------------------------------------------------------------------------
+ // GetTransformed
+ // -------------------------------------------------------------------------
+
+ [Fact(Skip = RevitRequired)]
+ public void GetTransformed_IdentityTransform_ReturnsSameBounds()
+ {
+ var bbox = new BoundingBoxXYZ { Min = new XYZ(1, 2, 3), Max = new XYZ(4, 5, 6) };
+
+ var result = bbox.GetTransformed(Transform.Identity);
+
+ result.Should().NotBeNull();
+ result!.Min.X.Should().BeApproximately(1, 1e-9);
+ result.Min.Y.Should().BeApproximately(2, 1e-9);
+ result.Min.Z.Should().BeApproximately(3, 1e-9);
+ result.Max.X.Should().BeApproximately(4, 1e-9);
+ result.Max.Y.Should().BeApproximately(5, 1e-9);
+ result.Max.Z.Should().BeApproximately(6, 1e-9);
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void GetTransformed_Translation_ShiftsAllCorners()
+ {
+ var bbox = new BoundingBoxXYZ { Min = new XYZ(0, 0, 0), Max = new XYZ(1, 1, 1) };
+ Transform translation = Transform.CreateTranslation(new XYZ(5, 0, 0));
+
+ var result = bbox.GetTransformed(translation);
+
+ result!.Min.X.Should().BeApproximately(5, 1e-9);
+ result.Max.X.Should().BeApproximately(6, 1e-9);
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void GetTransformed_NullBbox_ReturnsNull()
+ {
+ BoundingBoxXYZ bbox = null!;
+ bbox.GetTransformed(Transform.Identity).Should().BeNull();
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void GetTransformed_NullTransform_ReturnsNull()
+ {
+ var bbox = new BoundingBoxXYZ { Min = XYZ.Zero, Max = new XYZ(1, 1, 1) };
+ bbox.GetTransformed(null!).Should().BeNull();
+ }
+
+ // -------------------------------------------------------------------------
+ // Contains (point)
+ // -------------------------------------------------------------------------
+
+ [Fact(Skip = RevitRequired)]
+ public void Contains_PointInsideBox_ReturnsTrue()
+ {
+ var bbox = new BoundingBoxXYZ { Min = new XYZ(0, 0, 0), Max = new XYZ(2, 2, 2) };
+ bbox.Contains(new XYZ(1, 1, 1)).Should().BeTrue();
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void Contains_PointOnBoundary_ReturnsTrue()
+ {
+ var bbox = new BoundingBoxXYZ { Min = new XYZ(0, 0, 0), Max = new XYZ(2, 2, 2) };
+ bbox.Contains(new XYZ(0, 0, 0)).Should().BeTrue();
+ bbox.Contains(new XYZ(2, 2, 2)).Should().BeTrue();
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void Contains_PointOutsideBox_ReturnsFalse()
+ {
+ var bbox = new BoundingBoxXYZ { Min = new XYZ(0, 0, 0), Max = new XYZ(2, 2, 2) };
+ bbox.Contains(new XYZ(3, 1, 1)).Should().BeFalse();
+ }
+
+ // -------------------------------------------------------------------------
+ // Contains (box)
+ // -------------------------------------------------------------------------
+
+ [Fact(Skip = RevitRequired)]
+ public void Contains_InnerBoxFullyInside_ReturnsTrue()
+ {
+ var outer = new BoundingBoxXYZ { Min = new XYZ(0, 0, 0), Max = new XYZ(4, 4, 4) };
+ var inner = new BoundingBoxXYZ { Min = new XYZ(1, 1, 1), Max = new XYZ(3, 3, 3) };
+ outer.Contains(inner).Should().BeTrue();
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void Contains_BoxPartiallyOutside_ReturnsFalse()
+ {
+ var outer = new BoundingBoxXYZ { Min = new XYZ(0, 0, 0), Max = new XYZ(2, 2, 2) };
+ var inner = new BoundingBoxXYZ { Min = new XYZ(1, 1, 1), Max = new XYZ(3, 3, 3) };
+ outer.Contains(inner).Should().BeFalse();
+ }
+
+ // -------------------------------------------------------------------------
+ // ComputeCentroid
+ // -------------------------------------------------------------------------
+
+ [Fact(Skip = RevitRequired)]
+ public void ComputeCentroid_SymmetricBox_ReturnsCentre()
+ {
+ var bbox = new BoundingBoxXYZ { Min = new XYZ(0, 0, 0), Max = new XYZ(4, 6, 8) };
+ var c = bbox.ComputeCentroid();
+ c.X.Should().BeApproximately(2, 1e-9);
+ c.Y.Should().BeApproximately(3, 1e-9);
+ c.Z.Should().BeApproximately(4, 1e-9);
+ }
+
+ // -------------------------------------------------------------------------
+ // ComputeVolume
+ // -------------------------------------------------------------------------
+
+ [Fact(Skip = RevitRequired)]
+ public void ComputeVolume_UnitBox_Returns1()
+ {
+ var bbox = new BoundingBoxXYZ { Min = new XYZ(0, 0, 0), Max = new XYZ(1, 1, 1) };
+ bbox.ComputeVolume().Should().BeApproximately(1.0, 1e-9);
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void ComputeVolume_DegenerateBox_Returns0()
+ {
+ var bbox = new BoundingBoxXYZ { Min = new XYZ(0, 0, 0), Max = new XYZ(0, 1, 1) };
+ bbox.ComputeVolume().Should().Be(0);
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void ComputeVolume_ArbitraryBox_ReturnsCorrectVolume()
+ {
+ var bbox = new BoundingBoxXYZ { Min = new XYZ(1, 2, 3), Max = new XYZ(4, 6, 8) };
+ bbox.ComputeVolume().Should().BeApproximately(60.0, 1e-9);
+ }
+
+ // -------------------------------------------------------------------------
+ // Intersects (with tolerance)
+ // -------------------------------------------------------------------------
+
+ [Fact(Skip = RevitRequired)]
+ public void Intersects_WithTolerance_BoxesJustOutOfRangeButWithinTolerance_ReturnsTrue()
+ {
+ var a = new BoundingBoxXYZ { Min = new XYZ(0, 0, 0), Max = new XYZ(1, 1, 1) };
+ var b = new BoundingBoxXYZ { Min = new XYZ(1.5, 0, 0), Max = new XYZ(3, 1, 1) };
+ a.Intersects(b, tolerance: 1.0).Should().BeTrue();
+ a.Intersects(b, tolerance: 0.1).Should().BeFalse();
+ }
+
+ // -------------------------------------------------------------------------
+ // Combine
+ // -------------------------------------------------------------------------
+
+ [Fact(Skip = RevitRequired)]
+ public void Combine_TwoBoxes_ReturnsEnclosingBox()
+ {
+ var a = new BoundingBoxXYZ { Min = new XYZ(0, 0, 0), Max = new XYZ(1, 1, 1) };
+ var b = new BoundingBoxXYZ { Min = new XYZ(-1, 2, 0), Max = new XYZ(3, 3, 5) };
+ var result = a.Combine(b);
+ result.Min.X.Should().BeApproximately(-1, 1e-9);
+ result.Min.Y.Should().BeApproximately(0, 1e-9);
+ result.Min.Z.Should().BeApproximately(0, 1e-9);
+ result.Max.X.Should().BeApproximately(3, 1e-9);
+ result.Max.Y.Should().BeApproximately(3, 1e-9);
+ result.Max.Z.Should().BeApproximately(5, 1e-9);
+ }
+
+ // -------------------------------------------------------------------------
+ // Intersects
+ // -------------------------------------------------------------------------
+
+ [Fact(Skip = RevitRequired)]
+ public void Intersects_OverlappingBoxes_ReturnsTrue()
+ {
+ var a = new BoundingBoxXYZ { Min = new XYZ(0, 0, 0), Max = new XYZ(2, 2, 2) };
+ var b = new BoundingBoxXYZ { Min = new XYZ(1, 1, 1), Max = new XYZ(3, 3, 3) };
+ a.Intersects(b).Should().BeTrue();
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void Intersects_NonOverlappingBoxes_ReturnsFalse()
+ {
+ var a = new BoundingBoxXYZ { Min = new XYZ(0, 0, 0), Max = new XYZ(1, 1, 1) };
+ var b = new BoundingBoxXYZ { Min = new XYZ(2, 2, 2), Max = new XYZ(3, 3, 3) };
+ a.Intersects(b).Should().BeFalse();
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void Intersects_TouchingFaces_ReturnsTrue()
+ {
+ var a = new BoundingBoxXYZ { Min = new XYZ(0, 0, 0), Max = new XYZ(1, 1, 1) };
+ var b = new BoundingBoxXYZ { Min = new XYZ(1, 0, 0), Max = new XYZ(2, 1, 1) };
+ a.Intersects(b).Should().BeTrue();
+ }
+
+ // -------------------------------------------------------------------------
+ // GetPathLength
+ // -------------------------------------------------------------------------
+
+ [Fact(Skip = RevitRequired)]
+ public void GetPathLength_TwoPoints_ReturnsDistance()
+ {
+ var points = new[] { new XYZ(0, 0, 0), new XYZ(3, 4, 0) };
+ points.GetPathLength().Should().BeApproximately(5.0, 1e-9);
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void GetPathLength_ThreePoints_ReturnsSumOfSegments()
+ {
+ var points = new[] { new XYZ(0, 0, 0), new XYZ(1, 0, 0), new XYZ(1, 1, 0) };
+ points.GetPathLength().Should().BeApproximately(2.0, 1e-9);
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void GetPathLength_SinglePoint_ReturnsZero()
+ {
+ var points = new[] { new XYZ(1, 2, 3) };
+ points.GetPathLength().Should().Be(0.0);
+ }
+
+ // -------------------------------------------------------------------------
+ // ProjectOnto (Curve)
+ // -------------------------------------------------------------------------
+
+ [Fact(Skip = RevitRequired)]
+ public void ProjectOnto_Line_ProjectsEndpointsOntoXyPlane()
+ {
+ Plane plane = Plane.CreateByNormalAndOrigin(XYZ.BasisZ, XYZ.Zero);
+ Curve line = Line.CreateBound(new XYZ(0, 0, 5), new XYZ(1, 0, 5));
+
+ Curve result = line.ProjectOnto(plane);
+
+ result.Should().BeOfType();
+ result.GetEndPoint(0).Z.Should().BeApproximately(0, 1e-9);
+ result.GetEndPoint(1).Z.Should().BeApproximately(0, 1e-9);
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void ProjectOnto_LineOnPlane_ReturnsSameLine()
+ {
+ Plane plane = Plane.CreateByNormalAndOrigin(XYZ.BasisZ, XYZ.Zero);
+ Curve line = Line.CreateBound(new XYZ(0, 0, 0), new XYZ(1, 0, 0));
+
+ Curve result = line.ProjectOnto(plane);
+
+ result.GetEndPoint(0).Z.Should().BeApproximately(0, 1e-9);
+ result.GetEndPoint(1).Z.Should().BeApproximately(0, 1e-9);
+ }
}
diff --git a/Revit.Extensions.Tests/Extensions/PointExtensionsTests.cs b/Revit.Extensions.Tests/Extensions/PointExtensionsTests.cs
index 1649595..b6dac3c 100644
--- a/Revit.Extensions.Tests/Extensions/PointExtensionsTests.cs
+++ b/Revit.Extensions.Tests/Extensions/PointExtensionsTests.cs
@@ -25,4 +25,54 @@ public void Recalculate_ConvertsFeetToMeters()
result.Z.Should().BeApproximately(0.0, 1e-9);
}
#endif
+
+ private const string RevitRequired = "Requires RevitAPI.dll at runtime (Revit installation).";
+
+ [Fact(Skip = RevitRequired)]
+ public void ToMillimeters_1FootPoint_Returns304_8mm()
+ {
+ var point = new XYZ(1.0, 0.0, 0.0);
+
+ var result = point.ToMillimeters();
+
+ result.X.Should().BeApproximately(304.8, 1e-9);
+ result.Y.Should().BeApproximately(0.0, 1e-9);
+ result.Z.Should().BeApproximately(0.0, 1e-9);
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void ToMillimeters_AllAxes_ConvertsEachComponent()
+ {
+ var point = new XYZ(1.0, 2.0, 0.5);
+
+ var result = point.ToMillimeters();
+
+ result.X.Should().BeApproximately(304.8, 1e-9);
+ result.Y.Should().BeApproximately(609.6, 1e-9);
+ result.Z.Should().BeApproximately(152.4, 1e-9);
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void FromMillimeters_304_8mm_Returns1Foot()
+ {
+ var point = new XYZ(304.8, 0.0, 0.0);
+
+ var result = point.FromMillimeters();
+
+ result.X.Should().BeApproximately(1.0, 1e-9);
+ result.Y.Should().BeApproximately(0.0, 1e-9);
+ result.Z.Should().BeApproximately(0.0, 1e-9);
+ }
+
+ [Fact(Skip = RevitRequired)]
+ public void ToMillimeters_ThenFromMillimeters_RoundTrips()
+ {
+ var original = new XYZ(3.5, 1.2, 0.75);
+
+ var result = original.ToMillimeters().FromMillimeters();
+
+ result.X.Should().BeApproximately(original.X, 1e-9);
+ result.Y.Should().BeApproximately(original.Y, 1e-9);
+ result.Z.Should().BeApproximately(original.Z, 1e-9);
+ }
}
diff --git a/Revit.Extensions.Tests/Extensions/UnitExtensionsTests.cs b/Revit.Extensions.Tests/Extensions/UnitExtensionsTests.cs
new file mode 100644
index 0000000..89c65f3
--- /dev/null
+++ b/Revit.Extensions.Tests/Extensions/UnitExtensionsTests.cs
@@ -0,0 +1,38 @@
+using FluentAssertions;
+using Xunit;
+
+namespace Revit.Extensions.Tests;
+
+public class UnitExtensionsTests
+{
+ [Fact]
+ public void FeetToMillimeters_1Foot_Returns304_8mm() =>
+ (1.0).FeetToMillimeters().Should().BeApproximately(304.8, 1e-9);
+
+ [Fact]
+ public void FeetToMillimeters_0_ReturnsZero() =>
+ (0.0).FeetToMillimeters().Should().Be(0.0);
+
+ [Fact]
+ public void MillimetersToFeet_304_8mm_Returns1Foot() =>
+ (304.8).MillimetersToFeet().Should().BeApproximately(1.0, 1e-9);
+
+ [Fact]
+ public void FeetToMillimeters_ThenBack_RoundTrips() =>
+ (3.5).FeetToMillimeters().MillimetersToFeet().Should().BeApproximately(3.5, 1e-9);
+
+ [Fact] public void IsZero_ZeroValue_ReturnsTrue() =>
+ (0.0).IsZero().Should().BeTrue();
+
+ [Fact] public void IsZero_SmallValue_ReturnsFalse() =>
+ (1e-8).IsZero().Should().BeFalse();
+
+ [Fact] public void IsZero_WithTolerance_ReturnsTrue() =>
+ (1e-8).IsZero(1e-7).Should().BeTrue();
+
+ [Fact] public void IsEqual_EqualValues_ReturnsTrue() =>
+ (1.0).IsEqual(1.0).Should().BeTrue();
+
+ [Fact] public void IsEqual_DifferentValues_ReturnsFalse() =>
+ (1.0).IsEqual(2.0).Should().BeFalse();
+}
diff --git a/Revit.Extensions/Extensions/ConnectorExtensions.cs b/Revit.Extensions/Extensions/ConnectorExtensions.cs
new file mode 100644
index 0000000..031db11
--- /dev/null
+++ b/Revit.Extensions/Extensions/ConnectorExtensions.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Globalization;
+using Autodesk.Revit.DB;
+
+namespace Revit.Extensions;
+
+///
+/// Extension methods for .
+///
+///
+/// Revit internally parses connector dimension values using the current thread culture.
+/// All setters here temporarily switch to en-US to avoid
+/// when the system locale uses a comma as decimal separator.
+///
+public static class ConnectorExtensions
+{
+ /// Sets the connector radius, guarding against culture-dependent parse errors.
+ public static void SetRadius(this Connector connector, double radius)
+ {
+ using (new CultureScope())
+ connector.Radius = radius;
+ }
+
+ ///
+ /// Attempts to set the connector radius.
+ /// Returns false and leaves the connector unchanged when the operation fails.
+ ///
+ public static bool TrySetRadius(this Connector connector, double radius)
+ {
+ try { connector.SetRadius(radius); return true; }
+ catch { return false; }
+ }
+
+ /// Sets the connector width, guarding against culture-dependent parse errors.
+ public static void SetWidth(this Connector connector, double width)
+ {
+ using (new CultureScope())
+ connector.Width = width;
+ }
+
+ /// Sets the connector height, guarding against culture-dependent parse errors.
+ public static void SetHeight(this Connector connector, double height)
+ {
+ using (new CultureScope())
+ connector.Height = height;
+ }
+
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Temporarily sets to en-US
+ /// and restores the original on disposal.
+ ///
+ private sealed class CultureScope : IDisposable
+ {
+ private readonly CultureInfo _previous = CultureInfo.CurrentCulture;
+
+ public CultureScope() => CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
+
+ public void Dispose() => CultureInfo.CurrentCulture = _previous;
+ }
+}
diff --git a/Revit.Extensions/Extensions/DirectShapeExtensions.cs b/Revit.Extensions/Extensions/DirectShapeExtensions.cs
new file mode 100644
index 0000000..f210155
--- /dev/null
+++ b/Revit.Extensions/Extensions/DirectShapeExtensions.cs
@@ -0,0 +1,48 @@
+using Autodesk.Revit.DB;
+
+namespace Revit.Extensions;
+
+///
+/// Extension methods for creating elements.
+///
+public static class DirectShapeExtensions
+{
+ private const BuiltInCategory DefaultCategory = BuiltInCategory.OST_GenericModel;
+
+ ///
+ /// Creates a containing
+ /// in the given .
+ ///
+ public static DirectShape CreateDirectShape(
+ this Document document,
+ IEnumerable geometryObjects,
+ BuiltInCategory category = DefaultCategory)
+ {
+ var shape = DirectShape.CreateElement(document, new ElementId(category));
+ shape.SetName(shape.Category.Name);
+ shape.SetShape(geometryObjects.ToList());
+ return shape;
+ }
+
+ ///
+ /// Creates a containing a single
+ /// .
+ ///
+ public static DirectShape CreateDirectShape(
+ this Document document,
+ GeometryObject geometryObject,
+ BuiltInCategory category = DefaultCategory) =>
+ document.CreateDirectShape([geometryObject], category);
+
+ ///
+ /// Creates a visualising the line segment between
+ /// and .
+ /// Useful for debug visualisation of edges and path segments.
+ ///
+ public static DirectShape CreateDirectShape(
+ this Document document,
+ XYZ point1,
+ XYZ point2,
+ BuiltInCategory category = DefaultCategory) =>
+ document.CreateDirectShape(Line.CreateBound(point1, point2), category);
+}
diff --git a/Revit.Extensions/Extensions/DockablePaneExtensions.cs b/Revit.Extensions/Extensions/DockablePaneExtensions.cs
new file mode 100644
index 0000000..43e35bb
--- /dev/null
+++ b/Revit.Extensions/Extensions/DockablePaneExtensions.cs
@@ -0,0 +1,75 @@
+using System.Windows;
+using Autodesk.Revit.UI;
+
+namespace Revit.Extensions;
+
+///
+/// Extension methods for registering and controlling Revit dockable panes.
+///
+public static class DockablePaneExtensions
+{
+ ///
+ /// Registers a dockable pane backed by without requiring a
+ /// manual implementation.
+ ///
+ ///
+ ///
+ /// application.RegisterDockablePane(
+ /// new DockablePaneId(new Guid("...")),
+ /// "My panel",
+ /// new MyPage(),
+ /// dockPosition: DockPosition.Right,
+ /// minimumWidth: 400,
+ /// visibleByDefault: true);
+ ///
+ ///
+ public static void RegisterDockablePane(
+ this UIControlledApplication application,
+ DockablePaneId id,
+ string title,
+ FrameworkElement content,
+ DockPosition dockPosition = DockPosition.Right,
+ int minimumWidth = 300,
+ bool visibleByDefault = false)
+ {
+ var data = new DockablePaneProviderData
+ {
+ FrameworkElement = content,
+ VisibleByDefault = visibleByDefault,
+ InitialState = new DockablePaneState
+ {
+ DockPosition = dockPosition,
+ MinimumWidth = minimumWidth,
+ },
+ };
+
+ application.RegisterDockablePane(id, title, new InlineProvider(data));
+ }
+
+ ///
+ /// Shows the pane if it is currently hidden; hides it if it is currently shown.
+ ///
+ public static void Toggle(this DockablePane pane)
+ {
+ if (pane.IsShown())
+ pane.Hide();
+ else
+ pane.Show();
+ }
+
+ // -------------------------------------------------------------------------
+
+ private sealed class InlineProvider : IDockablePaneProvider
+ {
+ private readonly DockablePaneProviderData _data;
+
+ internal InlineProvider(DockablePaneProviderData data) => _data = data;
+
+ public void SetupDockablePane(DockablePaneProviderData data)
+ {
+ data.FrameworkElement = _data.FrameworkElement;
+ data.VisibleByDefault = _data.VisibleByDefault;
+ data.InitialState = _data.InitialState;
+ }
+ }
+}
diff --git a/Revit.Extensions/Extensions/DocumentExtensions.cs b/Revit.Extensions/Extensions/DocumentExtensions.cs
index 61958e0..cadb3d4 100644
--- a/Revit.Extensions/Extensions/DocumentExtensions.cs
+++ b/Revit.Extensions/Extensions/DocumentExtensions.cs
@@ -7,5 +7,60 @@ namespace Revit.Extensions;
///
public static class DocumentExtensions
{
- // TODO: Add Document extension methods
+ ///
+ /// Returns all values that have a valid
+ /// in .
+ /// Enum values that throw or resolve to null are silently skipped.
+ ///
+ public static IList GetValidCategories(this Document document)
+ {
+ return Enum.GetValues(typeof(BuiltInCategory))
+ .Cast()
+ .Where(bic =>
+ {
+ try { return Category.GetCategory(document, bic) is not null; }
+ catch { return false; }
+ })
+ .ToList();
+ }
+
+ ///
+ /// Returns the whose display name equals
+ /// , or
+ /// when no match is found.
+ ///
+ public static BuiltInCategory GetBuiltInCategory(this Document document, string categoryName)
+ {
+#if REVIT_2024 || REVIT_2023 || REVIT_2022 || REVIT_2021 || REVIT_2020
+ return document.GetValidCategories()
+ .FirstOrDefault(
+ bic => Category.GetCategory(document, bic).Name == categoryName);
+#else
+
+ return document.GetValidCategories()
+ .FirstOrDefault(
+ bic => Category.GetCategory(document, bic).Name == categoryName,
+ BuiltInCategory.INVALID);
+#endif
+ }
+
+ ///
+ /// Returns true when a view named exists in
+ /// .
+ ///
+ public static bool ViewExists(this Document document, string viewName)
+ {
+ var pvp = new ParameterValueProvider(new ElementId(BuiltInParameter.VIEW_NAME));
+#if REVIT_2020 || REVIT_2021 || REVIT_2022
+ var rule = new FilterStringRule(pvp, new FilterStringEquals(), viewName, true);
+#else
+ var rule = new FilterStringRule(pvp, new FilterStringEquals(), viewName);
+#endif
+ var filter = new ElementParameterFilter(rule);
+
+ return new FilteredElementCollector(document)
+ .OfCategory(BuiltInCategory.OST_Views)
+ .WherePasses(filter)
+ .FirstOrDefault() is View;
+ }
}
diff --git a/Revit.Extensions/Extensions/DoubleExtensions.cs b/Revit.Extensions/Extensions/DoubleExtensions.cs
new file mode 100644
index 0000000..91a8745
--- /dev/null
+++ b/Revit.Extensions/Extensions/DoubleExtensions.cs
@@ -0,0 +1,24 @@
+namespace Revit.Extensions;
+
+///
+/// Extension methods for values, tuned to Revit's numeric precision.
+///
+public static class DoubleExtensions
+{
+ private const double DefaultTolerance = 1e-9;
+
+ ///
+ /// Returns true when the absolute difference between
+ /// and is within .
+ /// Default tolerance (1e-9) matches Revit's internal precision.
+ ///
+ public static bool IsAlmostEqual(this double value, double other, double tolerance = DefaultTolerance) =>
+ Math.Abs(value - other) <= tolerance;
+
+ ///
+ /// Rounds to decimal places.
+ /// Defaults to 9 digits, matching Revit's internal precision.
+ ///
+ public static double Round(this double value, int digits = 9) =>
+ Math.Round(value, digits);
+}
diff --git a/Revit.Extensions/Extensions/DrawExtensions.cs b/Revit.Extensions/Extensions/DrawExtensions.cs
new file mode 100644
index 0000000..60cdb9a
--- /dev/null
+++ b/Revit.Extensions/Extensions/DrawExtensions.cs
@@ -0,0 +1,266 @@
+using Autodesk.Revit.DB;
+
+namespace Revit.Extensions;
+
+///
+/// Visualization helpers that create geometry
+/// and text notes in a Revit view.
+///
+///
+/// All methods must be called inside an active .
+///
+/// using var t = new Transaction(doc, "Draw");
+/// t.Start();
+/// doc.DrawLine(pt1, pt2, DrawExtensions.Red);
+/// doc.DrawPoint(pt);
+/// t.Commit();
+///
+///
+public static class DrawExtensions
+{
+ // -------------------------------------------------------------------------
+ // Predefined Revit colors
+ // -------------------------------------------------------------------------
+
+ public static Color White => new(255, 255, 255);
+ public static Color Black => new(0, 0, 0);
+ public static Color Blue => new(0, 0, 255);
+ public static Color Red => new(255, 0, 0);
+ public static Color Green => new(0, 255, 0);
+ public static Color DarkGreen => new(0, 128, 0);
+ public static Color Purple => new(255, 0, 255);
+ public static Color Cyan => new(0, 255, 255);
+ public static Color Orange => new(255, 153, 76);
+ public static Color DarkBlue => new(0, 0, 102);
+
+ // -------------------------------------------------------------------------
+ // DrawLine
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Creates a line in the model.
+ /// Returns null when the line is degenerate (zero length).
+ ///
+ public static DirectShape? DrawLine(this Document document,
+ Line line,
+ Color? color = null,
+ View? view = null)
+ {
+ if (line.Length <= 0)
+ return null;
+
+ DirectShape shape = document.CreateDirectShape(line);
+ shape.SetName("DebugLine");
+ ApplyColor(shape, document, color ?? DarkBlue, view);
+ return shape;
+ }
+
+ /// Creates a line between two points.
+ public static DirectShape? DrawLine(this Document document,
+ XYZ point1,
+ XYZ point2,
+ Color? color = null,
+ View? view = null)
+ {
+ if (point1.IsAlmostEqualTo(point2))
+ return null;
+
+ return document.DrawLine(Line.CreateBound(point1, point2), color, view);
+ }
+
+ // -------------------------------------------------------------------------
+ // DrawCross
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Creates two perpendicular lines centred on
+ /// along the X and Y axes.
+ ///
+ /// length in feet
+ public static void DrawCross(this Document document,
+ XYZ point,
+ double radius = 0.328084,
+ Color? color = null,
+ View? view = null)
+ {
+ Color c = color ?? DarkBlue;
+ document.DrawLine(point - XYZ.BasisX * radius, point + XYZ.BasisX * radius, c, view);
+ document.DrawLine(point - XYZ.BasisY * radius, point + XYZ.BasisY * radius, c, view);
+ }
+
+ // -------------------------------------------------------------------------
+ // DrawPoint
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Creates a sphere at ,
+ /// optionally labelled with a .
+ ///
+ /// radius in feet
+ public static DirectShape DrawPoint(this Document document,
+ XYZ point,
+ string? label = null,
+ double radius = 0.328084,
+ Color? color = null,
+ View? view = null)
+ {
+ Color c = color ?? DarkBlue;
+
+ Arc arc = Arc.Create(
+ point + XYZ.BasisZ * radius,
+ point - XYZ.BasisZ * radius,
+ point + XYZ.BasisX * radius);
+
+ Line axis = Line.CreateBound(
+ point - XYZ.BasisZ * radius,
+ point + XYZ.BasisZ * radius);
+
+ CurveLoop profile = new();
+ profile.Append(axis);
+ profile.Append(arc);
+
+ Solid sphere = GeometryCreationUtilities.CreateRevolvedGeometry(new Frame(point, XYZ.BasisX, XYZ.BasisY, XYZ.BasisZ),
+ [profile], -Math.PI, Math.PI);
+
+ DirectShape shape = document.CreateDirectShape(sphere);
+ shape.SetName("DebugPoint");
+
+ FillPatternElement? fill = FindSolidFillPattern(document);
+ if (fill is not null)
+ ApplyColorWithFill(shape, document, c, fill, view);
+ else
+ ApplyColor(shape, document, c, view);
+
+ if (!string.IsNullOrEmpty(label))
+ document.DrawText(label!, point, c, view);
+
+ return shape;
+ }
+
+ // -------------------------------------------------------------------------
+ // DrawText
+ // -------------------------------------------------------------------------
+
+ /// Creates a at .
+ public static TextNote DrawText(this Document document,
+ string text,
+ XYZ point,
+ Color? color = null,
+ View? view = null)
+ {
+ view ??= document.ActiveView;
+ ElementId typeId = document.GetDefaultElementTypeId(ElementTypeGroup.TextNoteType);
+ TextNote note = TextNote.Create(document, view.Id, point, text, typeId);
+ view.SetElementOverrides(note.Id, BuildOverride(color ?? DarkBlue));
+ return note;
+ }
+
+ // -------------------------------------------------------------------------
+ // DrawPolygon
+ // -------------------------------------------------------------------------
+
+ /// Creates lines for each segment in .
+ public static void DrawPolygon(this Document document,
+ IEnumerable lines,
+ Color? color = null,
+ View? view = null)
+ {
+ foreach (Line line in lines)
+ document.DrawLine(line, color ?? Black, view);
+ }
+
+ ///
+ /// Creates lines connecting
+ /// as a closed polygon.
+ ///
+ public static void DrawPolygon(
+ this Document document,
+ IEnumerable points,
+ Color? color = null,
+ View? view = null) =>
+ document.DrawPolygon(points.GetPolygonLines(), color ?? Blue, view);
+
+ // -------------------------------------------------------------------------
+ // Draw bounding boxes
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Draws the 12 edges of as lines.
+ /// Pass to also mark the 8 corners with spheres.
+ ///
+ public static void Draw(this BoundingBoxXYZ bbox,
+ Document document,
+ Color? color = null,
+ bool drawPoints = false,
+ View? view = null) =>
+ DrawBox(document, bbox.ComputeVertices(), color ?? Green, drawPoints, view);
+
+ ///
+ /// Draws the 12 edges of as lines.
+ ///
+ public static void DrawDebug(
+ this Outline outline,
+ Document document,
+ Color? color = null,
+ bool drawPoints = false,
+ View? view = null) =>
+ DrawBox(document, outline.ComputeVertices(), color ?? Green, drawPoints, view);
+
+ // -------------------------------------------------------------------------
+ // Private helpers
+ // -------------------------------------------------------------------------
+
+ private static void DrawBox(Document document, XYZ[] v, Color color, bool drawPoints, View? view)
+ {
+ // Bottom face, top face
+ document.DrawPolygon([v[0], v[1], v[2], v[3]], color, view);
+ document.DrawPolygon([v[4], v[5], v[6], v[7]], color, view);
+
+ // Vertical edges
+ foreach ((int b, int t) in new[] { (0, 4), (1, 5), (2, 6), (3, 7) })
+ document.DrawLine(v[b], v[t], color, view);
+
+ if (drawPoints)
+ foreach (XYZ pt in v)
+ document.DrawPoint(pt, color: color, view: view);
+ }
+
+ private static void ApplyColor(DirectShape shape, Document document, Color color, View? view)
+ {
+ view ??= document.ActiveView;
+ view.SetElementOverrides(shape.Id, BuildOverride(color));
+ }
+
+ private static void ApplyColorWithFill(
+ DirectShape shape, Document document, Color color, FillPatternElement fill, View? view)
+ {
+ view ??= document.ActiveView;
+ var ogs = new OverrideGraphicSettings();
+ ogs.SetSurfaceForegroundPatternColor(color);
+ ogs.SetSurfaceForegroundPatternId(fill.Id);
+ ogs.SetSurfaceForegroundPatternVisible(true);
+ ogs.SetSurfaceBackgroundPatternColor(color);
+ ogs.SetSurfaceBackgroundPatternId(fill.Id);
+ ogs.SetSurfaceBackgroundPatternVisible(true);
+ view.SetElementOverrides(shape.Id, ogs);
+ }
+
+ private static OverrideGraphicSettings BuildOverride(Color color)
+ {
+ var ogs = new OverrideGraphicSettings();
+ ogs.SetSurfaceForegroundPatternColor(color);
+ ogs.SetSurfaceBackgroundPatternColor(color);
+ ogs.SetSurfaceForegroundPatternVisible(true);
+ ogs.SetSurfaceBackgroundPatternVisible(true);
+ ogs.SetCutLineColor(color);
+ ogs.SetProjectionLineColor(color);
+ return ogs;
+ }
+
+ private static FillPatternElement? FindSolidFillPattern(Document document) =>
+ new FilteredElementCollector(document)
+ .OfClass(typeof(FillPatternElement))
+ .WhereElementIsNotElementType()
+ .OfType()
+ .FirstOrDefault(f => f.GetFillPattern().IsSolidFill);
+}
diff --git a/Revit.Extensions/Extensions/DrawSession.cs b/Revit.Extensions/Extensions/DrawSession.cs
new file mode 100644
index 0000000..98e7b12
--- /dev/null
+++ b/Revit.Extensions/Extensions/DrawSession.cs
@@ -0,0 +1,1072 @@
+using Autodesk.Revit.DB;
+using Autodesk.Revit.DB.DirectContext3D;
+using Autodesk.Revit.DB.ExternalService;
+using Autodesk.Revit.UI;
+
+namespace Revit.Extensions;
+
+///
+/// Renders truly transient geometry via .
+/// Supports lines, solids (faces + edges) and meshes.
+/// No model elements are created and no is required.
+/// Geometry is visible until the instance is disposed or is called.
+///
+///
+/// Registration is NOT automatic — call
+/// from
+/// IExternalApplication.OnStartup:
+///
+/// // OnStartup
+/// App.Session = application.RegisterDrawSession();
+///
+/// // OnShutdown
+/// App.Session?.Dispose();
+///
+///
+/// The session must outlive the command that draws into it — do NOT wrap it in a
+/// using statement inside IExternalCommand.Execute().
+/// Revit renders the view after Execute() returns, so the session must
+/// still be alive at that point.
+///
+/// // ✓ correct — stored at application scope, disposed in OnShutdown
+/// App.Session.DrawLine(pt1, pt2);
+///
+/// // ✗ wrong — disposed before Revit renders
+/// using var s = new DrawSession(); s.DrawLine(pt1, pt2);
+///
+///
+public sealed class DrawSession : IDirectContext3DServer, IDisposable
+{
+ // -------------------------------------------------------------------------
+ // Primitive storage (main thread only)
+ // -------------------------------------------------------------------------
+
+ private readonly List<(XYZ From, XYZ To, byte R, byte G, byte B)> _lines = new();
+
+ // Pre-computed triangle data — populated on the main thread inside DrawSolid/DrawMesh.
+ // Revit geometry API (face.Triangulate, face.ComputeNormal, mesh.get_Triangle) has
+ // main-thread affinity and must NOT be called from the render thread.
+ private readonly List<(byte R, byte G, byte B, double Transparency, List<(XYZ V0, XYZ V1, XYZ V2, XYZ Normal)> Tris)> _triData = new();
+
+ // Cache of normalised glyph triangles: key=(text, fontFamily),
+ // value=list of (x0,y0, x1,y1, x2,y2) in font-height-normalised coords (height=1.0, Y-up).
+ // Populated on the main thread in DrawText; safe to read from the same thread thereafter.
+ private static readonly Dictionary<(string, string), List<(float, float, float, float, float, float)>>
+ _textCache = new Dictionary<(string, string), List<(float, float, float, float, float, float)>>();
+
+ // -------------------------------------------------------------------------
+ // GPU buffers (rebuilt on render thread when _isDirty is true)
+ // -------------------------------------------------------------------------
+
+ // Line buffers — VertexFormatBits.Position + LineList
+ private readonly List<(VertexBuffer Vb, int VbCount, IndexBuffer Ib, int IbCount, int PrimCount, byte R, byte G, byte B)> _lineBuffers = new();
+
+ // Triangle buffers — VertexFormatBits.PositionNormal + TriangleList
+ // Transparency field: 0.0 = opaque, 1.0 = fully transparent.
+ private readonly List<(VertexBuffer Vb, int VbCount, IndexBuffer Ib, int IbCount, int PrimCount, byte R, byte G, byte B, double Transparency)> _triBuffers = new();
+
+ private volatile bool _isDirty;
+ private volatile bool _hasTransparency;
+
+ // Bounding box cached on the main thread; read on the render thread.
+ // Per the DirectContext3D spec, GetBoundingBox() may be called from a
+ // different thread — never compute it inline from _lines/_solids/_meshes there.
+ private volatile Outline? _cachedOutline;
+
+ private readonly Guid _serverId = Guid.NewGuid();
+ private UIApplication? _uiApp;
+ private bool _disposed;
+
+ // -------------------------------------------------------------------------
+ // Construction
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Creates the draw session.
+ ///
+ ///
+ /// Optional. Required only for automatic view refresh after drawing calls.
+ /// Can be omitted when constructing via
+ /// (called in
+ /// OnStartup) and supplied later via .
+ ///
+ ///
+ /// Registration with Revit's DirectContext3D service is NOT performed automatically.
+ /// Use instead.
+ ///
+ public DrawSession(UIApplication? uiApp = null)
+ {
+ _uiApp = uiApp;
+ }
+
+ ///
+ /// Supplies the needed for automatic view refresh.
+ /// Call this once from the first that uses the session.
+ ///
+ public void SetUIApplication(UIApplication uiApp) =>
+ _uiApp = uiApp ?? throw new ArgumentNullException(nameof(uiApp));
+
+ // -------------------------------------------------------------------------
+ // Fluent drawing API — lines (call from main / UI thread)
+ // -------------------------------------------------------------------------
+
+ /// Draws a line between two points.
+ public DrawSession DrawLine(XYZ from, XYZ to, Color? color = null)
+ {
+ if (from.IsAlmostEqualTo(to)) return this;
+ Color c = color ?? DrawExtensions.DarkBlue;
+ _lines.Add((from, to, c.Red, c.Green, c.Blue));
+ Invalidate();
+ return this;
+ }
+
+ /// Draws a .
+ public DrawSession DrawLine(Line line, Color? color = null) =>
+ DrawLine(line.GetEndPoint(0), line.GetEndPoint(1), color);
+
+ /// Draws a 3-axis cross centred on .
+ public DrawSession DrawCross(XYZ point, double radius = 0.328084, Color? color = null)
+ {
+ Color c = color ?? DrawExtensions.DarkBlue;
+ return DrawLine(point - XYZ.BasisX * radius, point + XYZ.BasisX * radius, c)
+ .DrawLine(point - XYZ.BasisY * radius, point + XYZ.BasisY * radius, c)
+ .DrawLine(point - XYZ.BasisZ * radius, point + XYZ.BasisZ * radius, c);
+ }
+
+ /// Draws a point marker (3-axis cross) at .
+ public DrawSession DrawPoint(XYZ point, double radius = 0.328084, Color? color = null) =>
+ DrawCross(point, radius, color);
+
+ ///
+ /// Draws a point marker (3-axis cross) with a text label next to it.
+ ///
+ /// Point position in world space.
+ /// Text label to display next to the point.
+ /// Cross half-size in Revit internal units (feet). Default ≈ 0.1 m.
+ /// Color for both cross and label. Defaults to .
+ ///
+ /// Face normal that defines the plane in which the label lies.
+ /// null defaults to (XY plane, horizontal text).
+ ///
+ public DrawSession DrawPoint(XYZ point, string label, double radius = 0.328084,
+ Color? color = null, XYZ? normal = null)
+ {
+ DrawCross(point, radius, color);
+ double textHeight = radius * 2.5;
+ var (right, up) = ComputeTextFrame((normal ?? XYZ.BasisZ).Normalize());
+ XYZ textOrigin = point + right * (radius * 1.5) + up * (-textHeight * 0.5);
+ return DrawText(label, textOrigin, textHeight, color, normal: normal);
+ }
+
+ /// Draws as a closed polygon.
+ public DrawSession DrawPolygon(IEnumerable points, Color? color = null)
+ {
+ var list = points as IList ?? points.ToList();
+ for (int i = 0; i < list.Count; i++)
+ DrawLine(list[i], list[(i + 1) % list.Count], color);
+ return this;
+ }
+
+ /// Draws the 12 edges of (wireframe only).
+ public DrawSession DrawBoundingBox(BoundingBoxXYZ bbox, Color? color = null) =>
+ DrawWireBox(bbox.ComputeVertices(), color);
+
+ /// Draws the 12 edges of (wireframe only).
+ public DrawSession DrawBoundingBox(Outline outline, Color? color = null) =>
+ DrawWireBox(outline.ComputeVertices(), color);
+
+ // -------------------------------------------------------------------------
+ // Fluent drawing API — solids & meshes (call from main / UI thread)
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Draws a with shaded faces and tessellated edges.
+ /// Faces are rendered as PositionNormalColored triangles; edges as lines.
+ ///
+ /// The solid to render.
+ /// Face fill color. Defaults to .
+ /// Edge line color. Defaults to .
+ /// Face transparency 0.0 (opaque) – 1.0 (invisible).
+ public DrawSession DrawSolid(Solid solid,
+ Color? faceColor = null,
+ Color? edgeColor = null,
+ double transparency = 0.0)
+ {
+ if (solid is null || solid.Volume <= 0) return this;
+
+ Color fc = faceColor ?? DrawExtensions.Blue;
+ Color ec = edgeColor ?? DrawExtensions.DarkBlue;
+ double t = transparency < 0.0 ? 0.0 : transparency > 1.0 ? 1.0 : transparency;
+
+ // Pre-compute face triangles HERE on the main thread.
+ // Revit geometry API (Triangulate, ComputeNormal) has main-thread affinity —
+ // calling it from the render thread (RebuildBuffers) returns garbage silently,
+ // producing zero-length normals and black faces.
+ var tris = new List<(XYZ V0, XYZ V1, XYZ V2, XYZ Normal)>();
+ foreach (Face face in solid.Faces)
+ {
+ try
+ {
+ Mesh m = face.Triangulate();
+ if (m == null) continue;
+ AppendFaceTriangles(face, m, tris);
+ }
+ catch { }
+ }
+ if (tris.Count > 0)
+ _triData.Add((fc.Red, fc.Green, fc.Blue, t, tris));
+
+ // Tessellate edges as lines — they share the line buffer pipeline.
+ foreach (Edge edge in solid.Edges)
+ {
+ IList pts = edge.Tessellate();
+ for (int i = 0; i < pts.Count - 1; i++)
+ if (!pts[i].IsAlmostEqualTo(pts[i + 1]))
+ _lines.Add((pts[i], pts[i + 1], ec.Red, ec.Green, ec.Blue));
+ }
+
+ Invalidate();
+ return this;
+ }
+
+ ///
+ /// Draws a as flat-shaded triangles (per-triangle normals).
+ ///
+ /// The mesh to render.
+ /// Fill color. Defaults to .
+ public DrawSession DrawMesh(Mesh mesh, Color? color = null)
+ {
+ if (mesh is null || mesh.NumTriangles == 0) return this;
+
+ Color c = color ?? DrawExtensions.Blue;
+
+ // Pre-compute triangles on the main thread — same reason as DrawSolid.
+ var tris = new List<(XYZ V0, XYZ V1, XYZ V2, XYZ Normal)>();
+ AppendMeshTriangles(mesh, tris);
+ if (tris.Count > 0)
+ _triData.Add((c.Red, c.Green, c.Blue, 0.0, tris));
+
+ Invalidate();
+ return this;
+ }
+
+ ///
+ /// Draws a filled axis-aligned box defined by and corners.
+ ///
+ /// Minimum corner (world space).
+ /// Maximum corner (world space).
+ /// Face fill color. Defaults to .
+ /// Edge line color. Defaults to .
+ /// Face transparency 0.0 (opaque) – 1.0 (invisible).
+ public DrawSession DrawBox(XYZ min, XYZ max,
+ Color? faceColor = null,
+ Color? edgeColor = null,
+ double transparency = 0.0)
+ {
+ Color fc = faceColor ?? DrawExtensions.Blue;
+ Color ec = edgeColor ?? DrawExtensions.DarkBlue;
+ double t = transparency < 0.0 ? 0.0 : transparency > 1.0 ? 1.0 : transparency;
+
+ var tris = new List<(XYZ V0, XYZ V1, XYZ V2, XYZ Normal)>();
+ AppendBoxTriangles(min, max, tris);
+ if (tris.Count > 0)
+ _triData.Add((fc.Red, fc.Green, fc.Blue, t, tris));
+
+ // 12 wireframe edges
+ XYZ v0 = min, v1 = new XYZ(min.X, max.Y, min.Z);
+ XYZ v2 = new XYZ(max.X, max.Y, min.Z), v3 = new XYZ(max.X, min.Y, min.Z);
+ XYZ v4 = new XYZ(min.X, min.Y, max.Z), v5 = new XYZ(min.X, max.Y, max.Z);
+ XYZ v6 = max, v7 = new XYZ(max.X, min.Y, max.Z);
+ byte er = ec.Red, eg = ec.Green, eb = ec.Blue;
+ _lines.Add((v0, v1, er, eg, eb)); _lines.Add((v1, v2, er, eg, eb));
+ _lines.Add((v2, v3, er, eg, eb)); _lines.Add((v3, v0, er, eg, eb));
+ _lines.Add((v4, v5, er, eg, eb)); _lines.Add((v5, v6, er, eg, eb));
+ _lines.Add((v6, v7, er, eg, eb)); _lines.Add((v7, v4, er, eg, eb));
+ _lines.Add((v0, v4, er, eg, eb)); _lines.Add((v1, v5, er, eg, eb));
+ _lines.Add((v2, v6, er, eg, eb)); _lines.Add((v3, v7, er, eg, eb));
+
+ Invalidate();
+ return this;
+ }
+
+ ///
+ /// Draws a filled box from a .
+ /// Uses and directly
+ /// (same convention as ).
+ ///
+ public DrawSession DrawBox(BoundingBoxXYZ bbox,
+ Color? faceColor = null,
+ Color? edgeColor = null,
+ double transparency = 0.0) =>
+ DrawBox(bbox.Min, bbox.Max, faceColor, edgeColor, transparency);
+
+ ///
+ /// Draws a filled box from an .
+ ///
+ public DrawSession DrawBox(Outline outline,
+ Color? faceColor = null,
+ Color? edgeColor = null,
+ double transparency = 0.0) =>
+ DrawBox(outline.MinimumPoint, outline.MaximumPoint, faceColor, edgeColor, transparency);
+
+ ///
+ /// Draws a filled UV sphere centred at with the given .
+ ///
+ /// Sphere centre (world space).
+ /// Sphere radius (Revit internal units — feet).
+ /// Face fill color. Defaults to .
+ ///
+ /// Color of the three great-circle edge lines (XY / XZ / YZ planes).
+ /// Pass null to skip edges.
+ ///
+ /// Face transparency 0.0 (opaque) – 1.0 (invisible).
+ public DrawSession DrawSphere(XYZ center, double radius,
+ Color? faceColor = null,
+ Color? edgeColor = null,
+ double transparency = 0.0)
+ {
+ if (radius <= 0) return this;
+
+ Color fc = faceColor ?? DrawExtensions.Blue;
+ Color ec = edgeColor ?? DrawExtensions.DarkBlue;
+ double t = transparency < 0.0 ? 0.0 : transparency > 1.0 ? 1.0 : transparency;
+
+ var tris = new List<(XYZ V0, XYZ V1, XYZ V2, XYZ Normal)>();
+ AppendSphereTriangles(center, radius, tris);
+ if (tris.Count > 0)
+ _triData.Add((fc.Red, fc.Green, fc.Blue, t, tris));
+
+ // Three great circles as wireframe edges
+ if (edgeColor != null)
+ AppendSphereEdges(center, radius, ec);
+
+ Invalidate();
+ return this;
+ }
+
+ ///
+ /// Draws as filled triangles tessellated from a real system font.
+ ///
+ ///
+ /// Uses System.Drawing.GraphicsPath to extract glyph outlines from the specified
+ /// font, then LibTessDotNet (EvenOdd winding) to tessellate them into triangles —
+ /// correctly handling inner holes in letters like O, A, B, D, Р, О, etc.
+ /// Results are cached per (text, fontFamily) so repeated calls are cheap.
+ ///
+ /// Text to render. Any Unicode characters supported by the font.
+ /// Bottom-left corner of the first character (world space).
+ /// Character height in Revit internal units (feet). Default 0.5 ft ≈ 15 cm.
+ /// Fill color. Defaults to .
+ ///
+ /// System font family name (e.g. "Arial", "Times New Roman", "Consolas").
+ /// Falls back to generic sans-serif if the font is not installed.
+ ///
+ /// Transparency 0.0 (opaque) – 1.0 (invisible).
+ ///
+ /// Face normal of the plane in which the text lies.
+ /// null defaults to (XY plane, horizontal text).
+ /// The local X/Y axes are computed automatically via .
+ ///
+ public DrawSession DrawText(string text, XYZ origin,
+ double height = 0.5,
+ Color? color = null,
+ string fontFamily = "Arial",
+ double transparency = 0.0,
+ XYZ? normal = null)
+ {
+ if (string.IsNullOrEmpty(text) || height <= 0) return this;
+
+ var cacheKey = (text, fontFamily);
+ if (!_textCache.TryGetValue(cacheKey, out var normalized))
+ {
+ try { normalized = BuildTextTriangles(text, fontFamily); }
+ catch { normalized = new List<(float, float, float, float, float, float)>(); }
+ _textCache[cacheKey] = normalized;
+ }
+ if (normalized.Count == 0) return this;
+
+ Color c = color ?? DrawExtensions.DarkBlue;
+ double t = transparency < 0.0 ? 0.0 : transparency > 1.0 ? 1.0 : transparency;
+
+ XYZ n = (normal ?? XYZ.BasisZ).Normalize();
+ var (right, up) = ComputeTextFrame(n);
+
+ var tris = new List<(XYZ V0, XYZ V1, XYZ V2, XYZ Normal)>(normalized.Count);
+ foreach (var (x0, y0, x1, y1, x2, y2) in normalized)
+ {
+ tris.Add((
+ origin + right * (x0 * height) + up * (y0 * height),
+ origin + right * (x1 * height) + up * (y1 * height),
+ origin + right * (x2 * height) + up * (y2 * height),
+ n));
+ }
+ _triData.Add((c.Red, c.Green, c.Blue, t, tris));
+ Invalidate();
+ return this;
+ }
+
+ // -------------------------------------------------------------------------
+ // Session management
+ // -------------------------------------------------------------------------
+
+ /// Removes all drawn primitives without disposing the session.
+ public DrawSession Clear()
+ {
+ _lines.Clear();
+ _triData.Clear();
+ Invalidate();
+ return this;
+ }
+
+ // -------------------------------------------------------------------------
+ // IDirectContext3DServer
+ // -------------------------------------------------------------------------
+
+ public Guid GetServerId() => _serverId;
+
+ public ExternalServiceId GetServiceId() =>
+ ExternalServices.BuiltInExternalServices.DirectContext3DService;
+
+ public string GetName() => "DrawSession";
+ public string GetDescription() => "Revit.Extensions transient geometry renderer";
+ public string GetVendorId() => "Revit.Extensions";
+ public string GetApplicationId() => "Revit.Extensions.DrawSession";
+ public string GetSourceId() => _serverId.ToString();
+ public bool UsesHandles() => false;
+
+ public bool CanExecute(View view) =>
+ !_disposed
+ && (_lines.Count > 0 || _triData.Count > 0)
+ && view is View3D;
+
+ ///
+ /// Returns true when any solid has transparency > 0,
+ /// so Revit calls a second time for the transparent pass.
+ ///
+ public bool UseInTransparentPass(View view) => !_disposed && _hasTransparency;
+
+ ///
+ /// Called from the render thread — returns a pre-computed outline.
+ /// Never access _lines / _triData here (main-thread data).
+ ///
+ public Outline? GetBoundingBox(View view) => _cachedOutline;
+
+ public void RenderScene(View view, DisplayStyle displayStyle)
+ {
+ try
+ {
+ if (_isDirty)
+ RebuildBuffers();
+
+ bool isTransparentPass = DrawContext.IsTransparentPass();
+ DrawContext.SetWorldTransform(Transform.Identity);
+
+ // Lines and opaque faces — opaque pass only.
+ if (!isTransparentPass)
+ {
+ // Lines
+ foreach (var (vb, vbCount, ib, ibCount, primCount, r, g, b) in _lineBuffers)
+ {
+ using var fmt = new VertexFormat(VertexFormatBits.Position);
+ using var eff = new EffectInstance(VertexFormatBits.Position);
+ eff.SetColor(new Color(r, g, b));
+ eff.SetTransparency(0.0);
+ DrawContext.FlushBuffer(
+ vb, vbCount, ib, ibCount, fmt, eff,
+ PrimitiveType.LineList, 0, primCount);
+ }
+
+ // Opaque triangle faces (solids + meshes)
+ foreach (var (vb, vbCount, ib, ibCount, primCount, r, g, b, transp) in _triBuffers)
+ {
+ if (transp > 0.0) continue;
+ FlushTriangleBuffer(vb, vbCount, ib, ibCount, primCount, r, g, b, 0.0, displayStyle);
+ }
+ }
+ else
+ {
+ // Transparent faces only — transparent pass
+ foreach (var (vb, vbCount, ib, ibCount, primCount, r, g, b, transp) in _triBuffers)
+ {
+ if (transp <= 0.0) continue;
+ FlushTriangleBuffer(vb, vbCount, ib, ibCount, primCount, r, g, b, transp, displayStyle);
+ }
+ }
+ }
+ catch
+ {
+ // Never crash the Revit render loop.
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Private helpers
+ // -------------------------------------------------------------------------
+
+ private static void FlushTriangleBuffer(
+ VertexBuffer vb, int vbCount,
+ IndexBuffer ib, int ibCount, int primCount,
+ byte r, byte g, byte b, double transparency,
+ DisplayStyle displayStyle)
+ {
+ using var fmt = new VertexFormat(VertexFormatBits.PositionNormalColored);
+ using var eff = new EffectInstance(VertexFormatBits.PositionNormalColored);
+ var color = new Color(r, g, b);
+ eff.SetColor(color);
+ eff.SetDiffuseColor(color);
+ eff.SetTransparency(transparency);
+ if (displayStyle == DisplayStyle.HLR)
+ {
+ eff.SetSpecularColor(color);
+ eff.SetAmbientColor(color);
+ eff.SetEmissiveColor(color);
+ }
+ DrawContext.FlushBuffer(
+ vb, vbCount, ib, ibCount, fmt, eff,
+ PrimitiveType.TriangleList, 0, primCount);
+ }
+
+ private DrawSession DrawWireBox(XYZ[] v, Color? color)
+ {
+ DrawPolygon(new[] { v[0], v[1], v[2], v[3] }, color);
+ DrawPolygon(new[] { v[4], v[5], v[6], v[7] }, color);
+ foreach ((int b, int t) in new[] { (0, 4), (1, 5), (2, 6), (3, 7) })
+ DrawLine(v[b], v[t], color);
+ return this;
+ }
+
+ ///
+ /// Called from the main thread after every mutation.
+ /// Updates the cached outline (safe to read from the render thread),
+ /// marks buffers dirty, and requests a view refresh.
+ ///
+ private void Invalidate()
+ {
+ _cachedOutline = ComputeOutline();
+ _hasTransparency = _triData.Any(t => t.Transparency > 0.0);
+ _isDirty = true;
+ _uiApp?.ActiveUIDocument?.RefreshActiveView();
+ }
+
+ /// Computes a world-space bounding outline from all current primitives.
+ private Outline? ComputeOutline()
+ {
+ bool any = false;
+ double minX = double.MaxValue, minY = double.MaxValue, minZ = double.MaxValue;
+ double maxX = double.MinValue, maxY = double.MinValue, maxZ = double.MinValue;
+
+ void Include(XYZ p)
+ {
+ any = true;
+ if (p.X < minX) minX = p.X;
+ if (p.Y < minY) minY = p.Y;
+ if (p.Z < minZ) minZ = p.Z;
+ if (p.X > maxX) maxX = p.X;
+ if (p.Y > maxY) maxY = p.Y;
+ if (p.Z > maxZ) maxZ = p.Z;
+ }
+
+ foreach (var (from, to, _, _, _) in _lines)
+ {
+ Include(from);
+ Include(to);
+ }
+
+ foreach (var (_, _, _, _, tris) in _triData)
+ {
+ foreach (var (v0, v1, v2, _) in tris)
+ {
+ Include(v0);
+ Include(v1);
+ Include(v2);
+ }
+ }
+
+ if (!any) return null;
+
+ const double eps = 0.01;
+ return new Outline(
+ new XYZ(minX - eps, minY - eps, minZ - eps),
+ new XYZ(maxX + eps, maxY + eps, maxZ + eps));
+ }
+
+ private void RebuildBuffers()
+ {
+ DisposeBuffers();
+
+ // ---- Line buffers ----
+ var lineGroups = new Dictionary<(byte R, byte G, byte B), List<(XYZ From, XYZ To)>>();
+ foreach (var (from, to, r, g, b) in _lines)
+ {
+ var key = (r, g, b);
+ if (!lineGroups.TryGetValue(key, out var list))
+ lineGroups[key] = list = new();
+ list.Add((from, to));
+ }
+
+#if REVIT_2024 || REVIT_2023 || REVIT_2022 || REVIT_2021 || REVIT_2020
+ foreach (var lgItem in lineGroups)
+ {
+ var lKey = lgItem.Key;
+ var lines = lgItem.Value;
+#else
+ foreach (var (lKey, lines) in lineGroups)
+ {
+#endif
+ int nVerts = lines.Count * 2;
+ int nPrims = lines.Count;
+ int vbSize = nVerts * VertexPosition.GetSizeInFloats();
+ int ibSize = nPrims * IndexLine.GetSizeInShortInts();
+
+ var vb = new VertexBuffer(vbSize);
+ vb.Map(vbSize);
+ var vs = vb.GetVertexStreamPosition();
+ foreach (var (from, to) in lines)
+ {
+ vs.AddVertex(new VertexPosition(from));
+ vs.AddVertex(new VertexPosition(to));
+ }
+ vb.Unmap();
+
+ var ib = new IndexBuffer(ibSize);
+ ib.Map(ibSize);
+ var ixs = ib.GetIndexStreamLine();
+ for (int i = 0; i < nPrims; i++)
+ ixs.AddLine(new IndexLine(i * 2, i * 2 + 1));
+ ib.Unmap();
+
+ _lineBuffers.Add((vb, nVerts, ib, ibSize, nPrims, lKey.R, lKey.G, lKey.B));
+ }
+
+ // ---- Triangle buffers (solid faces + meshes) ----
+ // Key: (R, G, B, Transparency) — group to minimise FlushBuffer calls.
+ // Triangle data was pre-computed on the main thread in DrawSolid/DrawMesh —
+ // no Revit geometry API calls needed here (render-thread safe).
+ var triGroups = new Dictionary<(byte R, byte G, byte B, double T), List<(XYZ V0, XYZ V1, XYZ V2, XYZ Normal)>>();
+
+ foreach (var (r, g, b, transp, tris) in _triData)
+ {
+ var key = (r, g, b, transp);
+ if (!triGroups.TryGetValue(key, out var group))
+ triGroups[key] = group = new();
+ group.AddRange(tris);
+ }
+
+#if REVIT_2024 || REVIT_2023 || REVIT_2022 || REVIT_2021 || REVIT_2020
+ foreach (var tgItem in triGroups)
+ {
+ var tKey = tgItem.Key;
+ var tris = tgItem.Value;
+#else
+ foreach (var (tKey, tris) in triGroups)
+ {
+#endif
+ if (tris.Count == 0) continue;
+
+ int nVerts = tris.Count * 3;
+ int nPrims = tris.Count;
+ int vbSize = nVerts * VertexPositionNormalColored.GetSizeInFloats();
+ int ibSize = nPrims * IndexTriangle.GetSizeInShortInts();
+
+ // Color is embedded per-vertex — required for PositionNormalColored format.
+ var vertColor = new ColorWithTransparency(
+ tKey.R, tKey.G, tKey.B,
+ (uint)(tKey.T * 255));
+
+ var vb = new VertexBuffer(vbSize);
+ vb.Map(vbSize);
+ var vs = vb.GetVertexStreamPositionNormalColored();
+ foreach (var (v0, v1, v2, n) in tris)
+ {
+ vs.AddVertex(new VertexPositionNormalColored(v0, n, vertColor));
+ vs.AddVertex(new VertexPositionNormalColored(v1, n, vertColor));
+ vs.AddVertex(new VertexPositionNormalColored(v2, n, vertColor));
+ }
+ vb.Unmap();
+
+ var ib = new IndexBuffer(ibSize);
+ ib.Map(ibSize);
+ var ixs = ib.GetIndexStreamTriangle();
+ for (int i = 0; i < nPrims; i++)
+ ixs.AddTriangle(new IndexTriangle(i * 3, i * 3 + 1, i * 3 + 2));
+ ib.Unmap();
+
+ _triBuffers.Add((vb, nVerts, ib, ibSize, nPrims, tKey.R, tKey.G, tKey.B, tKey.T));
+ }
+
+ _isDirty = false;
+ }
+
+ ///
+ /// Appends triangles from a solid tessellation.
+ /// Computes a reference outward normal via at the
+ /// face UV centroid, then flips each triangle's computed normal if it points inward.
+ /// This is necessary because Revit's tessellation winding order is not guaranteed
+ /// to produce outward-facing normals.
+ ///
+ private static void AppendFaceTriangles(
+ Face face,
+ Mesh mesh,
+ List<(XYZ V0, XYZ V1, XYZ V2, XYZ Normal)> tris)
+ {
+ // Compute a reference outward normal at the face UV centroid.
+ XYZ? faceRef = null;
+ try
+ {
+ BoundingBoxUV uvBox = face.GetBoundingBox();
+ UV centerUV = new UV(
+ (uvBox.Min.U + uvBox.Max.U) / 2.0,
+ (uvBox.Min.V + uvBox.Max.V) / 2.0);
+ faceRef = face.ComputeNormal(centerUV);
+ }
+ catch { }
+
+ for (int i = 0; i < mesh.NumTriangles; i++)
+ {
+ MeshTriangle tri = mesh.get_Triangle(i);
+ XYZ v0 = tri.get_Vertex(0);
+ XYZ v1 = tri.get_Vertex(1);
+ XYZ v2 = tri.get_Vertex(2);
+
+ XYZ cross = (v1 - v0).CrossProduct(v2 - v0);
+ if (cross.GetLength() < 1e-10) continue;
+
+ XYZ normal = cross.Normalize();
+
+ // If we have a reference normal, ensure triangle normal agrees with it.
+ if (faceRef != null && normal.DotProduct(faceRef) < 0)
+ normal = normal.Negate();
+
+ tris.Add((v0, v1, v2, normal));
+ }
+ }
+
+ ///
+ /// Appends triangles from a standalone using flat normals
+ /// computed from the cross product of each triangle's edges.
+ ///
+ private static void AppendMeshTriangles(
+ Mesh mesh,
+ List<(XYZ V0, XYZ V1, XYZ V2, XYZ Normal)> tris)
+ {
+ for (int i = 0; i < mesh.NumTriangles; i++)
+ {
+ MeshTriangle tri = mesh.get_Triangle(i);
+ XYZ v0 = tri.get_Vertex(0);
+ XYZ v1 = tri.get_Vertex(1);
+ XYZ v2 = tri.get_Vertex(2);
+
+ XYZ cross = (v1 - v0).CrossProduct(v2 - v0);
+ if (cross.GetLength() < 1e-10) continue;
+ tris.Add((v0, v1, v2, cross.Normalize()));
+ }
+ }
+
+ ///
+ /// Generates 12 triangles (6 quad-faces × 2) for an axis-aligned box.
+ /// Normals are the axis-unit vectors — no Revit API calls required.
+ ///
+ private static void AppendBoxTriangles(XYZ min, XYZ max,
+ List<(XYZ V0, XYZ V1, XYZ V2, XYZ Normal)> tris)
+ {
+ XYZ v0 = min, v1 = new XYZ(min.X, max.Y, min.Z);
+ XYZ v2 = new XYZ(max.X, max.Y, min.Z), v3 = new XYZ(max.X, min.Y, min.Z);
+ XYZ v4 = new XYZ(min.X, min.Y, max.Z), v5 = new XYZ(min.X, max.Y, max.Z);
+ XYZ v6 = max, v7 = new XYZ(max.X, min.Y, max.Z);
+
+ void AddQuad(XYZ a, XYZ b, XYZ c, XYZ d, XYZ n)
+ {
+ tris.Add((a, b, c, n));
+ tris.Add((a, c, d, n));
+ }
+
+ AddQuad(v0, v1, v2, v3, -XYZ.BasisZ); // bottom
+ AddQuad(v4, v7, v6, v5, XYZ.BasisZ); // top
+ AddQuad(v0, v3, v7, v4, -XYZ.BasisY); // front
+ AddQuad(v1, v5, v6, v2, XYZ.BasisY); // back
+ AddQuad(v0, v4, v5, v1, -XYZ.BasisX); // left
+ AddQuad(v3, v2, v6, v7, XYZ.BasisX); // right
+ }
+
+ ///
+ /// Generates triangles for a UV sphere with 16 latitude × 32 longitude rings.
+ /// Per-vertex normals are the unit direction from to the vertex.
+ ///
+ private static void AppendSphereTriangles(XYZ center, double radius,
+ List<(XYZ V0, XYZ V1, XYZ V2, XYZ Normal)> tris)
+ {
+ const int latSegs = 16;
+ const int lonSegs = 32;
+
+ XYZ UnitPt(double theta, double phi) =>
+ new XYZ(
+ Math.Sin(theta) * Math.Cos(phi),
+ Math.Sin(theta) * Math.Sin(phi),
+ Math.Cos(theta));
+
+ for (int i = 0; i < latSegs; i++)
+ {
+ double t0 = Math.PI * i / latSegs;
+ double t1 = Math.PI * (i + 1) / latSegs;
+
+ for (int j = 0; j < lonSegs; j++)
+ {
+ double p0 = 2.0 * Math.PI * j / lonSegs;
+ double p1 = 2.0 * Math.PI * (j + 1) / lonSegs;
+
+ XYZ n00 = UnitPt(t0, p0), n01 = UnitPt(t0, p1);
+ XYZ n10 = UnitPt(t1, p0), n11 = UnitPt(t1, p1);
+ XYZ v00 = center + n00 * radius, v01 = center + n01 * radius;
+ XYZ v10 = center + n10 * radius, v11 = center + n11 * radius;
+
+ // Upper triangle (skip at north pole where v00==v01)
+ if (i > 0)
+ tris.Add((v00, v10, v11, (n00 + n10 + n11).Normalize()));
+ // Lower triangle (skip at south pole where v10==v11)
+ if (i < latSegs - 1)
+ tris.Add((v00, v11, v01, (n00 + n11 + n01).Normalize()));
+ }
+ }
+ }
+
+ ///
+ /// Appends three great-circle edge rings (XY, XZ, YZ planes) as line segments.
+ ///
+ private void AppendSphereEdges(XYZ center, double radius, Color color)
+ {
+ const int segs = 48;
+ byte r = color.Red, g = color.Green, b = color.Blue;
+
+ for (int i = 0; i < segs; i++)
+ {
+ double a0 = 2.0 * Math.PI * i / segs;
+ double a1 = 2.0 * Math.PI * (i + 1) / segs;
+
+ // XY plane (equator)
+ _lines.Add((
+ center + new XYZ(Math.Cos(a0) * radius, Math.Sin(a0) * radius, 0),
+ center + new XYZ(Math.Cos(a1) * radius, Math.Sin(a1) * radius, 0),
+ r, g, b));
+ // XZ plane
+ _lines.Add((
+ center + new XYZ(Math.Cos(a0) * radius, 0, Math.Sin(a0) * radius),
+ center + new XYZ(Math.Cos(a1) * radius, 0, Math.Sin(a1) * radius),
+ r, g, b));
+ // YZ plane
+ _lines.Add((
+ center + new XYZ(0, Math.Cos(a0) * radius, Math.Sin(a0) * radius),
+ center + new XYZ(0, Math.Cos(a1) * radius, Math.Sin(a1) * radius),
+ r, g, b));
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Text tessellation helpers
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Computes a local right/up frame for text rendering given a face .
+ ///
+ /// - Horizontal surfaces ( ≈ ±Z): uses world X/Y.
+ /// - All other normals: up is the world-Z direction projected onto the plane;
+ /// right is derived via BasisZ × normal.
+ ///
+ ///
+ private static (XYZ Right, XYZ Up) ComputeTextFrame(XYZ n)
+ {
+ if (Math.Abs(n.DotProduct(XYZ.BasisZ)) > 0.99)
+ return (XYZ.BasisX, XYZ.BasisY); // horizontal surface — keep standard XY orientation
+
+ // Project world Z onto the plane to get "up", then derive "right"
+ XYZ right = XYZ.BasisZ.CrossProduct(n).Normalize();
+ XYZ up = n.CrossProduct(right).Normalize();
+ return (right, up);
+ }
+
+ ///
+ /// Builds a list of normalised triangles for using a real system
+ /// font. Coordinates are in font-height space: character height = 1.0, Y-up, X origin at
+ /// the left edge of the first glyph. Results are suitable for caching in
+ /// .
+ ///
+ private static List<(float, float, float, float, float, float)> BuildTextTriangles(
+ string text, string fontFamily)
+ {
+ const float RefSize = 100f;
+ var contours = new List>();
+
+ using (var gdiFamily = GetFontFamily(fontFamily))
+ using (var path = new System.Drawing.Drawing2D.GraphicsPath())
+ {
+ path.AddString(text, gdiFamily, (int)System.Drawing.FontStyle.Regular,
+ RefSize, System.Drawing.PointF.Empty,
+ System.Drawing.StringFormat.GenericTypographic);
+ path.Flatten(null, 0.5f);
+ ExtractContours(path, contours);
+ }
+
+ if (contours.Count == 0)
+ return new List<(float, float, float, float, float, float)>();
+
+ // Bounding box of all contour points (GDI+ Y-down coordinate space)
+ float minX = float.MaxValue, maxX = float.MinValue;
+ float minY = float.MaxValue, maxY = float.MinValue;
+ foreach (var contour in contours)
+ foreach (var pt in contour)
+ {
+ if (pt.X < minX) minX = pt.X;
+ if (pt.X > maxX) maxX = pt.X;
+ if (pt.Y < minY) minY = pt.Y;
+ if (pt.Y > maxY) maxY = pt.Y;
+ }
+
+ float h = maxY - minY;
+ if (h < 1e-6f)
+ return new List<(float, float, float, float, float, float)>();
+ float invH = 1.0f / h;
+
+ // Feed contours to LibTessDotNet with EvenOdd winding.
+ // EvenOdd correctly handles glyph holes (letters O, A, B, D, Р, О, etc.).
+ var tess = new LibTessDotNet.Tess();
+ foreach (var contour in contours)
+ {
+ if (contour.Count < 3) continue;
+ var verts = new LibTessDotNet.ContourVertex[contour.Count];
+ for (int i = 0; i < contour.Count; i++)
+ {
+ // Normalise to height=1.0 and flip Y (GDI+ Y-down → Revit Y-up)
+ float nx = (contour[i].X - minX) * invH;
+ float ny = (maxY - contour[i].Y) * invH;
+ verts[i] = new LibTessDotNet.ContourVertex
+ {
+ Position = new LibTessDotNet.Vec3 { X = nx, Y = ny, Z = 0 }
+ };
+ }
+ tess.AddContour(verts, LibTessDotNet.ContourOrientation.Original);
+ }
+
+ tess.Tessellate(LibTessDotNet.WindingRule.EvenOdd,
+ LibTessDotNet.ElementType.Polygons, 3);
+
+ var result = new List<(float, float, float, float, float, float)>(tess.ElementCount);
+ for (int i = 0; i < tess.ElementCount; i++)
+ {
+ int i0 = tess.Elements[i * 3];
+ int i1 = tess.Elements[i * 3 + 1];
+ int i2 = tess.Elements[i * 3 + 2];
+ if (i0 < 0 || i1 < 0 || i2 < 0) continue;
+ var p0 = tess.Vertices[i0].Position;
+ var p1 = tess.Vertices[i1].Position;
+ var p2 = tess.Vertices[i2].Position;
+ result.Add((p0.X, p0.Y, p1.X, p1.Y, p2.X, p2.Y));
+ }
+ return result;
+ }
+
+ ///
+ /// Parses path data into a list of
+ /// polyline contours. Each sub-path (delimited by PathPointType.Start points or
+ /// CloseSubpath flags) becomes one entry in .
+ ///
+ private static void ExtractContours(
+ System.Drawing.Drawing2D.GraphicsPath path,
+ List> contours)
+ {
+ var points = path.PathPoints;
+ var types = path.PathTypes;
+ if (points == null || points.Length == 0) return;
+
+ List? current = null;
+ for (int i = 0; i < points.Length; i++)
+ {
+ byte baseType = (byte)(types[i] & 0x07);
+ if (baseType == 0 || current == null) // PathPointType.Start
+ {
+ current = new List();
+ contours.Add(current);
+ }
+
+ current.Add(points[i]);
+
+ // CloseSubpath flag (0x80) — next point starts a fresh contour
+ if ((types[i] & 0x80) != 0)
+ current = null;
+ }
+ }
+
+ ///
+ /// Returns a for ,
+ /// falling back to Arial and then the generic sans-serif family if the requested
+ /// font is not installed. The caller is responsible for disposing the returned instance.
+ ///
+ private static System.Drawing.FontFamily GetFontFamily(string name)
+ {
+ try
+ {
+ var family = new System.Drawing.FontFamily(name);
+ if (family.IsStyleAvailable(System.Drawing.FontStyle.Regular))
+ return family;
+ family.Dispose();
+ }
+ catch { }
+
+ try { return new System.Drawing.FontFamily("Arial"); } catch { }
+ return new System.Drawing.FontFamily(
+ System.Drawing.Text.GenericFontFamilies.SansSerif);
+ }
+
+ private void RegisterImpl()
+ {
+ if (ExternalServiceRegistry.GetService(
+ ExternalServices.BuiltInExternalServices.DirectContext3DService)
+ is not MultiServerService service) return;
+
+ service.AddServer(this);
+ IList activeIds = service.GetActiveServerIds();
+ if (!activeIds.Contains(_serverId))
+ {
+ activeIds.Add(_serverId);
+ service.SetActiveServers(activeIds);
+ }
+ }
+
+ private void Unregister()
+ {
+ if (ExternalServiceRegistry.GetService(
+ ExternalServices.BuiltInExternalServices.DirectContext3DService)
+ is not MultiServerService service) return;
+
+ IList activeIds = service.GetActiveServerIds();
+ if (activeIds.Remove(_serverId))
+ service.SetActiveServers(activeIds);
+
+ try { service.RemoveServer(_serverId); } catch { }
+
+ _uiApp?.ActiveUIDocument?.RefreshActiveView();
+ }
+
+ private void DisposeBuffers()
+ {
+ foreach (var (vb, _, ib, _, _, _, _, _) in _lineBuffers)
+ {
+ vb.Dispose();
+ ib.Dispose();
+ }
+ _lineBuffers.Clear();
+
+ foreach (var (vb, _, ib, _, _, _, _, _, _) in _triBuffers)
+ {
+ vb.Dispose();
+ ib.Dispose();
+ }
+ _triBuffers.Clear();
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ Unregister();
+ DisposeBuffers();
+ }
+}
diff --git a/Revit.Extensions/Extensions/ElementExtensions.cs b/Revit.Extensions/Extensions/ElementExtensions.cs
index 4fcff4c..63c842b 100644
--- a/Revit.Extensions/Extensions/ElementExtensions.cs
+++ b/Revit.Extensions/Extensions/ElementExtensions.cs
@@ -1,3 +1,4 @@
+using System;
using Autodesk.Revit.DB;
using Autodesk.Revit.UI;
@@ -40,52 +41,35 @@ public static IList GetAllElementOrSelected(UIDocument uiDocument)
}
///
- /// Collects all grids visible on the view of the level closest to elevation zero.
- /// Falls back to all grids in the document if no level with 2+ grids is found.
+ /// Returns the world-space bounding box of as an
+ /// expanded by on every side.
+ /// The default enlargement is 1 mm in feet (0.00328084).
+ /// Returns null when the element has no bounding box.
///
- public static IList GetAllGridFromFirstLevel(Document document, out string? firstLevelName)
+ public static Outline? GetEnlargedOutline(this Element element, double enlargement = OneMmInFt)
{
- var levels = new FilteredElementCollector(document, document.ActiveView.Id)
- .OfClass(typeof(Level))
- .OfType()
- .OrderBy(l => Math.Abs(l.Elevation));
-
- if (!levels.Any())
- levels = new FilteredElementCollector(document)
- .OfClass(typeof(Level))
- .OfType()
- .OrderBy(l => Math.Abs(l.Elevation));
-
- foreach (var level in levels)
+ try
{
- ElementLevelFilter levelFilter = new(level.Id);
-
- var grids = new FilteredElementCollector(document, document.ActiveView.Id)
- .OfClass(typeof(Grid))
- .WherePasses(levelFilter)
- .OfType()
- .ToList();
-
- if (grids.Count < 2)
- continue;
-
- firstLevelName = level.Name;
- return grids;
+ BoundingBoxXYZ? bbox = element.get_BoundingBox(null);
+ if (bbox is null)
+ return null;
+
+ double minX = Math.Min(bbox.Min.X, bbox.Max.X);
+ double minY = Math.Min(bbox.Min.Y, bbox.Max.Y);
+ double minZ = Math.Min(bbox.Min.Z, bbox.Max.Z);
+ double maxX = Math.Max(bbox.Min.X, bbox.Max.X);
+ double maxY = Math.Max(bbox.Min.Y, bbox.Max.Y);
+ double maxZ = Math.Max(bbox.Min.Z, bbox.Max.Z);
+
+ return new Outline(
+ new XYZ(minX - enlargement, minY - enlargement, minZ - enlargement),
+ new XYZ(maxX + enlargement, maxY + enlargement, maxZ + enlargement));
+ }
+ catch
+ {
+ return null;
}
-
- firstLevelName = null;
-
- var allGrids = new FilteredElementCollector(document, document.ActiveView.Id)
- .OfClass(typeof(Grid))
- .OfType()
- .ToList();
-
- if (allGrids.Count == 0)
- allGrids = new FilteredElementCollector(document)
- .OfClass(typeof(Grid))
- .OfType()
- .ToList();
-
- return allGrids;
}
+
+ private const double OneMmInFt = 0.00328084;
}
diff --git a/Revit.Extensions/Extensions/ElementIdExtensions.cs b/Revit.Extensions/Extensions/ElementIdExtensions.cs
new file mode 100644
index 0000000..e1139b9
--- /dev/null
+++ b/Revit.Extensions/Extensions/ElementIdExtensions.cs
@@ -0,0 +1,119 @@
+using System;
+using Autodesk.Revit.DB;
+
+namespace Revit.Extensions;
+
+///
+/// Extension methods for .
+///
+public static class ElementIdExtensions
+{
+ ///
+ /// Returns true when the is non-null and not
+ /// .
+ ///
+ public static bool IsValid(this ElementId elementId) =>
+ elementId is not null && elementId != ElementId.InvalidElementId;
+
+ ///
+ /// Returns true when the is null or
+ /// .
+ ///
+ public static bool IsInvalid(this ElementId elementId) =>
+ !elementId.IsValid();
+
+ ///
+ /// Interprets the numeric value of as a
+ /// .
+ ///
+ public static BuiltInCategory ToBuiltInCategory(this ElementId elementId)
+ {
+#if REVIT_2024 || REVIT_2025 || REVIT_2026 || REVIT_2027
+ return (BuiltInCategory)elementId.Value;
+#else
+ return (BuiltInCategory)elementId.IntegerValue;
+#endif
+ }
+
+ ///
+ /// Interprets the numeric value of as a
+ /// .
+ ///
+ public static BuiltInParameter ToBuiltInParameter(this ElementId elementId)
+ {
+#if REVIT_2024 || REVIT_2025 || REVIT_2026 || REVIT_2027
+ return (BuiltInParameter)elementId.Value;
+#else
+ return (BuiltInParameter)elementId.IntegerValue;
+#endif
+ }
+
+ ///
+ /// Returns true when the id is positive — i.e. a user-created element
+ /// rather than a built-in constant.
+ ///
+ public static bool IsUserCreated(this ElementId elementId)
+ {
+#if REVIT_2024 || REVIT_2025 || REVIT_2026 || REVIT_2027
+ return elementId is not null && elementId.Value > ElementId.InvalidElementId.Value;
+#else
+ return elementId is not null && elementId.IntegerValue > ElementId.InvalidElementId.IntegerValue;
+#endif
+ }
+
+ ///
+ /// Returns true when the id is a negative built-in constant
+ /// (e.g. a or value).
+ ///
+ public static bool IsBuiltIn(this ElementId elementId)
+ {
+#if REVIT_2024 || REVIT_2025 || REVIT_2026 || REVIT_2027
+ return elementId is not null && elementId.Value < ElementId.InvalidElementId.Value;
+#else
+ return elementId is not null && elementId.IntegerValue < ElementId.InvalidElementId.IntegerValue;
+#endif
+ }
+
+ ///
+ /// Fetches the element from , cast to
+ /// . Returns null when the cast fails or
+ /// the element does not exist.
+ ///
+ public static T? GetElement(this ElementId elementId, Document document)
+ where T : Element
+ {
+ if (elementId is null) throw new ArgumentNullException(nameof(elementId));
+ if (document is null) throw new ArgumentNullException(nameof(document));
+ return document.GetElement(elementId) as T;
+ }
+
+ ///
+ /// Tries to fetch and cast the element. Returns false when the element
+ /// does not exist or is not of type .
+ ///
+ public static bool TryGetElement(this ElementId elementId, Document document, out T? element)
+ where T : Element
+ {
+ element = elementId.GetElement(document);
+ return element is not null;
+ }
+
+ // -------------------------------------------------------------------------
+ // Factory helpers — reverse direction: enum → ElementId
+ // -------------------------------------------------------------------------
+
+ /// Creates an from a .
+ public static ElementId ToElementId(this BuiltInCategory category) =>
+ new ElementId(category);
+
+ /// Creates an from a .
+ public static ElementId ToElementId(this BuiltInParameter parameter) =>
+ new ElementId(parameter);
+
+ ///
+ /// Fetches the element from without a type cast.
+ /// Returns null when the id is invalid or the element does not exist.
+ ///
+ public static Element? ToElement(this ElementId elementId, Document document) =>
+ elementId.IsValid() ? document.GetElement(elementId) : null;
+}
diff --git a/Revit.Extensions/Extensions/ElementTransformExtensions.cs b/Revit.Extensions/Extensions/ElementTransformExtensions.cs
new file mode 100644
index 0000000..31e4187
--- /dev/null
+++ b/Revit.Extensions/Extensions/ElementTransformExtensions.cs
@@ -0,0 +1,76 @@
+using System.Collections.Generic;
+using System.Linq;
+using Autodesk.Revit.DB;
+
+namespace Revit.Extensions;
+
+///
+/// Fluent extension methods for transforming Revit elements (move, rotate, mirror, copy).
+/// Wraps and returns the element (or new element) for chaining.
+/// All methods must be called inside an open .
+///
+public static class ElementTransformExtensions
+{
+ ///
+ /// Moves by the given offsets and returns it for chaining.
+ ///
+ public static Element Move(this Element element, double x, double y, double z)
+ {
+ ElementTransformUtils.MoveElement(element.Document, element.Id, new XYZ(x, y, z));
+ return element;
+ }
+
+ ///
+ /// Moves by and returns it for chaining.
+ ///
+ public static Element Move(this Element element, XYZ translation)
+ {
+ ElementTransformUtils.MoveElement(element.Document, element.Id, translation);
+ return element;
+ }
+
+ ///
+ /// Rotates around by
+ /// radians and returns it for chaining.
+ ///
+ public static Element Rotate(this Element element, Line axis, double angle)
+ {
+ ElementTransformUtils.RotateElement(element.Document, element.Id, axis, angle);
+ return element;
+ }
+
+ ///
+ /// Mirrors about in place and returns it.
+ /// Returns null when the element cannot be mirrored.
+ ///
+ public static Element? Mirror(this Element element, Plane plane)
+ {
+ if (!ElementTransformUtils.CanMirrorElement(element.Document, element.Id))
+ return null;
+
+ ElementTransformUtils.MirrorElement(element.Document, element.Id, plane);
+ return element;
+ }
+
+ ///
+ /// Creates a copy of offset by
+ /// and returns the new element.
+ /// Returns null when the copy operation produces no result.
+ ///
+ public static Element? Copy(this Element element, XYZ translation)
+ {
+ ICollection ids = ElementTransformUtils.CopyElement(
+ element.Document, element.Id, translation);
+
+ ElementId? newId = ids.FirstOrDefault();
+ return newId is null ? null : element.Document.GetElement(newId);
+ }
+
+ ///
+ /// Creates a copy of offset by the given values
+ /// and returns the new element.
+ /// Returns null when the copy operation produces no result.
+ ///
+ public static Element? Copy(this Element element, double x, double y, double z) =>
+ element.Copy(new XYZ(x, y, z));
+}
diff --git a/Revit.Extensions/Extensions/FamilyExtensions.cs b/Revit.Extensions/Extensions/FamilyExtensions.cs
new file mode 100644
index 0000000..ce46f4a
--- /dev/null
+++ b/Revit.Extensions/Extensions/FamilyExtensions.cs
@@ -0,0 +1,24 @@
+using Autodesk.Revit.DB;
+
+namespace Revit.Extensions;
+
+///
+/// Extension methods for Revit family editing API.
+///
+public static class FamilyExtensions
+{
+ ///
+ /// Returns all objects managed by
+ /// .
+ /// Returns an empty list when is null.
+ ///
+ public static List GetFamilyParameters(this FamilyManager familyManager)
+ {
+ if (familyManager is null)
+ return [];
+
+ return familyManager.Parameters
+ .OfType()
+ .ToList();
+ }
+}
diff --git a/Revit.Extensions/Extensions/FilterExtensions.cs b/Revit.Extensions/Extensions/FilterExtensions.cs
new file mode 100644
index 0000000..8684295
--- /dev/null
+++ b/Revit.Extensions/Extensions/FilterExtensions.cs
@@ -0,0 +1,39 @@
+using Autodesk.Revit.DB;
+
+namespace Revit.Extensions;
+
+///
+/// Extension methods for building objects.
+///
+public static class FilterExtensions
+{
+ ///
+ /// Returns a filter that matches element instances of
+ /// (excludes element types).
+ ///
+ ///
+ ///
+ /// var filter = BuiltInCategory.OST_Walls.ToInstanceFilter();
+ /// new FilteredElementCollector(doc).WherePasses(filter).ToElements();
+ ///
+ ///
+ public static ElementFilter ToInstanceFilter(this BuiltInCategory category) =>
+ new LogicalAndFilter(
+ new ElementIsElementTypeFilter(inverted: true),
+ new ElementCategoryFilter(category));
+
+ ///
+ /// Returns a filter that matches element types of
+ /// (excludes instances).
+ ///
+ ///
+ ///
+ /// var filter = BuiltInCategory.OST_Walls.ToTypeFilter();
+ /// new FilteredElementCollector(doc).WherePasses(filter).ToElements();
+ ///
+ ///
+ public static ElementFilter ToTypeFilter(this BuiltInCategory category) =>
+ new LogicalAndFilter(
+ new ElementIsElementTypeFilter(inverted: false),
+ new ElementCategoryFilter(category));
+}
diff --git a/Revit.Extensions/Extensions/GeometryExtensions.cs b/Revit.Extensions/Extensions/GeometryExtensions.cs
index 85bb39f..844238a 100644
--- a/Revit.Extensions/Extensions/GeometryExtensions.cs
+++ b/Revit.Extensions/Extensions/GeometryExtensions.cs
@@ -9,6 +9,9 @@ public static class GeometryExtensions
{
///
/// Converts an point to a value tuple (X, Y, Z).
+ /// Use this to pass coordinate data across a Revit API boundary — the returned tuple
+ /// carries no dependency on RevitAPI.dll, so it can be consumed by business logic,
+ /// serialisation code, or unit tests that run without a live Revit process.
///
public static (double X, double Y, double Z) ToVector(this XYZ point)
{
@@ -56,4 +59,225 @@ public static Outline Extend(this Outline outline, double size = 10)
Transform = transform.Multiply(bbox.Transform)
};
}
+
+ ///
+ /// Returns the midpoint between and .
+ ///
+ public static XYZ GetMidpoint(this XYZ point, XYZ other) => (point + other) / 2;
+
+ ///
+ /// Builds a list of segments connecting consecutive
+ /// . When is true
+ /// (default) the last point is connected back to the first, forming a closed polygon.
+ ///
+ public static IReadOnlyList GetPolygonLines(
+ this IEnumerable points,
+ bool isClosed = true)
+ {
+ IList pts = points as IList ?? points.ToList();
+ int segmentCount = isClosed ? pts.Count : pts.Count - 1;
+ var lines = new List(segmentCount);
+
+ for (int i = 0; i < segmentCount; i++)
+ {
+ XYZ from = pts[i];
+ XYZ to = i == segmentCount - 1 ? pts[0] : pts[i + 1];
+ lines.Add(Line.CreateBound(from, to));
+ }
+
+ return lines;
+ }
+
+ ///
+ /// Returns the 8 corner vertices of in bottom-then-top order:
+ /// [BLF, BRF, TRF, TLF, BLB, BRB, TRB, TLB].
+ ///
+ public static XYZ[] ComputeVertices(this BoundingBoxXYZ bbox)
+ {
+ XYZ mn = bbox.Min, mx = bbox.Max;
+ return
+ [
+ new XYZ(mn.X, mn.Y, mn.Z), new XYZ(mn.X, mx.Y, mn.Z),
+ new XYZ(mx.X, mx.Y, mn.Z), new XYZ(mx.X, mn.Y, mn.Z),
+ new XYZ(mn.X, mn.Y, mx.Z), new XYZ(mn.X, mx.Y, mx.Z),
+ new XYZ(mx.X, mx.Y, mx.Z), new XYZ(mx.X, mn.Y, mx.Z),
+ ];
+ }
+
+ ///
+ /// Returns the 8 corner vertices of in bottom-then-top order.
+ ///
+ public static XYZ[] ComputeVertices(this Outline outline)
+ {
+ XYZ mn = outline.MinimumPoint, mx = outline.MaximumPoint;
+ return
+ [
+ new XYZ(mn.X, mn.Y, mn.Z), new XYZ(mn.X, mx.Y, mn.Z),
+ new XYZ(mx.X, mx.Y, mn.Z), new XYZ(mx.X, mn.Y, mn.Z),
+ new XYZ(mn.X, mn.Y, mx.Z), new XYZ(mn.X, mx.Y, mx.Z),
+ new XYZ(mx.X, mx.Y, mx.Z), new XYZ(mx.X, mn.Y, mx.Z),
+ ];
+ }
+
+ ///
+ /// Returns the total arc-length of a polyline through
+ /// (sum of distances between consecutive points).
+ /// Returns 0 for sequences with fewer than two points.
+ ///
+ public static double GetPathLength(this IEnumerable points)
+ {
+ double total = 0;
+ XYZ? prev = null;
+ foreach (XYZ p in points)
+ {
+ if (prev is not null)
+ total += p.DistanceTo(prev);
+ prev = p;
+ }
+ return total;
+ }
+
+ ///
+ /// Returns the signed distance from to point .
+ /// Positive when is on the side the normal points toward.
+ ///
+ public static double SignedDistanceTo(this Plane plane, XYZ p)
+ {
+ XYZ delta = p - plane.Origin;
+ return plane.Normal.DotProduct(delta);
+ }
+
+ ///
+ /// Projects orthogonally onto .
+ ///
+ public static XYZ ProjectOnto(this Plane plane, XYZ p)
+ {
+ double distance = plane.SignedDistanceTo(p);
+ return p - distance * plane.Normal;
+ }
+
+ ///
+ /// Applies to all 8 corners of and
+ /// returns a new axis-aligned bounding box that encloses the transformed corners.
+ /// Returns null when either argument is null.
+ ///
+ public static BoundingBoxXYZ? GetTransformed(this BoundingBoxXYZ bbox, Transform transform)
+ {
+ if (bbox is null || transform is null)
+ return null;
+
+ XYZ mn = bbox.Min, mx = bbox.Max;
+ XYZ[] corners = new[]
+ {
+ new XYZ(mn.X, mn.Y, mn.Z), new XYZ(mn.X, mn.Y, mx.Z),
+ new XYZ(mn.X, mx.Y, mn.Z), new XYZ(mn.X, mx.Y, mx.Z),
+ new XYZ(mx.X, mn.Y, mn.Z), new XYZ(mx.X, mn.Y, mx.Z),
+ new XYZ(mx.X, mx.Y, mn.Z), new XYZ(mx.X, mx.Y, mx.Z),
+ }.Select(transform.OfPoint).ToArray();
+
+ return new BoundingBoxXYZ
+ {
+ Min = new XYZ(corners.Min(p => p.X), corners.Min(p => p.Y), corners.Min(p => p.Z)),
+ Max = new XYZ(corners.Max(p => p.X), corners.Max(p => p.Y), corners.Max(p => p.Z)),
+ };
+ }
+
+ ///
+ /// Returns true when the two axis-aligned bounding boxes overlap in all three axes.
+ ///
+ public static bool Intersects(this BoundingBoxXYZ first, BoundingBoxXYZ second) =>
+ first is not null && second is not null &&
+ first.Min.X <= second.Max.X && first.Max.X >= second.Min.X &&
+ first.Min.Y <= second.Max.Y && first.Max.Y >= second.Min.Y &&
+ first.Min.Z <= second.Max.Z && first.Max.Z >= second.Min.Z;
+
+ ///
+ /// Returns true when the two axis-aligned bounding boxes overlap in all three axes,
+ /// expanding each box by on every side before the check.
+ ///
+ public static bool Intersects(this BoundingBoxXYZ first, BoundingBoxXYZ second, double tolerance) =>
+ first is not null && second is not null &&
+ first.Min.X - tolerance <= second.Max.X + tolerance && first.Max.X + tolerance >= second.Min.X - tolerance &&
+ first.Min.Y - tolerance <= second.Max.Y + tolerance && first.Max.Y + tolerance >= second.Min.Y - tolerance &&
+ first.Min.Z - tolerance <= second.Max.Z + tolerance && first.Max.Z + tolerance >= second.Min.Z - tolerance;
+
+ ///
+ /// Returns the smallest axis-aligned bounding box that encloses both
+ /// and .
+ ///
+ public static BoundingBoxXYZ Combine(this BoundingBoxXYZ first, BoundingBoxXYZ second) =>
+ new()
+ {
+ Min = new XYZ(
+ Math.Min(first.Min.X, second.Min.X),
+ Math.Min(first.Min.Y, second.Min.Y),
+ Math.Min(first.Min.Z, second.Min.Z)),
+ Max = new XYZ(
+ Math.Max(first.Max.X, second.Max.X),
+ Math.Max(first.Max.Y, second.Max.Y),
+ Math.Max(first.Max.Z, second.Max.Z)),
+ };
+
+ ///
+ /// Returns true when lies inside or on the boundary
+ /// of .
+ ///
+ public static bool Contains(this BoundingBoxXYZ bbox, XYZ point) =>
+ bbox is not null && point is not null &&
+ point.X >= bbox.Min.X && point.X <= bbox.Max.X &&
+ point.Y >= bbox.Min.Y && point.Y <= bbox.Max.Y &&
+ point.Z >= bbox.Min.Z && point.Z <= bbox.Max.Z;
+
+ ///
+ /// Returns true when is fully enclosed within
+ /// .
+ ///
+ public static bool Contains(this BoundingBoxXYZ bbox, BoundingBoxXYZ other) =>
+ bbox is not null && other is not null &&
+ other.Min.X >= bbox.Min.X && other.Max.X <= bbox.Max.X &&
+ other.Min.Y >= bbox.Min.Y && other.Max.Y <= bbox.Max.Y &&
+ other.Min.Z >= bbox.Min.Z && other.Max.Z <= bbox.Max.Z;
+
+ ///
+ /// Returns the geometric centre of .
+ ///
+ public static XYZ ComputeCentroid(this BoundingBoxXYZ bbox) =>
+ new XYZ(
+ (bbox.Min.X + bbox.Max.X) / 2,
+ (bbox.Min.Y + bbox.Max.Y) / 2,
+ (bbox.Min.Z + bbox.Max.Z) / 2);
+
+ ///
+ /// Returns the volume of (width × depth × height).
+ /// Returns 0 for degenerate boxes where any dimension is zero or negative.
+ ///
+ public static double ComputeVolume(this BoundingBoxXYZ bbox)
+ {
+ double dx = bbox.Max.X - bbox.Min.X;
+ double dy = bbox.Max.Y - bbox.Min.Y;
+ double dz = bbox.Max.Z - bbox.Min.Z;
+ return dx <= 0 || dy <= 0 || dz <= 0 ? 0 : dx * dy * dz;
+ }
+
+ ///
+ /// Projects orthogonally onto .
+ ///
+ /// - — reconstructed from projected endpoints.
+ /// - — reconstructed through projected start, mid-tessellation, and end points.
+ /// - Any other curve — approximated as a through all projected tessellation points.
+ ///
+ ///
+ public static Curve ProjectOnto(this Curve curve, Plane plane)
+ {
+ XYZ[] pts = curve.Tessellate()
+ .Select(p => plane.ProjectOnto(p))
+ .ToArray();
+
+ return curve switch
+ {
+ Line => Line.CreateBound(pts[0], pts[pts.Length - 1]),
+ Arc => Arc.Create(pts[0], pts[pts.Length - 1], pts[pts.Length / 2]),
+ _ => NurbSpline.CreateCurve(pts, Enumerable.Repeat(1.0, pts.Length).ToArray()),
+ };
+ }
}
diff --git a/Revit.Extensions/Extensions/ParameterExtensions.cs b/Revit.Extensions/Extensions/ParameterExtensions.cs
new file mode 100644
index 0000000..205f19d
--- /dev/null
+++ b/Revit.Extensions/Extensions/ParameterExtensions.cs
@@ -0,0 +1,220 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Autodesk.Revit.DB;
+
+namespace Revit.Extensions;
+
+///
+/// Extension methods for reading Revit parameter values.
+///
+public static class ParameterExtensions
+{
+ ///
+ /// Returns the parameter value cast to .
+ /// Supported types: , , , .
+ /// Returns default when the parameter has no value ( is false).
+ ///
+ ///
+ /// Thrown when does not match the parameter's storage type.
+ ///
+ public static T? GetValue(this Parameter parameter)
+ {
+ if (!parameter.HasValue)
+ return default;
+
+ if (typeof(T) == typeof(string))
+ return (T?)(object?)parameter.AsString();
+
+ if (typeof(T) == typeof(double))
+ return (T?)(object?)parameter.AsDouble();
+
+ if (typeof(T) == typeof(int))
+ return (T?)(object?)parameter.AsInteger();
+
+ if (typeof(T) == typeof(ElementId))
+ return (T?)(object?)parameter.AsElementId();
+
+ throw new NotSupportedException(
+ $"Type '{typeof(T)}' is not supported. Use string, double, int, or ElementId.");
+ }
+
+ ///
+ /// Looks up a parameter by name and returns its value as .
+ /// Returns default when the parameter is not found or has no value.
+ ///
+ public static T? GetParameterValue(this Element element, string parameterName)
+ {
+ Parameter? param = element.LookupParameter(parameterName);
+ return param is null ? default : param.GetValue();
+ }
+
+ ///
+ /// Looks up a built-in parameter and returns its value as .
+ /// Returns default when the parameter is not found or has no value.
+ ///
+ public static T? GetParameterValue(this Element element, BuiltInParameter builtInParameter)
+ {
+ Parameter? param = element.get_Parameter(builtInParameter);
+ return param is null ? default : param.GetValue();
+ }
+
+ ///
+ /// Looks up a parameter by name on the element instance first, then on its type.
+ /// Returns null when the element is invalid or the parameter is not found on either.
+ ///
+ public static Parameter? GetParameterFromInstanceOrType(this Element element, string parameterName)
+ {
+ if (!element.IsValidObject)
+ return null;
+
+ Parameter? param = element.LookupParameter(parameterName);
+ if (param is not null)
+ return param;
+
+ ElementId typeId = element.GetTypeId();
+ if (typeId is null)
+ return null;
+
+ return element.Document?.GetElement(typeId)?.LookupParameter(parameterName);
+ }
+
+#if !REVIT_2020 && !REVIT_2021
+ ///
+ /// Returns a sorted list of shared/project parameter names that are filterable for
+ /// and whose data type matches .
+ /// Built-in parameters (negative ) are excluded.
+ ///
+ ///
+ ///
+ /// doc.GetParameterNamesBySpec(BuiltInCategory.OST_Walls, SpecTypeId.String.Text)
+ /// doc.GetParameterNamesBySpec(BuiltInCategory.OST_Walls, SpecTypeId.Length)
+ ///
+ ///
+ public static List GetParameterNamesBySpec(
+ this Document document,
+ BuiltInCategory category,
+ ForgeTypeId specTypeId)
+ {
+ ElementId categoryId = Category.GetCategory(document, category).Id;
+
+ return ParameterFilterUtilities
+ .GetFilterableParametersInCommon(document, [categoryId])
+#if REVIT_2024 || REVIT_2025 || REVIT_2026 || REVIT_2027
+ .Where(id => id.Value >= 0)
+#else
+ .Where(id => id.IntegerValue >= 0)
+#endif
+ .Select(id => document.GetElement(id) as ParameterElement)
+ .Where(pe => pe?.GetDefinition().GetDataType() == specTypeId)
+ .Select(pe => pe!.Name)
+ .OrderBy(name => name)
+ .ToList();
+ }
+
+ ///
+ /// Returns a sorted list of parameter names on
+ /// whose data type matches .
+ /// When is true, type parameters are included too.
+ ///
+ ///
+ ///
+ /// fam.GetParameterNamesBySpec(SpecTypeId.Length)
+ /// fam.GetParameterNamesBySpec(SpecTypeId.Length, includeTypeParameters: true)
+ ///
+ ///
+ public static List GetParameterNamesBySpec(
+ this FamilyInstance familyInstance,
+ ForgeTypeId specTypeId,
+ bool includeTypeParameters = false)
+ {
+ IEnumerable parameters = familyInstance.Parameters.Cast();
+
+ if (includeTypeParameters)
+ parameters = parameters.Concat(familyInstance.Symbol.Parameters.Cast());
+
+ return parameters
+ .Where(p => p.Definition.GetDataType() == specTypeId)
+ .Select(p => p.Definition.Name)
+ .Distinct()
+ .OrderBy(name => name)
+ .ToList();
+ }
+#endif
+
+ // -------------------------------------------------------------------------
+ // Type parameters
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Returns a built-in parameter from the element's type (not the instance).
+ /// Returns null when the type element does not exist.
+ ///
+ public static Parameter? GetTypeParameter(this Element element, BuiltInParameter builtInParameter)
+ {
+ Element? type = element.Document.GetElement(element.GetTypeId());
+ return type?.get_Parameter(builtInParameter);
+ }
+
+ ///
+ /// Looks up a named parameter on the element's type (not the instance).
+ /// Returns null when the type element does not exist or the parameter is not found.
+ ///
+ public static Parameter? GetTypeParameter(this Element element, string parameterName)
+ {
+ Element? type = element.Document.GetElement(element.GetTypeId());
+ return type?.LookupParameter(parameterName);
+ }
+
+ // -------------------------------------------------------------------------
+ // Element helper
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Returns the element referenced by this parameter,
+ /// cast to .
+ /// Returns null when the parameter is null, has no value,
+ /// is not an parameter,
+ /// or the element is not of type .
+ ///
+ public static T? AsElement(this Parameter parameter, Document document)
+ where T : Element
+ {
+ if (parameter is null || !parameter.HasValue)
+ return null;
+
+ if (parameter.StorageType != StorageType.ElementId)
+ return null;
+
+ ElementId id = parameter.AsElementId();
+ if (id is null)
+ return null;
+
+ return document.GetElement(id) as T;
+ }
+
+ // -------------------------------------------------------------------------
+ // Boolean helper
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Reads a Yes/No parameter as bool.
+ /// Returns false when the parameter is null, has no value,
+ /// or is not a Yes/No type.
+ ///
+ public static bool AsBool(this Parameter parameter)
+ {
+ if (parameter is null || !parameter.HasValue)
+ return false;
+
+#if REVIT_2020 || REVIT_2021
+ if (parameter.Definition.ParameterType != ParameterType.YesNo)
+ return false;
+#else
+ if (parameter.Definition.GetDataType() != SpecTypeId.Boolean.YesNo)
+ return false;
+#endif
+
+ return parameter.AsInteger() == 1;
+ }
+}
diff --git a/Revit.Extensions/Extensions/PointExtensions.cs b/Revit.Extensions/Extensions/PointExtensions.cs
index 8c790f1..1fa4416 100644
--- a/Revit.Extensions/Extensions/PointExtensions.cs
+++ b/Revit.Extensions/Extensions/PointExtensions.cs
@@ -32,4 +32,24 @@ public static XYZ Recalculate(this XYZ pointToChange, ForgeTypeId forgeTypeId)
return new(x, y, z);
}
#endif
+
+ ///
+ /// Converts an point from Revit internal units (feet) to millimeters
+ /// without requiring UnitUtils — works headless.
+ ///
+ public static XYZ ToMillimeters(this XYZ point) =>
+ new XYZ(
+ point.X.FeetToMillimeters(),
+ point.Y.FeetToMillimeters(),
+ point.Z.FeetToMillimeters());
+
+ ///
+ /// Converts an point from millimeters to Revit internal units (feet)
+ /// without requiring UnitUtils — works headless.
+ ///
+ public static XYZ FromMillimeters(this XYZ point) =>
+ new XYZ(
+ point.X.MillimetersToFeet(),
+ point.Y.MillimetersToFeet(),
+ point.Z.MillimetersToFeet());
}
diff --git a/Revit.Extensions/Extensions/RibbonExtensions.cs b/Revit.Extensions/Extensions/RibbonExtensions.cs
new file mode 100644
index 0000000..b423a26
--- /dev/null
+++ b/Revit.Extensions/Extensions/RibbonExtensions.cs
@@ -0,0 +1,224 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using Autodesk.Revit.UI;
+
+namespace Revit.Extensions;
+
+///
+/// Extension methods for building Revit ribbon tabs, panels, and buttons.
+///
+public static class RibbonExtensions
+{
+ // -------------------------------------------------------------------------
+ // Panel
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Returns an existing ribbon panel named on
+ /// , or creates the tab (if absent) and panel.
+ ///
+ public static RibbonPanel GetOrCreatePanel(
+ this UIControlledApplication app,
+ string tabName,
+ string panelName)
+ {
+ try
+ {
+ RibbonPanel? existing = app.GetRibbonPanels(tabName)
+ .FirstOrDefault(p => p.Name == panelName);
+
+ if (existing is not null)
+ return existing;
+ }
+ catch
+ {
+ // Tab does not exist yet — create it below.
+ }
+
+ try { app.CreateRibbonTab(tabName); } catch { /* already exists */ }
+
+ return app.CreateRibbonPanel(tabName, panelName);
+ }
+
+ // -------------------------------------------------------------------------
+ // Push button
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Adds a wired to to
+ /// .
+ ///
+ ///
+ /// An implementation in any loaded assembly.
+ ///
+ /// The ribbon panel to add the button to.
+ /// Label shown under the button.
+ /// Optional tooltip text.
+ /// Optional 32×32 image shown on large buttons.
+ /// Optional 16×16 image shown on small/stacked buttons.
+ ///
+ ///
+ /// panel.AddPushButton<MyCommand>(
+ /// "Export",
+ /// tooltip: "Exports the model to IFC",
+ /// largeImage: svgPath.ToRibbonImage(Colors.SteelBlue));
+ ///
+ ///
+ public static PushButton AddPushButton(
+ this RibbonPanel panel,
+ string text,
+ string? tooltip = null,
+ BitmapSource? largeImage = null,
+ BitmapSource? image = null)
+ where TCommand : class, IExternalCommand
+ {
+ Type commandType = typeof(TCommand);
+
+ Assembly assembly = Assembly.GetAssembly(commandType)
+ ?? throw new InvalidOperationException(
+ $"Unable to locate assembly for {commandType.FullName}.");
+
+ var data = new PushButtonData(
+ name: commandType.FullName!,
+ text: text,
+ assemblyName: assembly.Location,
+ className: commandType.FullName!);
+
+ var button = panel.AddItem(data) as PushButton
+ ?? throw new InvalidOperationException("Failed to create PushButton.");
+
+ if (tooltip is not null) button.ToolTip = tooltip;
+ if (largeImage is not null) button.LargeImage = largeImage;
+ if (image is not null) button.Image = image;
+
+ return button;
+ }
+#if REVIT_2024 || REVIT_2023 || REVIT_2022 || REVIT_2021 || REVIT_2020
+#else
+ ///
+ /// Adds a wired to
+ /// with availability controlled by to a
+ /// right-click .
+ ///
+ /// An implementation.
+ ///
+ /// An implementation with a public
+ /// parameterless constructor.
+ ///
+ public static CommandMenuItem AddMenuItem(
+ this ContextMenu menu,
+ string title,
+ string? tooltip = null)
+ where TCommand : class, IExternalCommand
+ where TAvailability : class, IExternalCommandAvailability, new()
+ {
+ Type commandType = typeof(TCommand);
+
+ Assembly assembly = Assembly.GetAssembly(commandType)
+ ?? throw new InvalidOperationException(
+ $"Unable to locate assembly for {commandType.FullName}.");
+
+ var menuItem = new CommandMenuItem(title, commandType.FullName!, assembly.Location);
+ menuItem.SetAvailabilityClassName(typeof(TAvailability).FullName!);
+
+ if (tooltip is not null)
+ menuItem.SetToolTip(tooltip);
+
+ menu.AddItem(menuItem);
+ return menuItem;
+ }
+#endif
+ // -------------------------------------------------------------------------
+ // Image helpers
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Renders an SVG path data string into a square .
+ /// The path is uniformly scaled and centred inside the canvas.
+ ///
+ /// SVG d attribute value, e.g. "M10 10 L90 90 …".
+ /// Fill colour of the rendered shape.
+ /// Canvas side length in pixels (default 32 for large ribbon images).
+ ///
+ ///
+ /// BitmapSource icon = "M60.6 10.7H42.3…".ToRibbonImage(Colors.SteelBlue, 32);
+ ///
+ ///
+ public static BitmapSource ToRibbonImage(this string svgPathData, Color color, double size = 32)
+ {
+ Geometry geometry = Geometry.Parse(svgPathData);
+ System.Windows.Rect bounds = geometry.Bounds;
+
+ double scale = Math.Min(size / bounds.Width, size / bounds.Height);
+
+ var transform = new TransformGroup();
+ transform.Children.Add(new ScaleTransform(scale, scale));
+ transform.Children.Add(new TranslateTransform(
+ -bounds.X * scale + (size - bounds.Width * scale) / 2,
+ -bounds.Y * scale + (size - bounds.Height * scale) / 2));
+
+ var drawing = new GeometryDrawing
+ {
+ Geometry = geometry,
+ Brush = new SolidColorBrush(color),
+ Pen = null,
+ };
+
+ var group = new DrawingGroup { Transform = transform };
+ group.Children.Add(drawing);
+
+ var visual = new DrawingVisual();
+ using (DrawingContext ctx = visual.RenderOpen())
+ ctx.DrawDrawing(group);
+
+ var bitmap = new RenderTargetBitmap(
+ (int)size, (int)size, 96, 96, PixelFormats.Pbgra32);
+ bitmap.Render(visual);
+ bitmap.Freeze();
+ return bitmap;
+ }
+
+ ///
+ /// Loads a from an image file on disk (PNG, BMP, ICO, etc.).
+ /// Returns null when the file does not exist.
+ ///
+ /// Absolute path to the image file.
+ public static BitmapSource? ImageFromFile(string filePath)
+ {
+ if (!File.Exists(filePath))
+ return null;
+
+ var image = new BitmapImage(new Uri(filePath, UriKind.Absolute));
+ image.Freeze();
+ return image;
+ }
+
+ ///
+ /// Decodes a from a raw image byte array (PNG, BMP, ICO, etc.).
+ /// Intended for images embedded as assembly resources.
+ ///
+ /// Raw bytes of the image file.
+ ///
+ ///
+ /// // Embed the file in the .csproj:
+ /// // <EmbeddedResource Include="Resources\icon_32.png" />
+ /// byte[] bytes = Resources.icon_32; // strongly-typed resource
+ /// BitmapSource icon = RibbonExtensions.ImageFromBytes(bytes);
+ ///
+ ///
+ public static BitmapSource ImageFromBytes(byte[] imageBytes)
+ {
+ using var stream = new MemoryStream(imageBytes);
+ var image = new BitmapImage();
+ image.BeginInit();
+ image.StreamSource = stream;
+ image.CacheOption = BitmapCacheOption.OnLoad;
+ image.EndInit();
+ image.Freeze();
+ return image;
+ }
+}
diff --git a/Revit.Extensions/Extensions/UIControlledApplicationExtensions.cs b/Revit.Extensions/Extensions/UIControlledApplicationExtensions.cs
new file mode 100644
index 0000000..9e35f6b
--- /dev/null
+++ b/Revit.Extensions/Extensions/UIControlledApplicationExtensions.cs
@@ -0,0 +1,60 @@
+using Autodesk.Revit.DB.ExternalService;
+using Autodesk.Revit.UI;
+using System;
+using System.Collections.Generic;
+
+namespace Revit.Extensions;
+
+///
+/// Extension methods for .
+///
+public static class UIControlledApplicationExtensions
+{
+ ///
+ /// Creates a and registers it with Revit's
+ /// DirectContext3D service. Call once from
+ /// .
+ ///
+ ///
+ /// The returned session is already active — Revit will call
+ /// as soon as a 3D view is displayed
+ /// and geometry has been added via the fluent drawing API.
+ /// Dispose the session in to
+ /// unregister the server and release GPU buffers.
+ ///
+ /// // App.cs — OnStartup
+ /// _drawSession = application.RegisterDrawSession();
+ ///
+ /// // App.cs — OnShutdown
+ /// _drawSession?.Dispose();
+ ///
+ ///
+ /// The controlled application from OnStartup.
+ /// The registered instance.
+ ///
+ /// Thrown when the DirectContext3D service is unavailable.
+ ///
+ public static DrawSession RegisterDrawSession(this UIControlledApplication application)
+ {
+ if (ExternalServiceRegistry.GetService(
+ ExternalServices.BuiltInExternalServices.DirectContext3DService)
+ is not MultiServerService service)
+ throw new InvalidOperationException(
+ "DirectContext3D service is not available. " +
+ "Ensure this is called from IExternalApplication.OnStartup.");
+
+ var session = new DrawSession();
+
+ // Per Revit API docs: AddServer first, then GetActiveServerIds.
+ service.AddServer(session);
+
+ IList activeIds = service.GetActiveServerIds();
+ if (!activeIds.Contains(session.GetServerId()))
+ {
+ activeIds.Add(session.GetServerId());
+ service.SetActiveServers(activeIds);
+ }
+
+ return session;
+ }
+}
diff --git a/Revit.Extensions/Extensions/UIDocumentExtensions.cs b/Revit.Extensions/Extensions/UIDocumentExtensions.cs
new file mode 100644
index 0000000..d835f30
--- /dev/null
+++ b/Revit.Extensions/Extensions/UIDocumentExtensions.cs
@@ -0,0 +1,66 @@
+using Autodesk.Revit.DB;
+using Autodesk.Revit.UI;
+using Autodesk.Revit.UI.Selection;
+
+namespace Revit.Extensions;
+
+///
+/// Extension methods for .
+///
+public static class UIDocumentExtensions
+{
+ ///
+ /// Prompts the user to pick an element of type from the model.
+ /// Returns null when the user cancels or picks an incompatible element.
+ ///
+ ///
+ ///
+ /// Wall? wall = uidoc.PickElementByClass<Wall>();
+ ///
+ ///
+ public static T? PickElementByClass(this UIDocument uidoc, string? statusPrompt = null)
+ where T : Element
+ {
+ try
+ {
+ Reference? reference = uidoc.Selection.PickObject(
+ ObjectType.Element,
+ new ClassFilter(),
+ statusPrompt ?? "Pick element");
+
+ if (reference is null)
+ return null;
+
+ Element element = uidoc.Document.GetElement(reference);
+
+ if (element is null || !element.IsValidObject)
+ return null;
+
+ return element as T;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Returns the elements currently selected in the model.
+ ///
+ public static IEnumerable GetSelectedElements(this UIDocument uidoc)
+ {
+ foreach (ElementId id in uidoc.Selection.GetElementIds())
+ yield return uidoc.Document.GetElement(id);
+ }
+
+ // -------------------------------------------------------------------------
+
+ private sealed class ClassFilter : ISelectionFilter
+ where T : Element
+ {
+ public bool AllowElement(Element elem) => elem is T;
+ public bool AllowReference(Reference reference, XYZ position) => true;
+ }
+}
diff --git a/Revit.Extensions/Extensions/UnitExtensions.cs b/Revit.Extensions/Extensions/UnitExtensions.cs
new file mode 100644
index 0000000..313532a
--- /dev/null
+++ b/Revit.Extensions/Extensions/UnitExtensions.cs
@@ -0,0 +1,26 @@
+using System;
+
+namespace Revit.Extensions;
+
+///
+/// Extension methods for unit conversions that do not require the Revit API.
+///
+public static class UnitExtensions
+{
+ /// Millimeters per foot (304.8 = 12 inches × 25.4 mm/inch).
+ private const double MmPerFoot = 304.8;
+
+ /// Converts Revit internal units (feet) to millimeters.
+ public static double FeetToMillimeters(this double feet) => feet * MmPerFoot;
+
+ /// Converts millimeters to Revit internal units (feet).
+ public static double MillimetersToFeet(this double mm) => mm / MmPerFoot;
+
+ private const double DefaultTolerance = 1e-9;
+
+ public static bool IsZero(this double a, double tolerance = DefaultTolerance) =>
+ Math.Abs(a) < tolerance;
+
+ public static bool IsEqual(this double a, double b, double tolerance = DefaultTolerance) =>
+ Math.Abs(a - b) < tolerance;
+}
diff --git a/Revit.Extensions/Extensions/ViewExtensions.cs b/Revit.Extensions/Extensions/ViewExtensions.cs
new file mode 100644
index 0000000..dd0e964
--- /dev/null
+++ b/Revit.Extensions/Extensions/ViewExtensions.cs
@@ -0,0 +1,47 @@
+using Autodesk.Revit.DB;
+
+namespace Revit.Extensions;
+
+///
+/// Extension methods for .
+///
+public static class ViewExtensions
+{
+ ///
+ /// Returns the that hosts this view on a sheet,
+ /// or null when the view is not placed on any sheet.
+ ///
+ ///
+ /// Returns the scope box (Volume of Interest) assigned to ,
+ /// or null when none is assigned.
+ ///
+ public static Element? GetScopeBox(this View view)
+ {
+ Parameter? p = view.get_Parameter(BuiltInParameter.VIEWER_VOLUME_OF_INTEREST_CROP);
+
+ if (p is null || !p.HasValue)
+ return null;
+
+ ElementId id = p.AsElementId();
+ return id.IsValid() ? view.Document.GetElement(id) : null;
+ }
+
+#if !REVIT_2020 && !REVIT_2021 && !REVIT_2022
+ public static Viewport? GetViewport(this View view)
+ {
+ var status = view.GetPlacementOnSheetStatus();
+ if (status == ViewPlacementOnSheetStatus.NotApplicable ||
+ status == ViewPlacementOnSheetStatus.NotPlaced)
+ return null;
+
+ var pvp = new ParameterValueProvider(new ElementId(BuiltInParameter.VIEWPORT_VIEW));
+ var rule = new FilterElementIdRule(pvp, new FilterNumericEquals(), view.Id);
+ var filter = new ElementParameterFilter(rule);
+
+ return new FilteredElementCollector(view.Document)
+ .OfCategory(BuiltInCategory.OST_Viewports)
+ .WherePasses(filter)
+ .FirstElement() as Viewport;
+ }
+#endif
+}
diff --git a/Revit.Extensions/Revit.Extensions.csproj b/Revit.Extensions/Revit.Extensions.csproj
index 5b427c0..8b99d68 100644
--- a/Revit.Extensions/Revit.Extensions.csproj
+++ b/Revit.Extensions/Revit.Extensions.csproj
@@ -19,11 +19,24 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Revit.Extensions/icon-large.png b/Revit.Extensions/icon-large.png
new file mode 100644
index 0000000..1966b17
Binary files /dev/null and b/Revit.Extensions/icon-large.png differ
diff --git a/Revit.Extensions/icon.png b/Revit.Extensions/icon.png
new file mode 100644
index 0000000..b84faf3
Binary files /dev/null and b/Revit.Extensions/icon.png differ