diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp
index c1d1220b15..57e60282cc 100644
--- a/src/ifcviewer-full/MainWindow.cpp
+++ b/src/ifcviewer-full/MainWindow.cpp
@@ -21,6 +21,7 @@
#include "AppSettings.h"
#include "Federation.h"
#include "FederationSettingsDialog.h"
+#include "Measurement.h"
#include "ModelTransformationDialog.h"
#include "SettingsWindow.h"
#include "LodBuilder.h"
@@ -871,6 +872,11 @@ void MainWindow::onObjectPicked(uint32_t object_id) {
}
populateProperties(object_id);
+
+ if (object_id != 0) {
+ const double v = volumeOfObjects(*viewport_, {object_id});
+ qInfo("Volume of object %u: %.6f m^3", object_id, v);
+ }
}
void MainWindow::onTreeSelectionChanged() {
diff --git a/src/ifcviewer-full/Measurement.cpp b/src/ifcviewer-full/Measurement.cpp
new file mode 100644
index 0000000000..1283fcd9d4
--- /dev/null
+++ b/src/ifcviewer-full/Measurement.cpp
@@ -0,0 +1,89 @@
+/********************************************************************************
+ * *
+ * 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 "Measurement.h"
+
+#include "ViewportWindow.h"
+
+#include
+#include
+#include
+#include
+
+namespace {
+
+double meshLocalVolume(const ViewportWindow::MeshTriangles& tris) {
+ // Signed tetrahedra from the origin: V = sum( a · (b × c) ) / 6.
+ // Absolute value at the end so winding convention doesn't matter.
+ double sum = 0.0;
+ const size_t n = tris.indices.size();
+ for (size_t i = 0; i + 2 < n; i += 3) {
+ const uint32_t ia = tris.indices[i + 0];
+ const uint32_t ib = tris.indices[i + 1];
+ const uint32_t ic = tris.indices[i + 2];
+ const float* a = &tris.positions[3 * ia];
+ const float* b = &tris.positions[3 * ib];
+ const float* c = &tris.positions[3 * ic];
+ const double cx = double(b[1]) * c[2] - double(b[2]) * c[1];
+ const double cy = double(b[2]) * c[0] - double(b[0]) * c[2];
+ const double cz = double(b[0]) * c[1] - double(b[1]) * c[0];
+ sum += double(a[0]) * cx + double(a[1]) * cy + double(a[2]) * cz;
+ }
+ return std::abs(sum) / 6.0;
+}
+
+double det3(const float M[16]) {
+ // Upper-left 3x3 of a column-major 4x4: M[col * 4 + row].
+ const double m00 = M[0], m10 = M[1], m20 = M[2];
+ const double m01 = M[4], m11 = M[5], m21 = M[6];
+ const double m02 = M[8], m12 = M[9], m22 = M[10];
+ return m00 * (m11 * m22 - m12 * m21)
+ - m01 * (m10 * m22 - m12 * m20)
+ + m02 * (m10 * m21 - m11 * m20);
+}
+
+} // namespace
+
+double volumeOfObjects(ViewportWindow& vp,
+ const std::vector& object_ids) {
+ if (object_ids.empty()) return 0.0;
+
+ // Group selected instances by (model_id, mesh_id) so each unique mesh
+ // is read back at most once per call. Each entry stores the |det| of
+ // every instance of that mesh in the request.
+ std::unordered_map> by_mesh;
+ by_mesh.reserve(object_ids.size());
+ 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;
+ by_mesh[key].push_back(std::abs(det3(lk.placement_transformation)));
+ }
+
+ double total = 0.0;
+ ViewportWindow::MeshTriangles tris;
+ for (const auto& [key, dets] : by_mesh) {
+ const uint32_t model_id = uint32_t(key >> 32);
+ const uint32_t mesh_id = uint32_t(key & 0xffffffffu);
+ if (!vp.readbackMeshTriangles(model_id, mesh_id, tris)) continue;
+ const double v = meshLocalVolume(tris);
+ for (double d : dets) total += v * d;
+ }
+ return total;
+}
diff --git a/src/ifcviewer-full/Measurement.h b/src/ifcviewer-full/Measurement.h
new file mode 100644
index 0000000000..847fcd7d69
--- /dev/null
+++ b/src/ifcviewer-full/Measurement.h
@@ -0,0 +1,38 @@
+/********************************************************************************
+ * *
+ * 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 IFCVIEWER_FULL_MEASUREMENT_H
+#define IFCVIEWER_FULL_MEASUREMENT_H
+
+#include
+#include
+
+class ViewportWindow;
+
+// Sum of mesh-local volumes (m³) of every instance whose object_id is in
+// `object_ids`. Groups by (model, mesh) so each unique mesh is read back
+// from the GPU at most once per call; instances of the same mesh are scaled
+// by |det(placement_3x3)| to pick up mapped-item scale/mirror. Volume is
+// taken as the absolute value of the signed-tetrahedra sum, so winding
+// convention does not matter. Returns 0.0 for empty input or when nothing
+// resolves. Recomputes from scratch on every call — no cache.
+double volumeOfObjects(ViewportWindow& vp,
+ const std::vector& object_ids);
+
+#endif // IFCVIEWER_FULL_MEASUREMENT_H
diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp
index 8330ce16a7..cd64ad2510 100644
--- a/src/ifcviewer/ViewportWindow.cpp
+++ b/src/ifcviewer/ViewportWindow.cpp
@@ -3747,3 +3747,65 @@ void ViewportWindow::printSelectedObjectCoords() {
qInfo("printSelectedObjectCoords: object_id %u not found in any model",
selected_object_id_);
}
+
+bool ViewportWindow::readbackMeshTriangles(uint32_t model_id, uint32_t mesh_id,
+ MeshTriangles& out) {
+ out.positions.clear();
+ out.indices.clear();
+ if (!gl_initialized_) return false;
+ auto it = models_gpu_.find(model_id);
+ if (it == models_gpu_.end()) return false;
+ const ModelGpuData& m = it->second;
+ if (!m.finalized) return false;
+ if (mesh_id >= m.meshes.size()) return false;
+ const MeshInfo& mi = m.meshes[mesh_id];
+ if (mi.vertex_count == 0 || mi.index_count == 0) return false;
+
+ context_->makeCurrent(this);
+
+ // Vertices: stride is INSTANCED_VERTEX_STRIDE_BYTES (12), positions are
+ // the first 6 bytes (3 x uint16) of each vertex. Read the full range
+ // and stride through it — simpler than chasing a position-only buffer.
+ std::vector raw(size_t(mi.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES);
+ gl_->glGetNamedBufferSubData(m.vbo, mi.vbo_byte_offset,
+ GLsizeiptr(raw.size()), raw.data());
+
+ out.positions.resize(size_t(mi.vertex_count) * 3);
+ const float ox = mi.local_aabb_min[0];
+ const float oy = mi.local_aabb_min[1];
+ const float oz = mi.local_aabb_min[2];
+ const float sx = (mi.local_aabb_max[0] - mi.local_aabb_min[0]) / 65535.0f;
+ const float sy = (mi.local_aabb_max[1] - mi.local_aabb_min[1]) / 65535.0f;
+ const float sz = (mi.local_aabb_max[2] - mi.local_aabb_min[2]) / 65535.0f;
+ for (uint32_t i = 0; i < mi.vertex_count; ++i) {
+ const uint16_t* p = reinterpret_cast(
+ raw.data() + size_t(i) * INSTANCED_VERTEX_STRIDE_BYTES);
+ out.positions[3 * i + 0] = ox + sx * float(p[0]);
+ out.positions[3 * i + 1] = oy + sy * float(p[1]);
+ out.positions[3 * i + 2] = oz + sz * float(p[2]);
+ }
+
+ // Indices: LOD0 only — measurements should use the full-resolution mesh.
+ out.indices.resize(mi.index_count);
+ gl_->glGetNamedBufferSubData(m.ebo, mi.ebo_byte_offset,
+ GLsizeiptr(mi.index_count * sizeof(uint32_t)),
+ out.indices.data());
+ return true;
+}
+
+bool ViewportWindow::findInstance(uint32_t object_id, InstanceLookup& out) const {
+ if (object_id == 0) return false;
+ for (const auto& kv : models_gpu_) {
+ const ModelGpuData& m = kv.second;
+ for (const InstanceCpu& inst : m.instances) {
+ if (inst.object_id != object_id) continue;
+ out.model_id = inst.model_id;
+ out.mesh_id = inst.mesh_id;
+ std::memcpy(out.placement_transformation,
+ inst.placement_transformation,
+ sizeof(out.placement_transformation));
+ return true;
+ }
+ }
+ return false;
+}
diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h
index 74cfdb36ad..09f89a03b8 100644
--- a/src/ifcviewer/ViewportWindow.h
+++ b/src/ifcviewer/ViewportWindow.h
@@ -183,6 +183,31 @@ public:
// (CoordinateOperation · placement_transformation), in metres.
void printSelectedObjectCoords();
+ // Mesh-local CPU triangles read back from the VBO/EBO. Positions are
+ // dequantised against the mesh's local AABB; units match the streamer
+ // (metres). Indices are mesh-local (0..vertex_count-1).
+ struct MeshTriangles {
+ std::vector positions; // 3 * vertex_count
+ std::vector indices; // 3 * triangle_count
+ };
+
+ // Lazy GPU readback of one mesh's triangles. Fails if model_id /
+ // mesh_id aren't live, the model isn't finalised, or GL isn't ready.
+ // Stalls the GL pipeline for the readback — call from the main thread
+ // and not from inside render().
+ bool readbackMeshTriangles(uint32_t model_id, uint32_t mesh_id,
+ MeshTriangles& out);
+
+ // Pure CPU lookup: object_id → owning model + mesh + raw streamer
+ // placement matrix (column-major, pre-CoordinateOperation /
+ // FederatedFalseOrigin / ModelTransformation).
+ struct InstanceLookup {
+ uint32_t model_id = 0;
+ uint32_t mesh_id = 0;
+ float placement_transformation[16]{};
+ };
+ bool findInstance(uint32_t object_id, InstanceLookup& out) const;
+
// Federation pipeline: composed instance transform =
// FederatedFalseOrigin · ModelTransformation · CoordinateOperation
// · placement_transformation