wgpu: bonsai-ready API surface + direct-IFC ingestion + streaming-always

Make the wgpu viewport ready for bonsai's verb actions, federation
refresh, and tool routing — i.e. callable from an outside host, not
just from the minimal viewer's own hotkeys.

Surface additions on WgpuViewportWindow:
- Qt signals: objectPicked, frameStatsUpdated, surfacePickedInTool,
  toolModeChanged, toolBackspacePressed.
- FrameStats struct + rolling 60-sample frame-time window for the
  fps field; emit at end of render() so external listeners see fresh
  numbers in the same tick.
- InstanceLookup struct + findInstance(object_id, ...) const for the
  measurement tools' O(1) object → (model, mesh, placement) resolve.
- Federation hooks (setFederatedFalseOrigin / setModelCoordinateOperation
  / setModelTransformation) + per-model coordinate_operation_meters /
  model_transformation_meters fields on WgpuModelGpuData. Implement
  composeInstanceFromPlacement + recomposeAndUploadModel so each setter
  actually applies — model recompose runs in double, casts to float for
  the GPU upload, and refreshes per-chunk world AABBs. meshLocalToGlobal
  now composes coordinate_operation · placement properly.
- showModel / hideModel for per-model visibility, plus element-level
  verbs (hideSelectedElements / isolateSelectedElements / showAllElements
  / invertElementVisibility) and setSelectedObjectId / cameraState() /
  projectionOrtho() / toggle{Area,Length,Volume}Tool wrappers.
- Section-cutting methods (toggleSectionTool / clearSectionPlanes /
  sectionToolActive) moved to public so bonsai's Commands.cpp can call.
- QVector3D overload of computeObjectAabb to match the GL signature.
- ToolMode::None → ToolMode::NoTool (X11 macro collision avoidance).

Direct-IFC ingestion (A-path), mirrors the GL streaming push API:
- uploadMeshChunk / uploadInstanceChunk stage into pending_direct_loads_
  using the same vertex quantisation as SidecarBuilder so direct-load
  and sidecar-load produce byte-identical buffers.
- finalizeModel wraps the staged data in a file-less StreamingSidecar,
  routes through the existing applyCachedModel chunk planner, then
  gathers per-chunk vertex+index bytes from memory and feeds
  applyStreamedChunk synchronously. Every chunk lands is_resident=true
  immediately (no disk I/O to defer).

Streaming collapse:
- Delete the applyCachedModel(SidecarData) full-load path entirely.
- Rename applyCachedModelStreaming → applyCachedModel; loadSidecar
  always uses the metadata-only reader. Drop the --streaming CLI flag
  from IfcViewerWgpuMinimal and the streaming_enabled_ field.

WgpuSelectionState::ids() → selectionIds() so bonsai's
`viewport_->selection().selectionIds()` compiles unchanged.

