diff --git a/AGENTS.md b/AGENTS.md
index c5fabe7..2f85d55 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,6 +10,7 @@ The same package bundles Roslyn tooling: an incremental source generator that em
* Model a temporary state change as an `IDisposable` returned from a `Begin...Scope()` factory that reverses it on `Dispose`.
* Shared static state is thread-safe.
* Reflection and native interop stay non-public in `Internal/`.
+* CsWin32 generates the Win32 interop from the `NativeMethods.txt` of each project. A new Win32 API goes into that file, never into a hand-written `DllImport` or `LibraryImport`.
* Never break the public surface. Deprecate a renamed member with `[Obsolete]` with a JetBrains `[CodeTemplate]` auto-conversion; the obsolete member forwards to the replacement and never throws or changes behavior.
* Mark a member Revit invokes but consumers must not call `[EditorBrowsable(EditorBrowsableState.Never)]`.
* The Roslyn tooling is public surface. It targets `netstandard2.0`, dual-targets Roslyn (the default with the `.Roslyn414` twins), uses stable `RVTTK####` diagnostic ids, and needs an `AnalyzerReleases.Unshipped.md` entry per new or changed rule. Never rename a generated member or renumber a shipped id.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 27ed88e..e590cad 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,9 +2,16 @@
### External Events
-- External events always run as your add-in, even when created on a background thread or in a modeless window. Inside the handler, `ActiveAddInId` returns your add-in, and Extensible Storage schemas with vendor access accept the changes. Previously, Revit ran such a handler without an add-in.
-- `ExternalApplication` and `ExternalCommand` set this up automatically, for the add-in project and every project it references.
-- New `RegisterAddInAssembly()` method for the remaining cases: an `IExternalApplication` without the Toolkit base class, an assembly loaded at run time, or a library from a NuGet package. Call it in `OnStartup`:
+#### External events run as your add-in from any thread
+
+An external event created on a background thread or in a modeless window used to run its handler without an add-in.
+Inside such a handler, `UIApplication.ActiveAddInId` returned no add-in, and Extensible Storage refused the changes to schemas with vendor write access.
+
+Now the handler always runs as your add-in: `ActiveAddInId` returns its ID, and vendor schemas accept the changes.
+`ExternalApplication` and `ExternalCommand` set this up automatically, for the add-in project and every project it references.
+
+The new `RegisterAddInAssembly()` method covers the remaining cases: an `IExternalApplication` without the Toolkit base class, an assembly loaded at run time, or a library from a NuGet package.
+Call it in `OnStartup`:
```c#
public Result OnStartup(UIControlledApplication application)
@@ -14,6 +21,27 @@ public Result OnStartup(UIControlledApplication application)
}
```
+### Context
+
+#### No more frozen Revit interface after loading older families
+
+It's a long-standing Revit issue known from the forums: an add-in loads a family saved in an earlier Revit version, and the Revit interface stops updating.
+The ribbon, the dockable panes, and the add-in windows still react to clicks, but nothing redraws until you resize a window.
+
+The cause is the modal loop of the Model Upgrade progress dialog: it swallows the message WPF waits for before it draws the next frame.
+
+The new `RevitContext.BeginModalLoopCompatibilityScope()` fixes it.
+Wrap the calls that load or open older files, and the interface redraws as soon as the scope ends:
+
+```c#
+using (RevitContext.BeginModalLoopCompatibilityScope())
+{
+ document.LoadFamily(fileName, out var family);
+}
+```
+
+The scope covers `Document.LoadFamily`, `Application.NewFamilyDocument`, and `Application.OpenDocumentFile`, and costs a single window message when it ends.
+
# 2027.0.3
### External Events
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 71811c7..7cd2b8e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -54,6 +54,9 @@
+
+
+
diff --git a/README.md b/README.md
index a0c1f3c..e8f93ba 100644
--- a/README.md
+++ b/README.md
@@ -210,15 +210,19 @@ A raise that Revit refuses with `Denied` or `TimedOut` never runs.
`Raise(argument)` returns that result to the caller.
The `RaiseAsync()` task fails with an `InvalidOperationException`.
-Inside the event handler, `UIApplication.ActiveAddInId` returns the ID of your add-in, even when the event is created on a background thread or in a modeless window.
-Extensible Storage schemas with vendor write access accept the changes of the handler.
-`ExternalApplication` and `ExternalCommand` set this up automatically.
-Without them, or for a library from a NuGet package or loaded at run time, register the assembly in `OnStartup`:
+**Running as your add-in from any thread**
+
+An external event created on a background thread or in a modeless window runs its handler as your add-in.
+Inside the handler, `UIApplication.ActiveAddInId` returns the ID of your add-in, and Extensible Storage schemas with vendor write access accept the changes.
+`ExternalApplication` and `ExternalCommand` register the add-in automatically.
+Without them, or for a library loaded at run time or from a NuGet package, register the assembly in `OnStartup`:
```c#
application.RegisterAddInAssembly(typeof(Application).Assembly);
```
+The method registers the project references of the assembly too.
+
#### ExternalEvent
A synchronous external event that queues the handler via the Revit external event mechanism.
@@ -843,6 +847,21 @@ using (RevitContext.BeginDialogSuppressionScope(args =>
}
```
+**Loading families from earlier Revit versions**
+
+After the API loads a family or creates one from a template saved in an earlier Revit version, the ribbon, the dockable panes, and the add-in windows stop redrawing.
+They respond to clicks but show no change until a window is resized.
+Wrap the calls in `BeginModalLoopCompatibilityScope()` on the Revit thread:
+
+```C#
+using (RevitContext.BeginModalLoopCompatibilityScope())
+{
+ document.LoadFamily(fileName, out var family);
+}
+```
+
+The interface redraws when the scope is disposed.
+
### Options
The Toolkit provides implementation of various Revit interfaces.
diff --git a/source/Nice3point.Revit.Toolkit/Internal/MediaContextNotificationWindow.cs b/source/Nice3point.Revit.Toolkit/Internal/MediaContextNotificationWindow.cs
new file mode 100644
index 0000000..e2a24b7
--- /dev/null
+++ b/source/Nice3point.Revit.Toolkit/Internal/MediaContextNotificationWindow.cs
@@ -0,0 +1,57 @@
+using Windows.Win32;
+using Windows.Win32.Foundation;
+
+namespace Nice3point.Revit.Toolkit.Internal;
+
+///
+/// Provides access to the window WPF creates on each UI thread to receive the notifications of its render thread.
+///
+///
+/// The window title and the registered message name are internal to WPF and have the same values in .NET Framework and .NET.
+///
+internal static class MediaContextNotificationWindow
+{
+ private const string WindowName = "MediaContextNotificationWindow";
+ private static readonly uint ChannelNotifyMessage = PInvoke.RegisterWindowMessage("MilChannelNotify");
+
+ [ThreadStatic] private static HWND _window;
+
+ ///
+ /// Posts the channel notification to the notification window of the current thread.
+ ///
+ ///
+ /// The method caches the window handle per thread after the first search.
+ /// A thread without a WPF dispatcher has no notification window, and the method posts nothing.
+ ///
+ public static void PostChannelNotification()
+ {
+ if (!_window.IsNull && PInvoke.PostMessage(_window, ChannelNotifyMessage, default, default))
+ {
+ return;
+ }
+
+ _window = FindThreadWindow();
+ if (!_window.IsNull)
+ {
+ PInvoke.PostMessage(_window, ChannelNotifyMessage, default, default);
+ }
+ }
+
+ ///
+ /// Finds the notification window of the current thread among the top-level windows of the desktop.
+ ///
+ /// The window handle, or a null handle when the thread has no notification window.
+ private static unsafe HWND FindThreadWindow()
+ {
+ var threadId = PInvoke.GetCurrentThreadId();
+ var window = HWND.Null;
+ while (true)
+ {
+ window = PInvoke.FindWindowEx(HWND.Null, window, null, WindowName);
+ if (window.IsNull || PInvoke.GetWindowThreadProcessId(window, null) == threadId)
+ {
+ return window;
+ }
+ }
+ }
+}
diff --git a/source/Nice3point.Revit.Toolkit/NativeMethods.txt b/source/Nice3point.Revit.Toolkit/NativeMethods.txt
new file mode 100644
index 0000000..98a664f
--- /dev/null
+++ b/source/Nice3point.Revit.Toolkit/NativeMethods.txt
@@ -0,0 +1,5 @@
+FindWindowEx
+GetCurrentThreadId
+GetWindowThreadProcessId
+PostMessage
+RegisterWindowMessage
diff --git a/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj b/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj
index f0eb7b5..f83ae1f 100644
--- a/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj
+++ b/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj
@@ -6,6 +6,7 @@
$(DefineConstants);JETBRAINS_ANNOTATIONS
Debug.R20;Debug.R21;Debug.R22;Debug.R23;Debug.R24;Debug.R25;Debug.R26;Debug.R27
$(Configurations);Release.R20;Release.R21;Release.R22;Release.R23;Release.R24;Release.R25;Release.R26;Release.R27
+ $(NoWarn);PInvoke009
@@ -36,6 +37,7 @@
+
diff --git a/source/Nice3point.Revit.Toolkit/RevitContext.cs b/source/Nice3point.Revit.Toolkit/RevitContext.cs
index 3f4d160..de4a9d9 100644
--- a/source/Nice3point.Revit.Toolkit/RevitContext.cs
+++ b/source/Nice3point.Revit.Toolkit/RevitContext.cs
@@ -3,9 +3,7 @@
using System.Windows;
using Autodesk.Revit.UI;
using Autodesk.Revit.UI.Events;
-#if REVIT2027_OR_GREATER
using Nice3point.Revit.Toolkit.Internal;
-#endif
namespace Nice3point.Revit.Toolkit;
@@ -353,6 +351,39 @@ public static IDisposable BeginDialogSuppressionScope(Action
+ /// Begins a scope that redraws the Revit user interface after the API upgrades a family from an earlier Revit version.
+ ///
+ /// A disposable scope. Call Dispose or use 'using' statement to redraw the user interface.
+ ///
+ ///
+ /// , ,
+ /// and upgrade a family or a family template saved in an earlier Revit version,
+ /// and Revit runs the modal loop of the Model Upgrade progress dialog.
+ /// The modal loop discards the notification WPF waits for before it draws the next frame.
+ /// After the upgrade, the ribbon, the dockable panes, and the WPF windows of the Revit thread respond to input but show no change until a window changes its size.
+ /// The model views are not affected.
+ ///
+ ///
+ /// Disposing the scope posts the notification again, and the user interface redraws.
+ /// Begin the scope on the Revit thread.
+ ///
+ /// This method is thread-safe.
+ ///
+ ///
+ ///
+ /// using (RevitContext.BeginModalLoopCompatibilityScope())
+ /// {
+ /// document.LoadFamily(fileName, out var family);
+ /// }
+ /// // The user interface redraws
+ ///
+ ///
+ public static IDisposable BeginModalLoopCompatibilityScope()
+ {
+ return ModalLoopCompatibilityScope.Instance;
+ }
+
internal static IDisposable BeginApiContextScope()
{
return new ApiContextScope(IncrementConstructorPointer, IncrementDestructorPointer);
@@ -431,6 +462,22 @@ public void Dispose()
}
}
+ ///
+ /// Represents a scope that posts the frame notification of WPF to the current thread on every dispose.
+ ///
+ ///
+ /// WPF returns at once on a repeated notification, and every call of returns the same instance.
+ ///
+ private sealed class ModalLoopCompatibilityScope : IDisposable
+ {
+ public static readonly ModalLoopCompatibilityScope Instance = new();
+
+ public void Dispose()
+ {
+ MediaContextNotificationWindow.PostChannelNotification();
+ }
+ }
+
[UnmanagedFunctionPointer(CallingConvention.ThisCall)]
private delegate IntPtr IncrementCtor(IntPtr self);
diff --git a/tests/Nice3point.Revit.Toolkit.Tests/ColorWindow.cs b/tests/Nice3point.Revit.Toolkit.Tests/ColorWindow.cs
new file mode 100644
index 0000000..d2120ad
--- /dev/null
+++ b/tests/Nice3point.Revit.Toolkit.Tests/ColorWindow.cs
@@ -0,0 +1,159 @@
+using System.Drawing;
+using System.Windows;
+using System.Windows.Interop;
+using System.Windows.Media;
+using System.Windows.Media.Animation;
+using Windows.Win32;
+using Windows.Win32.Foundation;
+using Windows.Win32.Graphics.Gdi;
+using Windows.Win32.Storage.Xps;
+using Brushes = System.Windows.Media.Brushes;
+using Color = System.Windows.Media.Color;
+using HorizontalAlignment = System.Windows.HorizontalAlignment;
+using Rectangle = System.Windows.Shapes.Rectangle;
+
+namespace Nice3point.Revit.Toolkit.Tests;
+
+///
+/// Represents a WPF window of the test thread filled with one color, outside the taskbar, with one column of pixels on the screen.
+///
+///
+/// Windows composes no frame for a window entirely outside the screen.
+/// The color the window shows is the color of the frame Windows composes for it, the last frame WPF presented.
+/// A square in its corner pulses on every frame, as the ribbon and the panes of Revit draw while Revit works: Revit discards the frame notification only while WPF draws.
+///
+internal sealed class ColorWindow : IDisposable
+{
+ private const double WindowSize = 40;
+ private const double VisibleWidth = 1;
+ private const double PulseSize = 4;
+ private const double PulseMinimumOpacity = 0.2;
+ private const PRINT_WINDOW_FLAGS RenderFullContent = (PRINT_WINDOW_FLAGS)2;
+
+ private static readonly TimeSpan DrawTimeout = TimeSpan.FromSeconds(2);
+ private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50);
+ private static readonly TimeSpan PulseDuration = TimeSpan.FromMilliseconds(500);
+
+ private readonly Window _window;
+ private readonly HWND _handle;
+
+ private ColorWindow(Color color)
+ {
+ _window = new Window
+ {
+ Width = WindowSize,
+ Height = WindowSize,
+ Left = SystemParameters.VirtualScreenLeft + VisibleWidth - WindowSize,
+ Top = SystemParameters.VirtualScreenTop,
+ WindowStyle = WindowStyle.None,
+ ResizeMode = ResizeMode.NoResize,
+ ShowInTaskbar = false,
+ ShowActivated = false,
+ Background = new SolidColorBrush(color)
+ };
+
+ var pulse = new Rectangle
+ {
+ Width = PulseSize,
+ Height = PulseSize,
+ Fill = Brushes.Black,
+ HorizontalAlignment = HorizontalAlignment.Left,
+ VerticalAlignment = VerticalAlignment.Top
+ };
+
+ pulse.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(PulseMinimumOpacity, PulseDuration)
+ {
+ AutoReverse = true,
+ RepeatBehavior = RepeatBehavior.Forever
+ });
+
+ _window.Content = pulse;
+ _window.Show();
+ _handle = new HWND(new WindowInteropHelper(_window).Handle);
+ }
+
+ ///
+ /// Shows the window and waits until it shows the color.
+ ///
+ /// The color of the window.
+ /// A used to cancel the wait.
+ ///
+ /// A task that represents the asynchronous show operation.
+ /// The task result contains the shown window.
+ ///
+ /// The window shows another color when the time to draw a frame runs out.
+ public static async Task ShowAsync(Color color, CancellationToken cancellationToken)
+ {
+ var window = new ColorWindow(color);
+ var drawnColor = await window.WaitForDrawnColorAsync(color, cancellationToken);
+ if (drawnColor != color)
+ {
+ window.Dispose();
+ throw new InvalidOperationException($"The window shows {drawnColor} in place of {color}.");
+ }
+
+ return window;
+ }
+
+ ///
+ /// Fills the window with the color.
+ ///
+ /// The color of the window.
+ public void Fill(Color color)
+ {
+ _window.Background = new SolidColorBrush(color);
+ }
+
+ ///
+ /// Waits until the window shows the expected color, or until the time to draw a frame runs out.
+ ///
+ /// The color to wait for.
+ /// A used to cancel the wait.
+ ///
+ /// A task that represents the asynchronous wait operation.
+ /// The task result contains the color the window shows.
+ ///
+ public async Task WaitForDrawnColorAsync(Color expectedColor, CancellationToken cancellationToken)
+ {
+ var deadline = DateTime.UtcNow + DrawTimeout;
+ var drawnColor = ReadDrawnColor();
+ while (drawnColor != expectedColor && DateTime.UtcNow < deadline)
+ {
+ await Task.Delay(PollInterval, cancellationToken);
+ drawnColor = ReadDrawnColor();
+ }
+
+ return drawnColor;
+ }
+
+ ///
+ public void Dispose()
+ {
+ _window.Close();
+ }
+
+ ///
+ /// Reads the center pixel of the frame Windows composes for the window.
+ ///
+ /// The color of the center pixel.
+ private Color ReadDrawnColor()
+ {
+ TestPInvoke.GetClientRect(_handle, out var bounds);
+ using var bitmap = new Bitmap(bounds.right, bounds.bottom);
+ using (var graphics = Graphics.FromImage(bitmap))
+ {
+ var context = graphics.GetHdc();
+ try
+ {
+ TestPInvoke.PrintWindow(_handle, new HDC(context), RenderFullContent);
+ }
+ finally
+ {
+ graphics.ReleaseHdc(context);
+ }
+ }
+
+ var pixel = bitmap.GetPixel(bounds.right / 2, bounds.bottom / 2);
+ return Color.FromRgb(pixel.R, pixel.G, pixel.B);
+ }
+}
diff --git a/tests/Nice3point.Revit.Toolkit.Tests/NativeMethods.json b/tests/Nice3point.Revit.Toolkit.Tests/NativeMethods.json
new file mode 100644
index 0000000..1c96c39
--- /dev/null
+++ b/tests/Nice3point.Revit.Toolkit.Tests/NativeMethods.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://aka.ms/CsWin32.schema.json",
+ "className": "TestPInvoke"
+}
diff --git a/tests/Nice3point.Revit.Toolkit.Tests/NativeMethods.txt b/tests/Nice3point.Revit.Toolkit.Tests/NativeMethods.txt
new file mode 100644
index 0000000..65bb2d2
--- /dev/null
+++ b/tests/Nice3point.Revit.Toolkit.Tests/NativeMethods.txt
@@ -0,0 +1,2 @@
+GetClientRect
+PrintWindow
diff --git a/tests/Nice3point.Revit.Toolkit.Tests/Nice3point.Revit.Toolkit.Tests.csproj b/tests/Nice3point.Revit.Toolkit.Tests/Nice3point.Revit.Toolkit.Tests.csproj
index c51569e..59baf50 100644
--- a/tests/Nice3point.Revit.Toolkit.Tests/Nice3point.Revit.Toolkit.Tests.csproj
+++ b/tests/Nice3point.Revit.Toolkit.Tests/Nice3point.Revit.Toolkit.Tests.csproj
@@ -4,6 +4,7 @@
Exe
true
false
+ true
Debug.R21;Debug.R22;Debug.R23;Debug.R24;Debug.R25;Debug.R26;Debug.R27
$(Configurations);Release.R21;Release.R22;Release.R23;Release.R24;Release.R25;Release.R26;Release.R27
@@ -14,6 +15,7 @@
+
diff --git a/tests/Nice3point.Revit.Toolkit.Tests/RevitContextTests.cs b/tests/Nice3point.Revit.Toolkit.Tests/RevitContextTests.cs
index 1d7c3fd..4369040 100644
--- a/tests/Nice3point.Revit.Toolkit.Tests/RevitContextTests.cs
+++ b/tests/Nice3point.Revit.Toolkit.Tests/RevitContextTests.cs
@@ -1,4 +1,6 @@
+using System.Globalization;
using System.IO;
+using System.Windows.Media;
using System.Windows.Threading;
using Autodesk.Revit.UI;
using Nice3point.TUnit.Revit;
@@ -209,4 +211,51 @@ await Assert.That(() =>
scope.Dispose();
}).ThrowsNothing();
}
+
+ [Test]
+ public async Task BeginModalLoopCompatibilityScope_TemplateOfThePreviousVersion_WindowDrawsTheNextFrame(CancellationToken cancellationToken)
+ {
+ // Arrange
+ var previousVersion = int.Parse(UiApplication.Application.VersionNumber, CultureInfo.InvariantCulture) - 1;
+ var templatePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Autodesk", $"RVT {previousVersion}", "Family Templates", "English", "Metric Detail Item.rft");
+ if (!File.Exists(templatePath))
+ {
+ Skip.Test($"Revit {previousVersion} with the English content is not installed");
+ return;
+ }
+
+ using var window = await ColorWindow.ShowAsync(Colors.Red, cancellationToken);
+
+ // Act
+ using (RevitContext.BeginModalLoopCompatibilityScope())
+ {
+ UiApplication.Application.NewFamilyDocument(templatePath).Close(false);
+ }
+
+ window.Fill(Colors.Blue);
+
+ // Assert
+ await Assert.That(await window.WaitForDrawnColorAsync(Colors.Blue, cancellationToken)).IsEqualTo(Colors.Blue);
+ }
+
+ [Test]
+ public async Task BeginModalLoopCompatibilityScope_DisposedTwice_DoesNotThrow()
+ {
+ // Arrange
+ var scope = RevitContext.BeginModalLoopCompatibilityScope();
+
+ // Act & Assert
+ await Assert.That(() =>
+ {
+ scope.Dispose();
+ scope.Dispose();
+ }).ThrowsNothing();
+ }
+
+ [Test]
+ public async Task BeginModalLoopCompatibilityScope_DisposedOnThreadWithoutWpf_DoesNotThrow()
+ {
+ // Act & Assert
+ await Assert.That(() => Task.Run(() => RevitContext.BeginModalLoopCompatibilityScope().Dispose())).ThrowsNothing();
+ }
}