A C# desktop IFC viewer on xeoifc.dll, with WPF

xeoIFC WPF is a desktop IFC viewer for Windows in C#. It has the features and the layout of the Qt viewer, in about 6,500 lines of C# and XAML with WPF on .NET 10, without a NuGet package. Its source code is open (MIT licence).

The IFC work is done by xeoifc.dll, the native library of xeoIFC, through the same plain C header that the Qt viewer uses. There is no .NET assembly of the library: one source file declares the functions of the header for P/Invoke. This page shows how a WPF window hosts the 3D view of the library, and the calls in C#.

1. The application

xeoIFC WPF with the Duplex model: tabs, 3D view cut by a section plane, tool buttons, model tree, properties and the section panel
xeoIFC WPF with the Duplex model, cut by a section plane. The tabs, the tree, the properties and the section panel are WPF controls; the 3D view is drawn by the library.

2. Download

From the releases page:

3. How a WPF window hosts the 3D view

WPF draws a window with its own renderer. The library renders with wgpu into a native window that you give it. WPF has a class for this case: an HwndHost element owns a native child window and keeps it on its place in the layout. The application creates the child window there and gives it to the library.

public sealed class ViewportHost : HwndHost
{
    protected override HandleRef BuildWindowCore(HandleRef hwndParent)
    {
        _hwnd = Win32.CreateViewportWindow(hwndParent.Handle);   // CreateWindowExW: WS_CHILD, no background brush
        return new HandleRef(this, _hwnd);
    }
    ...
}

// At the first WM_SIZE of the child window. The library draws into it with its own surface (sizes are physical pixels).
_viewer = Viewer.Create(_hwnd, width, height, out var error);    // xeoifc_viewer_create
CompositionTarget.Rendering += OnRendering;                       // every WPF frame: xeoifc_viewer_frame

Three details make it work:

protected override nint WndProc(nint hwnd, int msg, nint wParam, nint lParam, ref bool handled)
{
    int x = Win32.LoWord(lParam), y = Win32.HiWord(lParam);
    var keys = (int)wParam;
    switch (msg)
    {
        case Win32.WM_SIZE:
            var (w, h) = ((uint)(lParam & 0xFFFF), (uint)((lParam >> 16) & 0xFFFF));
            EnsureViewer(w, h);
            if (w > 0 && h > 0) _viewer?.Resize(w, h);               // xeoifc_viewer_resize
            Placed?.Invoke();
            break;
        case Win32.WM_LBUTTONDOWN:
            Press(Button.Left, x, y, (keys & Win32.MK_SHIFT) != 0);  // xeoifc_viewer_anchor_pick: rotate about this point
            break;
        case Win32.WM_MOUSEMOVE:
            Move(x, y);                                              // xeoifc_viewer_orbit or xeoifc_viewer_pan_to
            break;
        ...
    }
    return base.WndProc(hwnd, msg, wParam, lParam, ref handled);
}
<Window x:Class="XeoIfcWpf.ViewOverlay" ...
        WindowStyle="None" AllowsTransparency="True" Background="Transparent" ResizeMode="NoResize"
        ShowInTaskbar="False" ShowActivated="False" Focusable="False" ...>
// MainWindow: the overlay stays above its owner and follows the native window of the 3D view.
_overlay.Owner = this;
Viewport.Placed += GlueOverlay;

// ViewOverlay: physical pixels, so it is exact on every monitor scale.
public void Glue(nint viewport)
{
    if (_hwnd == 0 || viewport == 0 || !Win32.GetWindowRect(viewport, out var r))
    {
        return;
    }
    Win32.SetWindowPos(_hwnd, 0, r.Left, r.Top, r.Width, r.Height,
                       Win32.SWP_NOZORDER | Win32.SWP_NOACTIVATE | Win32.SWP_NOOWNERZORDER);
}

The overlay has the window style WS_EX_NOACTIVATE, so a click on a tool button leaves the main window active. The same pattern works for any renderer that takes a native window handle.

4. The library from C#

The library has a plain C interface: functions, opaque handles, and UTF-8 JSON strings in and out. Interop/Native.cs declares the 87 functions of the header with LibraryImport, and Interop/XeoIfc.cs wraps them in three classes that free what the library returns: Session, LoadJob and Viewer. The two files do not use WPF; you can copy them into your own .NET project.

internal static unsafe partial class Native
{
    private const string Lib = "xeoifc";

    [LibraryImport(Lib)] public static partial nint xeoifc_session_new();
    [LibraryImport(Lib, StringMarshalling = StringMarshalling.Utf8)]
    public static partial nint xeoifc_call(nint session, string requestJson);
    [LibraryImport(Lib, StringMarshalling = StringMarshalling.Utf8)]
    public static partial nint xeoifc_load_start(nint session, string path, string? optionsJson, XeoIfcLoadCallbacks* callbacks);
    [LibraryImport(Lib)] public static partial int xeoifc_job_poll(nint session, nint job, out nint statusJson);
    [LibraryImport(Lib)] public static partial int xeoifc_viewer_frame(nint viewer);
    ...
}