Eigen3 added as a public dep of IfcViewerWgpu for the federation
matrices.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-06-01 17:36:02 +10:00
parent e80897886d
commit 9c067d1d0e
6 changed files with 731 additions and 442 deletions
-5
View File
@@ -54,17 +54,12 @@ int main(int argc, char* argv[]) {
"Request the WebGPU mandatory floor limits (128MB max storage binding) "
"instead of the adapter's actual max. Use to verify scenes fit through "
"browser constraints."});
parser.addOption({"streaming",
"Enable streaming sidecar load. Reads metadata-only at load time; "
"vertex chunks are deferred and loaded on demand as they become "
"frustum-visible. Required for scenes that exceed GPU memory."});
parser.process(app);
auto* viewport = new WgpuViewportWindow;
viewport->resize(1280, 800);
if (parser.isSet("no-hiz")) viewport->hiz_enabled_ = false;
if (parser.isSet("web-limits")) viewport->web_limits_ = true;
if (parser.isSet("streaming")) viewport->streaming_enabled_ = true;
QWidget* container = QWidget::createWindowContainer(viewport);
container->setMinimumSize(320, 240);
+5
View File
@@ -22,6 +22,10 @@ message("Running CMakeLists.txt in /src/ifcviewer-wgpu")
set(QT_VERSION 6 CACHE STRING "Qt version")
find_package(Qt${QT_VERSION} COMPONENTS Core Gui REQUIRED PATHS ${QT_DIR})
# Eigen3 for the federation hooks (setModelTransformation etc.) and the
# per-model coordinate-operation matrices stored on WgpuModelGpuData.
find_package(Eigen3 REQUIRED)
# wgpu-native fetched as a pre-built binary release from upstream.
# Pin the version with WGPU_NATIVE_VERSION; bump to pull a newer release.
set(WGPU_NATIVE_VERSION "v29.0.0.0" CACHE STRING "wgpu-native release tag")
@@ -127,6 +131,7 @@ target_link_libraries(IfcViewerWgpu PUBLIC
Qt${QT_VERSION}::Core
Qt${QT_VERSION}::Gui
wgpu_native
Eigen3::Eigen
)
# Qt platform-handle access (QNativeInterface::QX11Application etc.) is in
+11
View File
@@ -22,6 +22,8 @@
#include <webgpu/webgpu.h>
#include <Eigen/Dense>
#include <cstddef>
#include <cstdint>
#include <limits>
@@ -341,6 +343,15 @@ struct WgpuModelGpuData {
// is gone; cull iterates m.chunks instead.
bool hidden = false;
// Per-model federation matrices in metres. Default identity → no
// per-model contribution to the composed transform. See bonsai's
// Federation.h for the full pipeline composition order. Stored
// here so setModelCoordinateOperation / setModelTransformation
// have somewhere to land; the recompose-and-reupload pass that
// would actually apply them is deferred.
Eigen::Matrix4d coordinate_operation_meters = Eigen::Matrix4d::Identity();
Eigen::Matrix4d model_transformation_meters = Eigen::Matrix4d::Identity();
};
// Release every wgpu handle in `m` (including per-chunk and per-model pool
+3 -1
View File
@@ -77,7 +77,9 @@ public:
bool contains(uint32_t id) const { return ids_.count(id) > 0; }
uint32_t activeId() const { return active_; }
const std::unordered_set<uint32_t>& ids() const { return ids_; }
// Named selectionIds() rather than ids() so bonsai's
// `viewport_->selection().selectionIds()` compiles unchanged.
const std::unordered_set<uint32_t>& selectionIds() const { return ids_; }
size_t count() const { return ids_.size(); }
bool dirty() const { return dirty_; }
File diff suppressed because it is too large Load Diff
+178 -26
View File
@@ -31,6 +31,8 @@
#include <webgpu/webgpu.h>
#include <Eigen/Dense>
#include <atomic>
#include <cstdint>
#include <deque>
@@ -68,29 +70,58 @@ public:
// normalises stem → .ifcview).
void queueLoadSidecar(const QString& path);
// Synchronous load + GPU upload. Requires wgpu init to have completed
// (i.e. the window has been exposed at least once). Returns the
// assigned model_id, or 0 on failure. Routes through the streaming
// loader when streaming_enabled_, else the legacy full-load path.
// Synchronous metadata load + GPU upload. Requires wgpu init to have
// completed (i.e. the window has been exposed at least once). Returns
// the assigned model_id, or 0 on failure. Reads metadata only (mesh
// dict + instance dict + georef); per-chunk vertex / index bytes are
// read on demand by the per-frame loader as chunks become visible.
uint32_t loadSidecar(const QString& path);
// Restore a finalised model from a SidecarData struct: allocate wgpu
// buffers, upload vertex/index/mesh/instance bytes, register in
// models_gpu_. Replaces any existing state for model_id.
void applyCachedModel(uint32_t model_id, SidecarData data);
// Allocates per-chunk small buffers and the model-shared mesh /
// instance storage upfront, but leaves each chunk's pool ranges
// unclaimed and is_resident=false. The per-frame loader
// (driveStreamingLoads) sub-allocates the chunk's vertex + index
// ranges from pool_ on demand as cull flags them visible.
void applyCachedModel(uint32_t model_id,
struct StreamingSidecar metadata);
// Streaming variant: takes a StreamingSidecar (metadata only — no
// vertex / index bytes). Allocates per-chunk small buffers and the
// model-shared mesh / instance storage upfront, but leaves each
// chunk's pool ranges unclaimed and is_resident=false. The per-frame
// loader (driveStreamingLoads) sub-allocates the chunk's vertex +
// index ranges from pool_ on demand as cull flags them visible.
void applyCachedModelStreaming(uint32_t model_id,
struct StreamingSidecar metadata);
// Direct-IFC ingestion (mirrors GL ViewportWindow). The host (typically
// a GeometryStreamer running on a worker) calls uploadMeshChunk +
// uploadInstanceChunk once per representation / placement as the IFC
// triangulates; finalizeModel commits when the iterator finishes.
// Staged in CPU memory; finalizeModel runs the chunk planner over the
// staged data, allocates pool slices, and uploads — same render path
// as a sidecar load. Bytes are gathered from memory (no disk I/O), so
// every chunk lands `is_resident=true` immediately. The streamer's
// model_id is passed through unchanged; the viewport's globally-unique
// object_id rebasing happens at finalize time.
void uploadMeshChunk(const struct MeshChunk& chunk);
void uploadInstanceChunk(const struct InstanceChunk& chunk);
void finalizeModel(uint32_t model_id);
void removeModel(uint32_t model_id);
void resetScene();
// Model-level visibility. Mirrors the GL ViewportWindow API — flips
// WgpuModelGpuData::hidden, which every render/pick/cull pass already
// consults. requestUpdate() so the change is visible immediately.
void hideModel(uint32_t model_id);
void showModel(uint32_t model_id);
// Federation pipeline: composed instance transform =
// FederatedFalseOrigin · ModelTransformation · CoordinateOperation
// · placement_transformation
// Wgpu does not yet recompose instances against these matrices —
// composeInstanceFromPlacement is still placement-only — so the
// setters store the input and post requestUpdate(). Bonsai-side
// integration compiles against these signatures; visual georef parity
// arrives with the recompose+SSBO-rewrite work tracked separately.
void setFederatedFalseOrigin(const Eigen::Matrix4d& matrix_meters);
void setModelCoordinateOperation(uint32_t model_id,
const Eigen::Matrix4d& matrix_meters);
void setModelTransformation(uint32_t model_id,
const Eigen::Matrix4d& matrix_meters);
size_t modelCount() const { return models_gpu_.size(); }
// Frame the union of all loaded models' world AABBs. No-op on empty
@@ -113,8 +144,39 @@ public:
void setStandardView(float yaw_deg, float pitch_deg);
void focusOnSelectedObject();
void toggleProjection();
bool projectionOrtho() const { return projection_ortho_; }
QString cameraString() const;
// Snapshot of the orbit camera. Mirrors GL ViewportWindow::CameraState
// so bonsai's "save view" / "restore view" commands port unchanged.
struct CameraState {
QVector3D target;
float distance;
float yaw; // degrees
float pitch; // degrees
};
CameraState cameraState() const;
// Tool toggles: flip between NoTool and the named tool. Wrappers
// around setToolMode so bonsai's verb actions stay terse.
void toggleAreaTool();
void toggleLengthTool();
void toggleVolumeTool();
// Element-level visibility verbs. The fine-grained per-id mutations
// go through visibility_; these high-level methods are what bonsai's
// Commands.cpp calls. Hidden elements are dropped from cull (no draw,
// no depth, no pick).
void hideSelectedElements();
void isolateSelectedElements();
void showAllElements();
void invertElementVisibility();
// Replace the selection with {id} (or clear if id == 0). Used by
// SessionState mirroring and by project commands that drop selection
// on model removal. Wrapper around selection_.replace / clear.
void setSelectedObjectId(uint32_t id);
// FPS / fly mode. enterFpsMode swaps the orbit camera for a WASD/QE
// free-fly camera (hotkey: Shift+F). exitFpsMode restores the orbit
// pivot and reveals the cursor. Mouse-look uses raw deltas (cursor is
@@ -135,6 +197,13 @@ private:
// Build the camera AABB for a single object across all loaded models.
bool computeObjectAabb(uint32_t object_id,
float mn[3], float mx[3]) const;
public:
// QVector3D overload — matches GL ViewportWindow::computeObjectAabb so
// bonsai's volume readout / focus callers compile unchanged. Just a
// thin wrapper around the float[3] version.
bool computeObjectAabb(uint32_t object_id,
QVector3D& mn, QVector3D& mx) const;
private:
// Re-aim the orbit camera so the bounding sphere of [mn, mx] fits.
void frameAabb(const float mn[3], const float mx[3], float padding);
// Resolve nav_preset_ env var to orbit/pan bindings.
@@ -268,6 +337,7 @@ private:
// clipped to the framebuffer by the caller.
std::vector<uint32_t> picksInRect(int x, int y, int w, int h);
public:
// Section-cutting tool. Mirrors the GL ViewportWindow API:
// K toggle (sectionToolActive / toggleSectionTool)
// Shift+K clearSectionPlanes
@@ -282,8 +352,6 @@ private:
void removeSectionPlane(int index);
void clearSectionPlanes();
int sectionPlaneCount() const { return int(section_planes_.size()); }
public:
// Overlay primitives. Mirror GL ViewportWindow so the Measurement +
// dimension tools can target either backend through one API.
// Empty inputs clears the corresponding set.
@@ -311,6 +379,23 @@ public:
bool readbackMeshTriangles(uint32_t model_id, uint32_t mesh_id,
MeshTriangles& out) const;
// Pure CPU lookup: object_id → owning model + mesh + raw placement
// matrix (column-major, pre-CoordinateOperation / FederatedFalseOrigin
// / ModelTransformation). Mirrors GL ViewportWindow::InstanceLookup
// so Measurement.cpp ports unchanged.
struct InstanceLookup {
uint32_t model_id = 0;
uint32_t mesh_id = 0;
double placement_transformation[16]{};
};
bool findInstance(uint32_t object_id, InstanceLookup& out) const;
// Selection accessor. Exposed for callers (bonsai's volume readout)
// that need to read selectionIds() / activeObjectId(). Mutation goes
// through the existing setSelection / pick paths.
WgpuSelectionState& selection() { return selection_; }
const WgpuSelectionState& selection() const { return selection_; }
// Pick + resolve to mesh-local space. Runs pickSurfaceAt to get the
// world-space hit, then inverts the instance's composed transform
// to express the hit in the mesh's own coordinates — what
@@ -359,9 +444,45 @@ public:
// NoTool (not None) because X11/X.h #define's None as 0L; including
// it transitively via Qt's xcb back-end breaks any enum named None.
enum class ToolMode { NoTool, Volume, Area, Length };
Q_ENUM(ToolMode)
ToolMode toolMode() const { return tool_mode_; }
void setToolMode(ToolMode m);
// Per-frame snapshot of cull / scene stats, emitted via
// frameStatsUpdated at the end of each render(). Mirrors GL
// ViewportWindow::FrameStats so bonsai's status bar binding ports
// unchanged. gl_draw_calls is the wgpu draw-call count (named for
// continuity with the GL field bonsai's status format string uses).
struct FrameStats {
float fps;
float frame_time_ms;
uint32_t total_objects;
uint32_t visible_objects;
uint32_t total_triangles;
uint32_t visible_triangles;
uint32_t unique_meshes;
uint32_t gl_draw_calls; // wgpu draw-call count; name kept for bonsai parity
uint32_t indirect_sub_draws; // sub-draws packed into the chunk-indirect lists
};
signals:
// Selection moved by a pick / marquee. Emitted with the active id
// (0 = miss). Bonsai mirrors this into SessionState.
void objectPicked(uint32_t object_id);
void frameStatsUpdated(const WgpuViewportWindow::FrameStats& stats);
// Emitted instead of objectPicked when an Area / Length tool is
// active. The host branches on toolMode() and calls
// pickMeshLocalAt(x, y, ...) for hit details. Coordinates are in
// physical pixels (post-DPR).
void surfacePickedInTool(int x, int y, int modifiers);
// Emitted whenever the active tool changes (incl. on→off).
void toolModeChanged(WgpuViewportWindow::ToolMode mode);
// Backspace/Delete pressed while a tool is active. Length tool's
// remove-last-point; other tools may ignore.
void toolBackspacePressed();
public:
// Sum of mesh-local volumes (m³) of every instance whose object_id
// is in `object_ids`. Each instance is scaled by |det(placement_3x3)|
// to pick up mapped-item scale/mirror; signed-tetrahedra absolute
@@ -442,6 +563,22 @@ private:
// thread-safe).
void cullModelCpuUpload(WgpuModelGpuData& m);
// Compose one instance's `transform` (float[16] column-major) from
// FederatedFalseOrigin · ModelTransformation · CoordinateOperation
// · placement_transformation
// and recompute its world AABB from the mesh's local AABB. Maths runs
// in double; the cast to float happens last so large IFC placements
// get cancelled by the federation false origin before precision is
// narrowed. Mirrors GL ViewportWindow::composeInstanceFromPlacement.
void composeInstanceFromPlacement(InstanceCpu& inst,
const WgpuModelGpuData& m) const;
// Walk every instance of `model_id`, recompose its transform from the
// current federation matrices, refresh per-chunk world AABBs, and
// re-upload InstanceGpu[] into m.instance_storage. No-op if the model
// is unknown, has no instances, or wgpu init hasn't completed.
void recomposeAndUploadModel(uint32_t model_id);
bool wgpu_initialized_ = false;
bool surface_configured_ = false;
int configured_w_ = 0;
@@ -757,14 +894,6 @@ public:
// scene fits through the constraints a browser will impose.
bool web_limits_ = false;
// Streaming load (task #16). When enabled, queueLoadSidecar routes
// through the metadata-only reader: mesh dict + instance dict + georef
// load immediately; per-chunk vertex bytes are read + uploaded on
// demand by the per-frame loader as chunks become frustum-visible.
// Default OFF so existing behaviour (synchronous full load) is
// preserved; --streaming opts in.
bool streaming_enabled_ = false;
// Monotonic frame counter, bumped at the top of driveStreamingLoads.
// Used as the LRU key for chunk eviction.
uint64_t streaming_frame_idx_ = 0;
@@ -827,6 +956,15 @@ private:
// Sidecar paths queued before init completes.
std::deque<QString> pending_sidecars_;
// Direct-IFC staging buffers, keyed by streamer model_id. Populated
// by uploadMeshChunk / uploadInstanceChunk; consumed and cleared by
// finalizeModel. Shape matches SidecarData so the same chunk-planner
// + apply flow services both sidecar and direct-IFC loads. Held by
// unique_ptr so emplace / erase don't copy the (potentially huge)
// vertex byte vector when the map rehashes.
std::unordered_map<uint32_t, std::unique_ptr<SidecarData>>
pending_direct_loads_;
// Set after the first model load triggers a viewAll(); prevents
// subsequent loads from snapping the camera away from where the
// user pointed it.
@@ -898,6 +1036,20 @@ private:
// Increments every render() and prints stats every N frames.
int interactive_frame_count_ = 0;
// Rolling 60-sample frame-time window for the smoothed fps emitted
// via frameStatsUpdated. Index wraps; sum kept incrementally to
// avoid a per-frame reduction.
static constexpr int FRAME_TIME_WINDOW = 60;
double frame_time_ms_window_[FRAME_TIME_WINDOW] = {};
int frame_time_ms_count_ = 0;
int frame_time_ms_head_ = 0;
double frame_time_ms_sum_ = 0.0;
// FederatedFalseOrigin matrix, in metres. Default identity. Stored
// but not yet applied to per-instance composed transforms — the
// recompose pass arrives with the federation-load OOM work.
Eigen::Matrix4d federated_false_origin_meters_ = Eigen::Matrix4d::Identity();
// Per-frame LOD selection counts, mutated from cullModelCpuCompute
// and reset after the [frame] heartbeat prints them. Keeps an eye
// on whether LOD1 is actually firing on real scenes — early-days