From 7029feca0aede7280443a61c46ee3911f1fa13a9 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 10 May 2026 08:45:08 +1000 Subject: [PATCH] ifcviewer-full: volume tool with HUD + per-object labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the Area tool's display: HUD shows total volume + object count, each selected object gets a label at its world-AABB centroid showing its individual volume. Gated behind ToolMode::Volume (Ctrl+Shift+V) so it stays out of the way until invoked. Volume is a passive tool — selection works as in None (multi-select, modifier toggle, box-select all keep working). Area / Length still intercept clicks through surfacePickedInTool. Adds volumesPerObject() reusing the same mesh-cached readback path as volumeOfObjects, so the per-object split costs no extra GL readbacks. computeObjectAabb is promoted to public for the centroid lookup. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-full/MainWindow.cpp | 58 ++++++++++++++++++++++++++---- src/ifcviewer-full/MainWindow.h | 4 +++ src/ifcviewer-full/Measurement.cpp | 35 ++++++++++++++++++ src/ifcviewer-full/Measurement.h | 9 +++++ src/ifcviewer/ViewportWindow.cpp | 18 +++++++--- src/ifcviewer/ViewportWindow.h | 16 ++++++--- 6 files changed, 124 insertions(+), 16 deletions(-) diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp index 252278dced..930f51f834 100644 --- a/src/ifcviewer-full/MainWindow.cpp +++ b/src/ifcviewer-full/MainWindow.cpp @@ -238,8 +238,17 @@ void MainWindow::setupUi() { viewport_->setHudText("Length tool: click first point"); status_label_->setText("Length tool: LMB add point, Backspace remove last, Esc exits"); break; + case ViewportWindow::ToolMode::Volume: + // Volume tool is passive — selection works as in None. The + // readout helper writes both HUD and per-object labels for + // whatever's currently selected, then keeps them in sync as + // the selection changes. + status_label_->setText("Volume tool: click / box-select objects, Esc exits"); + updateVolumeReadout(); + break; case ViewportWindow::ToolMode::None: viewport_->setHudText(QString()); + viewport_->setOverlayLabels({}); status_label_->setText("Ready"); break; } @@ -332,6 +341,9 @@ void MainWindow::setupMenus() { view_menu->addAction("Measure &Length", this, [this]() { viewport_->toggleLengthTool(); }, QKeySequence("Ctrl+Shift+L")); + view_menu->addAction("Measure &Volume", this, [this]() { + viewport_->toggleVolumeTool(); + }, QKeySequence("Ctrl+Shift+V")); view_menu->addSeparator(); view_menu->addAction("Set &Home View", this, &MainWindow::onSetHomeView); view_menu->addAction("&Go to Home View", this, &MainWindow::onGoHomeView); @@ -929,15 +941,47 @@ void MainWindow::onObjectPicked(uint32_t object_id) { } populateProperties(object_id); + updateVolumeReadout(); +} - // Volume readout: report for the full selection so multi-select - // matches the highlighted set. - const auto& selection = viewport_->selection().selectionIds(); - if (!selection.empty()) { - std::vector ids(selection.begin(), selection.end()); - const double v = volumeOfObjects(*viewport_, ids); - qInfo("Volume of %zu selected object(s): %.6f m^3", ids.size(), v); +void MainWindow::updateVolumeReadout() { + // Volume readout only renders while the volume tool is active — + // matches Area/Length, which are also gated behind their own tool + // mode. Other modes own the HUD + overlay labels for their + // lifetime, so we stay quiet here. + if (viewport_->toolMode() != ViewportWindow::ToolMode::Volume) return; + + const auto& sel = viewport_->selection().selectionIds(); + if (sel.empty()) { + viewport_->setHudText(QString()); + viewport_->setOverlayLabels({}); + return; } + + std::vector ids(sel.begin(), sel.end()); + const auto per_obj = volumesPerObject(*viewport_, ids); + + double total = 0.0; + std::vector labels; + labels.reserve(per_obj.size()); + for (const auto& [oid, v] : per_obj) { + total += v; + QVector3D mn, mx; + if (!viewport_->computeObjectAabb(oid, mn, mx)) continue; + OverlayRenderer::Label lbl; + const QVector3D c = (mn + mx) * 0.5f; + lbl.world_pos[0] = c.x(); + lbl.world_pos[1] = c.y(); + lbl.world_pos[2] = c.z(); + lbl.text = QString::number(v, 'f', 4) + " m³"; + labels.push_back(std::move(lbl)); + } + + viewport_->setHudText(QString("Volume: %1 m³ (%2 object%3)") + .arg(total, 0, 'f', 4) + .arg(per_obj.size()) + .arg(per_obj.size() == 1 ? "" : "s")); + viewport_->setOverlayLabels(labels); } void MainWindow::onTreeSelectionChanged() { diff --git a/src/ifcviewer-full/MainWindow.h b/src/ifcviewer-full/MainWindow.h index e518b9c9a8..2d26cda6fd 100644 --- a/src/ifcviewer-full/MainWindow.h +++ b/src/ifcviewer-full/MainWindow.h @@ -93,6 +93,10 @@ private: bool confirmDiscardIfDirty(); void updateWindowTitle(); void populateProperties(uint32_t object_id); + // Push the volume HUD + per-object volume labels for the current + // selection. No-op when a measurement tool is active — that tool + // owns the overlay state until it's exited. + void updateVolumeReadout(); void appendElementToTree(uint32_t model_id, uint32_t object_id, int ifc_id, diff --git a/src/ifcviewer-full/Measurement.cpp b/src/ifcviewer-full/Measurement.cpp index 181729f290..da333cd688 100644 --- a/src/ifcviewer-full/Measurement.cpp +++ b/src/ifcviewer-full/Measurement.cpp @@ -94,6 +94,41 @@ double volumeOfObjects(ViewportWindow& vp, return total; } +std::vector> +volumesPerObject(ViewportWindow& vp, + const std::vector& object_ids) { + std::vector> out; + if (object_ids.empty()) return out; + out.reserve(object_ids.size()); + + // Cache the local-frame volume per unique (model_id, mesh_id) so each + // mesh is read back at most once even when many instances share it + // (common for repeated families like windows / columns). + std::unordered_map mesh_vol_local; + mesh_vol_local.reserve(object_ids.size()); + + ViewportWindow::MeshTriangles tris; + for (uint32_t oid : object_ids) { + ViewportWindow::InstanceLookup lk; + if (!vp.findInstance(oid, lk)) continue; + + const uint64_t key = (uint64_t(lk.model_id) << 32) | lk.mesh_id; + auto it = mesh_vol_local.find(key); + double v_local = 0.0; + if (it == mesh_vol_local.end()) { + if (vp.readbackMeshTriangles(lk.model_id, lk.mesh_id, tris)) { + v_local = meshLocalVolume(tris); + } + mesh_vol_local.emplace(key, v_local); + } else { + v_local = it->second; + } + const double det = std::abs(det3(lk.placement_transformation)); + out.emplace_back(oid, v_local * det); + } + return out; +} + namespace { // edge_key: undirected edge between two mesh-local vertex indices. diff --git a/src/ifcviewer-full/Measurement.h b/src/ifcviewer-full/Measurement.h index 79db156546..cbd6807eb0 100644 --- a/src/ifcviewer-full/Measurement.h +++ b/src/ifcviewer-full/Measurement.h @@ -39,6 +39,15 @@ double volumeOfObjects(ViewportWindow& vp, const std::vector& object_ids); +// Per-object volumes (m³). Same algorithm as volumeOfObjects but +// attributed per id rather than summed. Skips ids that don't resolve +// to a live instance, so the result may be shorter than the input. +// Used by MainWindow's volume readout to drive both the total HUD and +// the per-object overlay labels. +std::vector> +volumesPerObject(ViewportWindow& vp, + const std::vector& object_ids); + // Click-to-accumulate area measurement. Each pick resolves the screen // click to a (instance, triangle) using ViewportWindow's primitives, // expands it into the connected coplanar patch (BFS over shared edges, diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index ca725ea3b6..77557920bc 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -3596,8 +3596,11 @@ void ViewportWindow::handleMousePress(QMouseEvent* e) { // drag that happens to start on an object still box-selects, which // matches user intuition: the start point shouldn't disqualify the // gesture. Tool-mode LMB defers pick handling to surfacePickedInTool - // on release. - if (e->button() == Qt::LeftButton && tool_mode_ == ToolMode::None) { + // on release for tools that consume clicks (Area / Length); Volume is + // passive and routes input through normal selection. + const bool tool_consumes_clicks = + (tool_mode_ == ToolMode::Area || tool_mode_ == ToolMode::Length); + if (e->button() == Qt::LeftButton && !tool_consumes_clicks) { press_pick_id_ = pickObjectAt(e->pos().x(), e->pos().y()); box_select_start_pos_ = e->pos(); box_select_current_pos_ = e->pos(); @@ -3637,8 +3640,11 @@ void ViewportWindow::handleMouseRelease(QMouseEvent* e) { selection_.setSelection(picks, selection_.activeObjectId()); } } else if (!was_drag) { - // Click — apply press-time pick + modifiers. - if (tool_mode_ != ToolMode::None) { + // Click — apply press-time pick + modifiers. Area / Length + // intercept clicks; None and Volume drive normal selection. + const bool tool_consumes_clicks = + (tool_mode_ == ToolMode::Area || tool_mode_ == ToolMode::Length); + if (tool_consumes_clicks) { emit surfacePickedInTool(e->pos().x(), e->pos().y(), int(e->modifiers())); } else { @@ -4225,6 +4231,10 @@ void ViewportWindow::toggleLengthTool() { setToolMode(tool_mode_ == ToolMode::Length ? ToolMode::None : ToolMode::Length); } +void ViewportWindow::toggleVolumeTool() { + setToolMode(tool_mode_ == ToolMode::Volume ? ToolMode::None : ToolMode::Volume); +} + void ViewportWindow::setHighlightTriangles(const std::vector& world_xyz, float r, float g, float b, float a) { if (!gl_initialized_) return; diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 8434c4fa25..822d4e55c4 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -255,11 +255,15 @@ public: // object selection); the app interprets them per-tool. Esc exits the // active tool. Backspace/Delete in length mode emits // toolBackspacePressed for "remove last point" semantics. - enum class ToolMode { None, Area, Length }; + // Area / Length consume LMB clicks via surfacePickedInTool; Volume + // is passive — selection behaves as in None and the host just gates + // its volume HUD/labels on this mode. + enum class ToolMode { None, Area, Length, Volume }; Q_ENUM(ToolMode) void toggleAreaTool(); void toggleLengthTool(); + void toggleVolumeTool(); void setToolMode(ToolMode mode); ToolMode toolMode() const { return tool_mode_; } @@ -379,6 +383,10 @@ public: // Frame the union of all finalized models. No-op if the scene is empty. void viewAll(); + // World-space AABB query. Returns false when the object has no live + // instance or no mesh AABB yet (caller should treat as "unknown"). + bool computeObjectAabb(uint32_t object_id, QVector3D& mn, QVector3D& mx) const; + struct CameraState { QVector3D target; float distance; @@ -466,10 +474,8 @@ private: void updateSectionDrag(int x, int y); void updateCamera(); - // Geometry queries used by focusOnSelectedObject() / viewAll(). Both - // return false when nothing matched (caller should leave the camera - // alone). Bounds are world-space AABBs. - bool computeObjectAabb(uint32_t object_id, QVector3D& mn, QVector3D& mx) const; + // Scene-wide AABB used by viewAll(). Returns false when the scene + // has no finalized geometry (caller should leave the camera alone). bool computeSceneAabb(QVector3D& mn, QVector3D& mx) const; // Re-aim the orbit camera so the bounding sphere of [mn, mx] just fits // vertically and horizontally within the current FOV, with `padding`