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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 31 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.0.0"/>
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.4"/>
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing" Version="1.1.4"/>

<!-- Interop -->
<PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.360"/>
</ItemGroup>

<!-- Development dependencies -->
Expand Down
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using Windows.Win32;
using Windows.Win32.Foundation;

namespace Nice3point.Revit.Toolkit.Internal;

/// <summary>
/// Provides access to the window WPF creates on each UI thread to receive the notifications of its render thread.
/// </summary>
/// <remarks>
/// The window title and the registered message name are internal to WPF and have the same values in .NET Framework and .NET.
/// </remarks>
internal static class MediaContextNotificationWindow
{
private const string WindowName = "MediaContextNotificationWindow";
private static readonly uint ChannelNotifyMessage = PInvoke.RegisterWindowMessage("MilChannelNotify");

[ThreadStatic] private static HWND _window;

/// <summary>
/// Posts the channel notification to the notification window of the current thread.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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);
}
}

/// <summary>
/// Finds the notification window of the current thread among the top-level windows of the desktop.
/// </summary>
/// <returns>The window handle, or a null handle when the thread has no notification window.</returns>
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;
}
}
}
}
5 changes: 5 additions & 0 deletions source/Nice3point.Revit.Toolkit/NativeMethods.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
FindWindowEx
GetCurrentThreadId
GetWindowThreadProcessId
PostMessage
RegisterWindowMessage
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<DefineConstants>$(DefineConstants);JETBRAINS_ANNOTATIONS</DefineConstants>
<Configurations>Debug.R20;Debug.R21;Debug.R22;Debug.R23;Debug.R24;Debug.R25;Debug.R26;Debug.R27</Configurations>
<Configurations>$(Configurations);Release.R20;Release.R21;Release.R22;Release.R23;Release.R24;Release.R25;Release.R26;Release.R27</Configurations>
<NoWarn>$(NoWarn);PInvoke009</NoWarn>
</PropertyGroup>

<PropertyGroup>
Expand Down Expand Up @@ -36,6 +37,7 @@
<ItemGroup>
<PackageReference Include="Nice3point.Revit.Api.RevitAPI" PrivateAssets="all"/>
<PackageReference Include="Nice3point.Revit.Api.RevitAPIUI" PrivateAssets="all"/>
<PackageReference Include="Microsoft.Windows.CsWin32" PrivateAssets="all"/>
</ItemGroup>

<ItemGroup>
Expand Down
51 changes: 49 additions & 2 deletions source/Nice3point.Revit.Toolkit/RevitContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -353,6 +351,39 @@ public static IDisposable BeginDialogSuppressionScope(Action<DialogBoxShowingEve
return new DialogSuppressionScope();
}

/// <summary>
/// Begins a scope that redraws the Revit user interface after the API upgrades a family from an earlier Revit version.
/// </summary>
/// <returns>A disposable scope. Call Dispose or use 'using' statement to redraw the user interface.</returns>
/// <remarks>
/// <para>
/// <see cref="Autodesk.Revit.DB.Document.LoadFamily(string)" />, <see cref="Autodesk.Revit.ApplicationServices.Application.NewFamilyDocument(string)" />,
/// and <see cref="Autodesk.Revit.ApplicationServices.Application.OpenDocumentFile(string)" /> 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.
/// </para>
/// <para>
/// Disposing the scope posts the notification again, and the user interface redraws.
/// Begin the scope on the Revit thread.
/// </para>
/// This method is thread-safe.
/// </remarks>
/// <example>
/// <code>
/// using (RevitContext.BeginModalLoopCompatibilityScope())
/// {
/// document.LoadFamily(fileName, out var family);
/// }
/// // The user interface redraws
/// </code>
/// </example>
public static IDisposable BeginModalLoopCompatibilityScope()
{
return ModalLoopCompatibilityScope.Instance;
}

internal static IDisposable BeginApiContextScope()
{
return new ApiContextScope(IncrementConstructorPointer, IncrementDestructorPointer);
Expand Down Expand Up @@ -431,6 +462,22 @@ public void Dispose()
}
}

/// <summary>
/// Represents a scope that posts the frame notification of WPF to the current thread on every dispose.
/// </summary>
/// <remarks>
/// WPF returns at once on a repeated notification, and every call of <see cref="BeginModalLoopCompatibilityScope" /> returns the same instance.
/// </remarks>
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);

Expand Down
Loading