diff --git a/src/ifcviewer/CMakeLists.txt b/src/ifcviewer/CMakeLists.txt
index ffc87e436a..fe84002203 100644
--- a/src/ifcviewer/CMakeLists.txt
+++ b/src/ifcviewer/CMakeLists.txt
@@ -145,6 +145,7 @@ set(IFCVIEWER_CORE_SOURCES
SidecarCache.cpp
StreamingLoader.cpp
StreamingThread.cpp
+ ViewportCore.cpp
)
set(IFCVIEWER_CORE_HEADERS
BufferPool.h
@@ -158,6 +159,8 @@ set(IFCVIEWER_CORE_HEADERS
StreamingLoader.h
StreamingThread.h
VertexQuantization.h
+ ViewportCore.h
+ ViewportHost.h
VisibilityState.h
)
list(TRANSFORM IFCVIEWER_CORE_SOURCES PREPEND "${CMAKE_CURRENT_SOURCE_DIR}/")
diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp
new file mode 100644
index 0000000000..19f06f6a33
--- /dev/null
+++ b/src/ifcviewer/ViewportCore.cpp
@@ -0,0 +1,23 @@
+/********************************************************************************
+ * *
+ * This file is part of IfcOpenShell. *
+ * *
+ * IfcOpenShell is free software: you can redistribute it and/or modify *
+ * it under the terms of the Lesser GNU General Public License as published by *
+ * the Free Software Foundation, either version 3.0 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * IfcOpenShell is distributed in the hope that it will be useful, *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
+ * Lesser GNU General Public License for more details. *
+ * *
+ * You should have received a copy of the Lesser GNU General Public License *
+ * along with this program. If not, see . *
+ * *
+ ********************************************************************************/
+
+#include "ViewportCore.h"
+
+ViewportCore::ViewportCore(ViewportHost* host) : host_(host) {}
+ViewportCore::~ViewportCore() = default;
diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h
new file mode 100644
index 0000000000..c526512149
--- /dev/null
+++ b/src/ifcviewer/ViewportCore.h
@@ -0,0 +1,51 @@
+/********************************************************************************
+ * *
+ * This file is part of IfcOpenShell. *
+ * *
+ * IfcOpenShell is free software: you can redistribute it and/or modify *
+ * it under the terms of the Lesser GNU General Public License as published by *
+ * the Free Software Foundation, either version 3.0 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * IfcOpenShell is distributed in the hope that it will be useful, *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
+ * Lesser GNU General Public License for more details. *
+ * *
+ * You should have received a copy of the Lesser GNU General Public License *
+ * along with this program. If not, see . *
+ * *
+ ********************************************************************************/
+
+#ifndef VIEWPORTCORE_H
+#define VIEWPORTCORE_H
+
+// Platform-agnostic render core. Owns the wgpu state + scene state +
+// per-frame render path. Talks to its embedder through ViewportHost
+// (window/canvas surface, scheduling, notifications) — has no Qt or
+// browser dependencies of its own.
+//
+// Empty for now: this header / TU is the placeholder for the
+// incremental Path-A refactor (#77-#86). Each subsequent commit moves
+// one subsystem out of ViewportWindow.cpp into here and updates the
+// public surface accordingly. Until that work lands, ViewportWindow
+// retains its render body; this class is just the target for the
+// move.
+
+#include "ViewportHost.h"
+
+class ViewportCore {
+public:
+ explicit ViewportCore(ViewportHost* host);
+ ~ViewportCore();
+
+ ViewportCore(const ViewportCore&) = delete;
+ ViewportCore& operator=(const ViewportCore&) = delete;
+
+ ViewportHost* host() const { return host_; }
+
+private:
+ ViewportHost* host_;
+};
+
+#endif // VIEWPORTCORE_H
diff --git a/src/ifcviewer/ViewportHost.h b/src/ifcviewer/ViewportHost.h
new file mode 100644
index 0000000000..8c33515936
--- /dev/null
+++ b/src/ifcviewer/ViewportHost.h
@@ -0,0 +1,101 @@
+/********************************************************************************
+ * *
+ * This file is part of IfcOpenShell. *
+ * *
+ * IfcOpenShell is free software: you can redistribute it and/or modify *
+ * it under the terms of the Lesser GNU General Public License as published by *
+ * the Free Software Foundation, either version 3.0 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * IfcOpenShell is distributed in the hope that it will be useful, *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
+ * Lesser GNU General Public License for more details. *
+ * *
+ * You should have received a copy of the Lesser GNU General Public License *
+ * along with this program. If not, see . *
+ * *
+ ********************************************************************************/
+
+#ifndef VIEWPORTHOST_H
+#define VIEWPORTHOST_H
+
+// Abstraction layer between the platform-agnostic ViewportCore (wgpu
+// state, scene state, render path, cull, input) and whichever host owns
+// the native window / canvas / event loop. On desktop the host is a
+// QWindow-derived ViewportWindow; on web it'll be a small canvas-bound
+// shell in src/ifcviewer-web. The host implements this interface so
+// ViewportCore can call back into it for:
+//
+// - surface creation (only the host knows its native handle)
+// - framebuffer geometry (size, DPR)
+// - scheduling the next render tick (QWindow::requestUpdate vs RAF)
+// - quitting the process
+// - bubbling user-facing notifications (selection changed, picked
+// object, tool mode, frame stats) up to whatever signal/callback
+// mechanism the host uses
+//
+// All host-side notifications have default empty implementations so
+// hosts only override the ones they care about — minimal-boilerplate
+// hosts (a screenshot CI runner, the web spike before UI lands) can
+// ignore the rest. Inverting control this way keeps ViewportCore from
+// having to know whether it's running inside Qt's event loop or
+// Emscripten's main-loop.
+
+#include
+
+#include
+
+class ViewportHost {
+public:
+ virtual ~ViewportHost() = default;
+
+ // Surface creation. The host wraps its native window/canvas in the
+ // appropriate WGPUSurfaceDescriptor extension struct (Xlib, HWND,
+ // CAMetalLayer, EmscriptenCanvas) and returns the resulting surface.
+ // Returns nullptr if the surface can't be created — caller logs and
+ // aborts init.
+ virtual WGPUSurface createSurface(WGPUInstance instance) = 0;
+
+ // Framebuffer geometry. width/height are in device pixels (post-
+ // multiplied by devicePixelRatio); the depth attachment and surface
+ // configuration both consume these.
+ virtual void framebufferSize(int& width_px, int& height_px) const = 0;
+ // Device pixel ratio (CSS-px → device-px). Renamed from
+ // `devicePixelRatio` because QWindow already exposes that name; an
+ // override conflict between qreal (QWindow) and float (a hypothetical
+ // ViewportHost::devicePixelRatio) would surprise readers. `dpr` is
+ // unambiguous.
+ virtual float dpr() const = 0;
+
+ // Ask the host to schedule another render tick. On Qt this is
+ // QWindow::requestUpdate (coalesced + DPR-aware); on web this is a
+ // requestAnimationFrame schedule. Idempotent within a tick.
+ virtual void requestFrame() = 0;
+
+ // Ask the host to terminate the process / close the tab. Used by
+ // the --screenshot / --benchmark exits, and by the future
+ // window-close path.
+ virtual void quit() = 0;
+
+ // ---- Notifications ----------------------------------------------------
+ //
+ // Each callback corresponds to something ViewportWindow currently
+ // exposes as a Q_SIGNAL. Default empty body so non-Qt hosts can
+ // selectively implement; the desktop ViewportWindow override
+ // forwards each to emit objectPicked() / emit toolModeChanged() etc.
+
+ virtual void onObjectPicked(uint32_t /*object_id*/) {}
+ virtual void onSurfacePickedInTool(int /*x_px*/, int /*y_px*/,
+ int /*modifiers*/) {}
+ virtual void onToolModeChanged(int /*tool_mode*/) {}
+ virtual void onToolBackspacePressed() {}
+ // FrameStats is a Qt-using struct declared inside ViewportWindow
+ // today; once the move to ViewportCore happens it'll come back here
+ // as a plain POD. For B4 the desktop ViewportWindow keeps the
+ // existing typed signal and this is just the placeholder.
+ virtual void onFrameStats(double /*frame_ms*/, double /*cull_ms*/,
+ uint32_t /*draws*/, uint32_t /*tris*/) {}
+};
+
+#endif // VIEWPORTHOST_H
diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp
index 0b0e19f759..333eae2034 100644
--- a/src/ifcviewer/ViewportWindow.cpp
+++ b/src/ifcviewer/ViewportWindow.cpp
@@ -25,6 +25,7 @@
#include "StreamingLoader.h"
#include "VertexQuantization.h"
+#include
#include
#include
#include
@@ -628,6 +629,57 @@ ViewportWindow::~ViewportWindow() {
shutdown();
}
+// ---- ViewportHost overrides ------------------------------------------------
+//
+// Scaffolding for Path-A. ViewportCore is empty today, so these don't
+// yet have callers; the abstract methods exist only to define the
+// boundary that subsequent commits will rely on. Each notification
+// forwards to the existing Q_SIGNAL so bonsai-side consumers see no
+// change.
+
+WGPUSurface ViewportWindow::createSurface(WGPUInstance /*instance*/) {
+ // initWgpu() still drives surface creation through the private
+ // no-arg createSurface() helper that populates surface_. Once
+ // ViewportCore takes over the wgpu init flow it'll call this
+ // override instead and the private helper goes away; for now the
+ // override is a getter.
+ return surface_;
+}
+
+void ViewportWindow::framebufferSize(int& width_px, int& height_px) const {
+ const float r = float(QWindow::devicePixelRatio());
+ width_px = int(QWindow::width() * r);
+ height_px = int(QWindow::height() * r);
+}
+
+float ViewportWindow::dpr() const {
+ return float(QWindow::devicePixelRatio());
+}
+
+void ViewportWindow::requestFrame() {
+ requestUpdate();
+}
+
+void ViewportWindow::quit() {
+ QCoreApplication::quit();
+}
+
+void ViewportWindow::onObjectPicked(uint32_t object_id) {
+ emit objectPicked(object_id);
+}
+
+void ViewportWindow::onSurfacePickedInTool(int x_px, int y_px, int modifiers) {
+ emit surfacePickedInTool(x_px, y_px, modifiers);
+}
+
+void ViewportWindow::onToolModeChanged(int tool_mode) {
+ emit toolModeChanged(static_cast(tool_mode));
+}
+
+void ViewportWindow::onToolBackspacePressed() {
+ emit toolBackspacePressed();
+}
+
void ViewportWindow::setBackgroundColor(const QColor& color) {
background_color_ = color;
if (isExposed()) requestUpdate();
diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h
index 85589e623c..0bb1d11b74 100644
--- a/src/ifcviewer/ViewportWindow.h
+++ b/src/ifcviewer/ViewportWindow.h
@@ -47,6 +47,8 @@
#include "OverlayRenderer.h"
#include "SelectionState.h"
#include "StreamingThread.h"
+#include "ViewportCore.h"
+#include "ViewportHost.h"
#include "VisibilityState.h"
// Stage-2 wgpu viewport: opens a native QWindow, brings up a wgpu instance/
@@ -57,12 +59,37 @@
//
// Mirrors the lifecycle shape of the GL ViewportWindow so subsequent stages
// can grow this into a full IFC renderer without restructuring the host.
-class ViewportWindow : public QWindow {
+//
+// Also implements ViewportHost: as the Path-A refactor moves rendering
+// state out into ViewportCore, this class plays the embedder role
+// (provides the wgpu surface, schedules frames, forwards notifications
+// to Q_SIGNALS). The web target's host is the analog on the Emscripten
+// side. Today most state still lives here; the override implementations
+// at the bottom of the class are the bridge for whatever has already
+// moved.
+class ViewportWindow : public QWindow, public ViewportHost {
Q_OBJECT
public:
explicit ViewportWindow(QWindow* parent = nullptr);
~ViewportWindow();
+ // --- ViewportHost ----------------------------------------------------
+ //
+ // Implementations live in ViewportWindow.cpp alongside the
+ // platform-specific surface code so the Qt + native window-handle
+ // bits stay co-located. Notification overrides (onObjectPicked
+ // etc.) forward to the existing Q_SIGNALS so bonsai-side consumers
+ // see no change.
+ WGPUSurface createSurface(WGPUInstance instance) override;
+ void framebufferSize(int& width_px, int& height_px) const override;
+ float dpr() const override;
+ void requestFrame() override;
+ void quit() override;
+ void onObjectPicked(uint32_t object_id) override;
+ void onSurfacePickedInTool(int x_px, int y_px, int modifiers) override;
+ void onToolModeChanged(int tool_mode) override;
+ void onToolBackspacePressed() override;
+
void setBackgroundColor(const QColor& color);
// Queue a sidecar path to be loaded after wgpu init completes. Safe to