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 + Revit.Extensions # 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