/// <summary>A string of the library as a .NET string; gives the memory back.</summary>
public static string? String(nint s)
{
    if (s == 0) return null;
    var text = Marshal.PtrToStringUTF8(s);
    Native.xeoifc_free_string(s);
    return text;
}

Load a file in the background

A load job reads and tessellates the file on its own thread inside the library, and calls back on that thread. The callbacks are static C# methods that only store the last progress. A DispatcherTimer polls the job every 30 ms on the GUI thread and gives the result to the renderer.

_self = GCHandle.Alloc(this);
var callbacks = new Native.XeoIfcLoadCallbacks
{
    User = (void*)GCHandle.ToIntPtr(_self),
    Progress = &OnProgress,
    Snapshot = snapshots ? &OnSnapshot : null,
};
_job = Native.xeoifc_load_start(session.Raw, path, options.ToJsonString(), &callbacks);

// The two callbacks run on the job thread. An exception must not cross into the DLL.
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static void OnProgress(void* user, byte* phase, uint done, uint total)
{
    try
    {
        if (GCHandle.FromIntPtr((nint)user).Target is LoadJob job)
        {
            Volatile.Write(ref job._progress, new ProgressNote(Marshal.PtrToStringUTF8((nint)phase) ?? "", done, total));
        }
    }
    catch { }
}
var code = Native.xeoifc_job_poll(_session.Raw, _job, out var status);   // 0 running, 1 done, 2 failed, 3 cancelled
...
case 1: Finished?.Invoke(Take.Object(status)); break;

// MainWindow, when the job has finished:
using (var pack = job.TakePack())                                         // the tessellated model, in the memory of the library
{
    scene = pack == null ? -1 : viewer.SetScene(pack, _hub.Models.Count == 0 ? 0 : 1, placement);
}
...
Tree.AddModel(model, tree, Parse(status["groupings"]));

Find elements

The document API is one function, xeoifc_call: a JSON request in, a JSON response out.

// A page holds 10,000 results at most: follow the cursor to the end.
string? cursor = null;
do
{
    var args = new JsonObject { ["text"] = text, ["includeRefs"] = true, ["limit"] = 10000 };   // text: name ~ "M_Fixed"
    if (cursor != null) args["cursor"] = cursor;
    var reply = _hub.Session.Call("query.select", args, model.DocId);
    ...
    var result = reply["result"];                                 // {"count":40,"refs":["#6293","#6412",..],..}
    foreach (var entry in result?["refs"] as JsonArray ?? [])
    {
        if (entry?.ToString() is ['#', .. var number] && uint.TryParse(number, out var entity))
            found.Add(new ElementRef(model.Scene, entity));
    }
    cursor = result?["nextCursor"]?.ToString();
}
while (!string.IsNullOrEmpty(cursor));
FindPage.Found += elements => Viewport.Viewer?.FitElements(AsSpan(elements));   // xeoifc_viewer_fit_elements

Section planes

// A plane is its unit normal and -dot(point, normal).
var values = _planes.SelectMany(p => new[] { p.Normal.X, p.Normal.Y, p.Normal.Z, -Vector3.Dot(p.Point, p.Normal) }).ToArray();
viewer.SetClipPlanes(values);                                     // xeoifc_viewer_set_clip_planes, at most six planes

// A 2D view straight at the section.
_viewport.Viewer?.ViewSection([plane.Point.X, plane.Point.Y, plane.Point.Z, plane.Normal.X, plane.Normal.Y, plane.Normal.Z]);

What the library can do, with load times: the Qt viewer page.

5. Build it yourself

You need 64-bit Windows 10 or 11 and the .NET 10 SDK. The project uses no NuGet packages. In the unpacked xeoifc-wpf-source.zip:

dotnet run -c Release -- model.ifc

The build copies sdk\lib\windows-x64\xeoifc.dll next to the exe. Visual Studio 2026 opens xeoifc-wpf.csproj directly. This command writes the application as one file, which runs on the .NET Desktop Runtime of the machine:

dotnet publish -c Release -p:PublishSingleFile=true -o publish

Linux and macOS are not supported, because WPF runs on Windows only. The Qt viewer runs on Linux.

6. Licence

xeoIFC WPF is open source under the MIT licence. You can use, change and distribute it, also in commercial products. The xeokit logo files in Resources/ and the logo shape in Styles.xaml are not covered by the MIT licence. The application uses no third-party packages, and .NET is not in the zips.

xeoifc.dll is not open source. It is licensed under the xeoIFC Testing License (sdk/LICENSE.txt in the source zip): you can use and redistribute it unmodified, in binary form and free of charge, solely for testing and evaluation. For commercial or production use, contact xeoFoundry. All functions work without a licence key. Without a key, each IFC file that the library writes gets one more element: an IfcBuildingElementProxy with the text "xeoIFC evaluation version" next to the model. A licence key from xeoFoundry removes it; enter it on the Settings tab.