refactor: merge ifcviewer-wgpu into ifcviewer, drop Wgpu prefix

The GL backend is gone (task #53). The wgpu/non-wgpu folder split and
the Wgpu* class prefix were both disambiguation artefacts from the
overlap period — now pure dead weight.

## Folder + library merge

* `src/ifcviewer-wgpu/`  → folded into `src/ifcviewer/` (git mv tracks
  every file as a rename so blame/log history survives).
* `src/ifcviewer-wgpu-minimal/` → `src/ifcviewer-minimal/` (the exe was
  already named `IfcViewerMinimal`; this just brings the folder + CMake
  target name into line).
* `src/ifcviewer-wgpu/tests/test_wgpu_{selection,visibility}.cpp` →
  `src/ifcviewer/tests/test_{selection,visibility}.cpp`, folded into
  the existing `add_ifcviewer_unit_test(...)` helper.
* The `IfcViewerWgpu` static library is dissolved — its sources become
  part of the unified `IfcViewer` static library, which now bundles
  scene/loader + renderer in one target. The pre-merge circular
  dependency (IfcViewer linking IfcViewerWgpu just to get the
  ViewportWindow.h include path that SceneLoader.h needs) goes away.
* The wgpu-native FetchContent block, the Cocoa/QuartzCore link on
  Apple, the OBJCXX-enabled `.mm` source, and the wgpu-native runtime
  install all move into `src/ifcviewer/CMakeLists.txt` unchanged.

## Type renames (Wgpu prefix dropped from every Wgpu* identifier)

  WgpuAreaMeasurement   → AreaMeasurement
  WgpuBufferPool        → BufferPool
  WgpuLengthMeasurement → LengthMeasurement
  WgpuMetalSurface      → MetalSurface
  WgpuModelGpuData      → ModelGpuData
  WgpuOverlayFrame      → OverlayFrame
  WgpuOverlayRenderer   → OverlayRenderer
  WgpuSectionPlane      → SectionPlane
  WgpuSelectionState    → SelectionState
  WgpuStreamingLoader   → StreamingLoader
  WgpuStreamingThread   → StreamingThread
  WgpuViewportWindow    → ViewportWindow
  WgpuVisibilityState   → VisibilityState

  CMake target IfcViewerWgpuMinimal → IfcViewerMinimal (exe name was
  already this since wgpu shipped as default).

Deliberately kept: `onWgpuLog` (wgpu-native log callback — names a
binding to an external API, not one of *our* types), and the WGPU*
enum/struct prefixes from wgpu-native's own headers. `WgpuMemProbe`
lives in the separate `src/wgpu-mem-probe/` standalone diagnostic
project and isn't touched.

## Include-path updates

Every `#include "../ifcviewer-wgpu/Wgpu<X>.h"` → `"../ifcviewer/<X>.h"`,
every in-directory `#include "Wgpu<X>.h"` → `"<X>.h"`. Includes from
sibling subdirectories (modules/, etc.) are updated to point at
`../../../ifcviewer/` instead of `../../../ifcviewer-wgpu/`.

## cmake/CMakeLists.txt simplification

The redundant `add_subdirectory(ifcviewer-wgpu)` blocks (one inside
the BUILD_BONSAIVIEWER fan-in, one in the BONSAIVIEWER-less standalone
block) collapse into a single unconditional
`add_subdirectory(../src/ifcviewer ifcviewer)`. The standalone block
keeps only `wgpu-mem-probe` (the diagnostic tool, unrelated to the
viewer lib).

## Verification

* Full build green: `IfcViewer` static lib, `IfcViewerMinimal` exe,
  `BonsaiViewer` exe, all four pre-existing ifcviewer unit tests, and
  the two new-location tests (`test_selection`, `test_visibility`).
* No stray `Wgpu<X>` identifier remains across `src/ifcviewer/`,
  `src/bonsaiviewer/`, `src/ifcviewer-minimal/` (verified by grep).
* Renames tracked by git as `R` entries — `git log --follow` on
  ViewportWindow.cpp etc. continues to show history through the move.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-06-04 11:11:14 +10:00
parent 7f87408b78
commit 8ab5c31e75
48 changed files with 705 additions and 815 deletions
+405
View File
@@ -0,0 +1,405 @@
/********************************************************************************
* *
* 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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "AreaMeasurement.h"
#include "OverlayRenderer.h"
#include "ViewportWindow.h"
#include <QDebug>
#include <QString>
#include <algorithm>
#include <cmath>
#include <cstring>
#include <limits>
#include <queue>
#include <unordered_set>
namespace {
// Undirected edge key between two mesh-local vertex indices.
uint64_t edgeKey(uint32_t a, uint32_t b) {
if (a > b) std::swap(a, b);
return (uint64_t(a) << 32) | uint64_t(b);
}
// Triangle area = 0.5 * |(b - a) × (c - a)|. Also returns the unit
// normal (zeroed for degenerate tris).
double triAreaAndNormal(const float* a, const float* b, const float* c,
float n_out[3]) {
const double bax = double(b[0]) - a[0];
const double bay = double(b[1]) - a[1];
const double baz = double(b[2]) - a[2];
const double cax = double(c[0]) - a[0];
const double cay = double(c[1]) - a[1];
const double caz = double(c[2]) - a[2];
const double nx = bay * caz - baz * cay;
const double ny = baz * cax - bax * caz;
const double nz = bax * cay - bay * cax;
const double len = std::sqrt(nx * nx + ny * ny + nz * nz);
if (len > 0.0) {
n_out[0] = float(nx / len);
n_out[1] = float(ny / len);
n_out[2] = float(nz / len);
} else {
n_out[0] = n_out[1] = n_out[2] = 0.0f;
}
return 0.5 * len;
}
// Squared distance from point `p` to triangle (a, b, c) — clipped to
// the triangle's interior or boundary, whichever is closest. Standard
// Ericson "Real-Time Collision Detection" implementation; identical to
// the GL AreaMeasurement helper.
double pointTriangleDistSq(const float p[3],
const float a[3], const float b[3], const float c[3]) {
auto sub = [](const float u[3], const float v[3], double r[3]) {
r[0] = double(u[0]) - v[0];
r[1] = double(u[1]) - v[1];
r[2] = double(u[2]) - v[2];
};
auto dot = [](const double u[3], const double v[3]) {
return u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
};
double ab[3], ac[3], ap[3];
sub(b, a, ab);
sub(c, a, ac);
sub(p, a, ap);
const double d1 = dot(ab, ap);
const double d2 = dot(ac, ap);
if (d1 <= 0.0 && d2 <= 0.0) {
return ap[0]*ap[0] + ap[1]*ap[1] + ap[2]*ap[2];
}
double bp[3];
sub(p, b, bp);
const double d3 = dot(ab, bp);
const double d4 = dot(ac, bp);
if (d3 >= 0.0 && d4 <= d3) {
return bp[0]*bp[0] + bp[1]*bp[1] + bp[2]*bp[2];
}
const double vc = d1 * d4 - d3 * d2;
if (vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0) {
const double v = d1 / (d1 - d3);
const double qx = ap[0] - v * ab[0];
const double qy = ap[1] - v * ab[1];
const double qz = ap[2] - v * ab[2];
return qx*qx + qy*qy + qz*qz;
}
double cp[3];
sub(p, c, cp);
const double d5 = dot(ab, cp);
const double d6 = dot(ac, cp);
if (d6 >= 0.0 && d5 <= d6) {
return cp[0]*cp[0] + cp[1]*cp[1] + cp[2]*cp[2];
}
const double vb = d5 * d2 - d1 * d6;
if (vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0) {
const double w = d2 / (d2 - d6);
const double qx = ap[0] - w * ac[0];
const double qy = ap[1] - w * ac[1];
const double qz = ap[2] - w * ac[2];
return qx*qx + qy*qy + qz*qz;
}
const double va = d3 * d6 - d5 * d4;
if (va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0) {
const double w = (d4 - d3) / ((d4 - d3) + (d5 - d6));
const double qx = double(b[0]) + w * (double(c[0]) - b[0]) - p[0];
const double qy = double(b[1]) + w * (double(c[1]) - b[1]) - p[1];
const double qz = double(b[2]) + w * (double(c[2]) - b[2]) - p[2];
return qx*qx + qy*qy + qz*qz;
}
const double denom = 1.0 / (va + vb + vc);
const double v = vb * denom;
const double w = vc * denom;
const double qx = double(a[0]) + v * ab[0] + w * ac[0] - p[0];
const double qy = double(a[1]) + v * ab[1] + w * ac[1] - p[1];
const double qz = double(a[2]) + v * ab[2] + w * ac[2] - p[2];
return qx*qx + qy*qy + qz*qz;
}
constexpr double kCoplanarDot = 0.9999; // ~0.81° tolerance, matches GL
} // namespace
AreaMeasurement::AreaMeasurement() = default;
void AreaMeasurement::clear(ViewportWindow& vp) {
mesh_cache_.clear();
selected_.clear();
total_area_m2_ = 0.0;
vp.setHighlightTriangles({}, 0, 0, 0, 0);
vp.setOverlayLabels({});
}
AreaMeasurement::MeshAdj*
AreaMeasurement::meshAdj(ViewportWindow& vp,
uint32_t model_id, uint32_t mesh_id) {
const uint64_t key = (uint64_t(model_id) << 32) | uint64_t(mesh_id);
auto it = mesh_cache_.find(key);
if (it != mesh_cache_.end()) return &it->second;
// Need the raw positions + indices for adjacency. We never store
// them in the per-mesh cache (positions can be hundreds of KB each
// and live in the viewport already), so just look them up freshly
// each time the user picks a brand-new mesh.
ViewportWindow::MeshTriangles tris;
if (!vp.readbackMeshTriangles(model_id, mesh_id, tris)) return nullptr;
if (tris.indices.size() < 3) return nullptr;
MeshAdj a;
const size_t n_tris = tris.indices.size() / 3;
a.tri_normals.resize(n_tris * 3);
a.tri_areas.resize(n_tris);
a.edges.reserve(n_tris * 3);
for (size_t t = 0; t < n_tris; ++t) {
const uint32_t ia = tris.indices[3 * t + 0];
const uint32_t ib = tris.indices[3 * t + 1];
const uint32_t ic = tris.indices[3 * t + 2];
if (3 * ia + 2 >= tris.positions.size()
|| 3 * ib + 2 >= tris.positions.size()
|| 3 * ic + 2 >= tris.positions.size()) continue;
const float* pa = &tris.positions[3 * ia];
const float* pb = &tris.positions[3 * ib];
const float* pc = &tris.positions[3 * ic];
float n[3];
a.tri_areas[t] = triAreaAndNormal(pa, pb, pc, n);
a.tri_normals[3 * t + 0] = n[0];
a.tri_normals[3 * t + 1] = n[1];
a.tri_normals[3 * t + 2] = n[2];
a.edges[edgeKey(ia, ib)].push_back(uint32_t(t));
a.edges[edgeKey(ib, ic)].push_back(uint32_t(t));
a.edges[edgeKey(ic, ia)].push_back(uint32_t(t));
}
return &mesh_cache_.emplace(key, std::move(a)).first->second;
}
void AreaMeasurement::onPick(ViewportWindow& vp,
int x_phys, int y_phys, bool alt) {
ViewportWindow::MeshLocalPick pick;
if (!vp.pickMeshLocalAt(x_phys, y_phys, pick)) return;
ViewportWindow::MeshTriangles tris;
if (!vp.readbackMeshTriangles(pick.model_id, pick.mesh_id, tris)) return;
const size_t n_tris = tris.indices.size() / 3;
if (n_tris == 0) return;
MeshAdj* adj = meshAdj(vp, pick.model_id, pick.mesh_id);
if (!adj) return;
// Seed: the triangle whose interior (or boundary) is closest to the
// mesh-local pick point.
uint32_t seed = 0;
double best = std::numeric_limits<double>::infinity();
for (size_t t = 0; t < n_tris; ++t) {
const uint32_t ia = tris.indices[3 * t + 0];
const uint32_t ib = tris.indices[3 * t + 1];
const uint32_t ic = tris.indices[3 * t + 2];
const double d = pointTriangleDistSq(pick.mesh_local,
&tris.positions[3 * ia],
&tris.positions[3 * ib],
&tris.positions[3 * ic]);
if (d < best) { best = d; seed = uint32_t(t); }
}
// Coplanar patch via BFS over shared edges. Alt skips the expand
// (single-triangle accumulate).
std::vector<uint32_t> patch;
if (alt) {
patch.push_back(seed);
} else {
const float* sn = &adj->tri_normals[3 * seed];
std::unordered_set<uint32_t> visited;
visited.insert(seed);
std::queue<uint32_t> frontier;
frontier.push(seed);
while (!frontier.empty()) {
const uint32_t t = frontier.front(); frontier.pop();
patch.push_back(t);
for (int e = 0; e < 3; ++e) {
const uint32_t ia = tris.indices[3 * t + e];
const uint32_t ib = tris.indices[3 * t + (e + 1) % 3];
auto eit = adj->edges.find(edgeKey(ia, ib));
if (eit == adj->edges.end()) continue;
for (uint32_t nt : eit->second) {
if (nt == t || visited.count(nt)) continue;
const float* nn = &adj->tri_normals[3 * nt];
const double dot = double(sn[0]) * nn[0]
+ double(sn[1]) * nn[1]
+ double(sn[2]) * nn[2];
if (dot < kCoplanarDot) continue;
visited.insert(nt);
frontier.push(nt);
}
}
}
}
// Toggle: if the seed was already in the set, remove the patch;
// otherwise add it.
const uint64_t seed_key = triKey(pick.object_id, seed);
const bool removing = selected_.count(seed_key) > 0;
double delta = 0.0;
for (uint32_t t : patch) {
const uint64_t k = triKey(pick.object_id, t);
if (removing) {
auto it = selected_.find(k);
if (it != selected_.end()) {
if (t < adj->tri_areas.size()) delta -= adj->tri_areas[t];
selected_.erase(it);
}
} else {
SelectedTri sel;
sel.model_id = pick.model_id;
sel.mesh_id = pick.mesh_id;
sel.tri = t;
std::memcpy(sel.composed_transform, pick.composed_transform,
sizeof(sel.composed_transform));
if (selected_.emplace(k, sel).second) {
if (t < adj->tri_areas.size()) delta += adj->tri_areas[t];
}
}
}
total_area_m2_ += delta;
rebuildHighlightAndLabels(vp);
qInfo("[wgpu area] %s%.6f m^2 (total: %.6f m^2, %zu tris)",
delta >= 0.0 ? "+" : "", delta,
total_area_m2_, selected_.size());
}
void AreaMeasurement::rebuildHighlightAndLabels(ViewportWindow& vp) {
// 1) Highlight triangle list — each selected tri's three vertices
// transformed by its captured composed_transform. Push as a
// flat world-space tri list; the overlay tints them translucent
// cyan to match GL.
std::vector<float> world_xyz;
world_xyz.reserve(selected_.size() * 9);
// Cache the latest MeshTriangles per (model,mesh) for this rebuild
// to avoid repeated viewport lookups when many tris share a mesh.
std::unordered_map<uint64_t, ViewportWindow::MeshTriangles> tris_cache;
auto get_tris = [&](uint32_t model_id, uint32_t mesh_id)
-> ViewportWindow::MeshTriangles* {
const uint64_t k = (uint64_t(model_id) << 32) | uint64_t(mesh_id);
auto it = tris_cache.find(k);
if (it != tris_cache.end()) return &it->second;
ViewportWindow::MeshTriangles t;
if (!vp.readbackMeshTriangles(model_id, mesh_id, t)) return nullptr;
return &tris_cache.emplace(k, std::move(t)).first->second;
};
for (const auto& [key, sel] : selected_) {
ViewportWindow::MeshTriangles* t = get_tris(sel.model_id, sel.mesh_id);
if (!t) continue;
if (size_t(sel.tri) * 3 + 2 >= t->indices.size()) continue;
const float* M = sel.composed_transform; // column-major
for (int e = 0; e < 3; ++e) {
const uint32_t vi = t->indices[3 * sel.tri + e];
if (3 * vi + 2 >= t->positions.size()) continue;
const float* p = &t->positions[3 * vi];
// World = M * (p, 1). Column-major: M[col*4 + row].
const float wx = M[0]*p[0] + M[4]*p[1] + M[8]*p[2] + M[12];
const float wy = M[1]*p[0] + M[5]*p[1] + M[9]*p[2] + M[13];
const float wz = M[2]*p[0] + M[6]*p[1] + M[10]*p[2] + M[14];
world_xyz.push_back(wx);
world_xyz.push_back(wy);
world_xyz.push_back(wz);
}
}
// Bonsai's area-tool cyan tint: 0.20, 0.85, 1.00 @ 0.45 alpha.
vp.setHighlightTriangles(world_xyz, 0.20f, 0.85f, 1.00f, 0.45f);
// 2) Per-patch labels via connected-components sweep restricted to
// selected tris, one label per component at its area-weighted
// centroid (mesh-local → world via the captured transform).
std::unordered_map<uint32_t, std::vector<const SelectedTri*>> by_object;
for (const auto& [key, sel] : selected_) {
const uint32_t object_id = uint32_t(key >> 32);
by_object[object_id].push_back(&sel);
}
std::vector<OverlayRenderer::Label> labels;
for (const auto& [obj_id, sels] : by_object) {
if (sels.empty()) continue;
const SelectedTri& any = *sels[0];
ViewportWindow::MeshTriangles* t = get_tris(any.model_id, any.mesh_id);
if (!t) continue;
MeshAdj* adj = meshAdj(vp, any.model_id, any.mesh_id);
if (!adj) continue;
std::unordered_set<uint32_t> remaining;
remaining.reserve(sels.size());
for (const SelectedTri* s : sels) remaining.insert(s->tri);
while (!remaining.empty()) {
const uint32_t start = *remaining.begin();
std::unordered_set<uint32_t> in_comp{start};
std::queue<uint32_t> frontier;
frontier.push(start);
std::vector<uint32_t> component;
while (!frontier.empty()) {
const uint32_t tri = frontier.front(); frontier.pop();
component.push_back(tri);
if (size_t(tri) * 3 + 2 >= t->indices.size()) continue;
for (int e = 0; e < 3; ++e) {
const uint32_t ia = t->indices[3 * tri + e];
const uint32_t ib = t->indices[3 * tri + (e + 1) % 3];
auto eit = adj->edges.find(edgeKey(ia, ib));
if (eit == adj->edges.end()) continue;
for (uint32_t nt : eit->second) {
if (in_comp.count(nt) || remaining.count(nt) == 0) continue;
in_comp.insert(nt);
frontier.push(nt);
}
}
}
for (uint32_t tri : component) remaining.erase(tri);
double area = 0.0, cx = 0.0, cy = 0.0, cz = 0.0;
for (uint32_t tri : component) {
if (size_t(tri) >= adj->tri_areas.size()) continue;
const double a = adj->tri_areas[tri];
area += a;
const uint32_t ia = t->indices[3 * tri + 0];
const uint32_t ib = t->indices[3 * tri + 1];
const uint32_t ic = t->indices[3 * tri + 2];
const float* va = &t->positions[3 * ia];
const float* vb = &t->positions[3 * ib];
const float* vc = &t->positions[3 * ic];
cx += a * (double(va[0]) + vb[0] + vc[0]) / 3.0;
cy += a * (double(va[1]) + vb[1] + vc[1]) / 3.0;
cz += a * (double(va[2]) + vb[2] + vc[2]) / 3.0;
}
if (area <= 0.0) continue;
cx /= area; cy /= area; cz /= area;
const float* M = any.composed_transform;
OverlayRenderer::Label lbl;
lbl.world_pos[0] = float(M[0]*cx + M[4]*cy + M[8]*cz + M[12]);
lbl.world_pos[1] = float(M[1]*cx + M[5]*cy + M[9]*cz + M[13]);
lbl.world_pos[2] = float(M[2]*cx + M[6]*cy + M[10]*cz + M[14]);
lbl.text = QString::number(area, 'f', 4) + QStringLiteral("");
labels.push_back(std::move(lbl));
}
}
vp.setOverlayLabels(labels);
}
+96
View File
@@ -0,0 +1,96 @@
/********************************************************************************
* *
* 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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef WGPUAREAMEASUREMENT_H
#define WGPUAREAMEASUREMENT_H
#include <cstddef>
#include <cstdint>
#include <unordered_map>
#include <vector>
class ViewportWindow;
// Click-to-accumulate area measurement for the wgpu viewport. Mirrors
// src/bonsaiviewer/Measurement.h's AreaMeasurement: each pick resolves
// to (instance, triangle) via ViewportWindow::pickMeshLocalAt, then
// either adds or removes the connected coplanar patch (BFS over shared
// edges, dot(normal, seed_normal) > 0.9999) depending on whether the
// seed triangle was already in the running set. Alt-click skips the BFS.
// Picks on different instances (even of the same mesh) are kept as
// separate patches.
//
// On every mutation the world-space triangles of the running set are
// pushed to ViewportWindow::setHighlightTriangles for the
// translucent cyan patch shading, and per-component "X.XXXX m²" labels
// are pushed to setOverlayLabels at each connected component's
// area-weighted centroid.
class AreaMeasurement {
public:
AreaMeasurement();
// Pixel coords are physical (post-DPR), to match
// ViewportWindow::pickMeshLocalAt's convention.
void onPick(ViewportWindow& vp, int x_phys, int y_phys, bool alt);
void clear(ViewportWindow& vp);
double totalArea() const { return total_area_m2_; }
size_t triangleCount() const { return selected_.size(); }
private:
// Cached per-mesh derived data: triangle normals + areas + edge
// adjacency. Computed once per (model, mesh) on first pick; the
// raw positions + indices live in
// ViewportWindow::readbackMeshTriangles' CPU shadow.
struct MeshAdj {
std::vector<float> tri_normals; // 3 floats per tri (unit, mesh-local)
std::vector<float> tri_areas; // mesh-local area per tri
// edge_key (min<<32 | max) → list of triangle indices touching it.
std::unordered_map<uint64_t, std::vector<uint32_t>> edges;
};
// Keyed by (model_id << 32) | mesh_id.
MeshAdj* meshAdj(ViewportWindow& vp,
uint32_t model_id, uint32_t mesh_id);
// Per-selected-triangle record. The composed transform is captured
// at pick time so highlight rebuilds don't have to re-query the
// viewport for it (and so the overlay keeps working if the picked
// instance later goes hidden).
struct SelectedTri {
uint32_t model_id;
uint32_t mesh_id;
uint32_t tri;
float composed_transform[16];
};
// Selection key: object_id (high 32) | tri index (low 32). Packing
// by object_id rather than mesh_id keeps two distinct instances of
// the same mesh contributing independently — matches the GL impl.
static uint64_t triKey(uint32_t object_id, uint32_t tri) {
return (uint64_t(object_id) << 32) | uint64_t(tri);
}
void rebuildHighlightAndLabels(ViewportWindow& vp);
std::unordered_map<uint64_t, MeshAdj> mesh_cache_;
std::unordered_map<uint64_t, SelectedTri> selected_;
double total_area_m2_ = 0.0;
};
#endif // WGPUAREAMEASUREMENT_H
+225
View File
@@ -0,0 +1,225 @@
/********************************************************************************
* *
* 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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "BufferPool.h"
#include <QtDebug>
#include <cassert>
#include <cstring>
BufferPool::~BufferPool() {
destroy();
}
void BufferPool::configure(WGPUInstance instance, WGPUDevice device,
WGPUBufferUsage usage,
uint64_t per_sub_buffer_capacity,
const char* label_prefix) {
destroy();
instance_ = instance;
device_ = device;
usage_ = usage;
per_sub_buffer_capacity_ = per_sub_buffer_capacity;
last_growth_size_ = per_sub_buffer_capacity;
label_prefix_ = label_prefix ? label_prefix : "";
}
void BufferPool::destroy() {
for (auto& sp : sub_pools_) {
if (sp.buffer) wgpuBufferRelease(sp.buffer);
}
sub_pools_.clear();
device_ = nullptr;
instance_ = nullptr;
usage_ = 0;
per_sub_buffer_capacity_ = 0;
last_growth_size_ = 0;
growth_disabled_ = false;
label_prefix_.clear();
}
bool BufferPool::addSubBuffer() {
if (!device_ || per_sub_buffer_capacity_ == 0) return false;
if (growth_disabled_) return false;
// 64 MB floor: smaller sub-buffers aren't worth the per-allocation
// bookkeeping cost (one bind group per chunk, free-list overhead).
// If the driver won't grant even 64 MB the pool is genuinely at
// its ceiling; growth_disabled_ latches and future grow attempts
// skip the doomed retry.
constexpr uint64_t MIN_SUB_BUFFER_BYTES = 64ull * 1024 * 1024;
uint64_t try_size = last_growth_size_ > 0
? last_growth_size_
: per_sub_buffer_capacity_;
if (try_size < MIN_SUB_BUFFER_BYTES) try_size = MIN_SUB_BUFFER_BYTES;
while (try_size >= MIN_SUB_BUFFER_BYTES) {
// wgpu-native classifies "Not enough memory left" as Validation,
// not OutOfMemory. Nested scopes: OOM inner, Validation outer.
wgpuDevicePushErrorScope(device_, WGPUErrorFilter_Validation);
wgpuDevicePushErrorScope(device_, WGPUErrorFilter_OutOfMemory);
char label[128];
std::snprintf(label, sizeof(label), "%s.sub%zu",
label_prefix_.c_str(), sub_pools_.size());
WGPUBufferDescriptor desc = {};
desc.usage = usage_;
desc.size = try_size;
desc.label.data = label;
desc.label.length = std::strlen(label);
WGPUBuffer buf = wgpuDeviceCreateBuffer(device_, &desc);
struct PopResult { bool done = false; bool error = false; };
auto pop = [&](PopResult& pr) {
WGPUPopErrorScopeCallbackInfo pcb = {};
pcb.mode = WGPUCallbackMode_AllowProcessEvents;
pcb.callback = [](WGPUPopErrorScopeStatus, WGPUErrorType type,
WGPUStringView, void* ud1, void* /*ud2*/) {
auto* p = static_cast<PopResult*>(ud1);
p->done = true;
p->error = (type != WGPUErrorType_NoError);
};
pcb.userdata1 = &pr;
wgpuDevicePopErrorScope(device_, pcb);
while (!pr.done) wgpuInstanceProcessEvents(instance_);
};
PopResult oom_pop, validation_pop;
pop(oom_pop);
pop(validation_pop);
if (buf && !oom_pop.error && !validation_pop.error) {
SubPool sp;
sp.buffer = buf;
sp.capacity = try_size;
sp.used = 0;
sp.free_ranges.push_back({0, try_size});
sub_pools_.push_back(std::move(sp));
last_growth_size_ = try_size;
qInfo().noquote().nospace()
<< "[wgpu pool] added sub-buffer " << (sub_pools_.size() - 1)
<< " (" << (try_size / (1024 * 1024)) << " MB); pool total now "
<< (total_capacity_bytes() / (1024 * 1024)) << " MB";
return true;
}
if (buf) wgpuBufferRelease(buf);
try_size /= 2;
}
qInfo().noquote().nospace()
<< "[wgpu pool] driver refused growth even at "
<< (MIN_SUB_BUFFER_BYTES / (1024 * 1024)) << " MB; pool capped at "
<< (total_capacity_bytes() / (1024 * 1024))
<< " MB across " << sub_pools_.size() << " sub-buffer(s) — growth disabled";
growth_disabled_ = true;
return false;
}
BufferPool::Slice BufferPool::alloc(uint64_t size, uint64_t align) {
Slice out;
if (size == 0 || align == 0) return out;
// First-fit across all sub-buffers. When none fits, try to grow by
// adding another sub-buffer and retry once.
for (int attempt = 0; attempt < 2; ++attempt) {
for (size_t sp_idx = 0; sp_idx < sub_pools_.size(); ++sp_idx) {
SubPool& sp = sub_pools_[sp_idx];
for (size_t i = 0; i < sp.free_ranges.size(); ++i) {
const FreeRange& r = sp.free_ranges[i];
const uint64_t aligned = (r.offset + (align - 1)) & ~(align - 1);
const uint64_t pad = aligned - r.offset;
if (pad >= r.size) continue;
if (size > r.size - pad) continue;
const uint64_t post_off = aligned + size;
const uint64_t post_size = (r.offset + r.size) - post_off;
if (pad == 0 && post_size == 0) {
sp.free_ranges.erase(sp.free_ranges.begin() + i);
} else if (pad == 0) {
sp.free_ranges[i] = {post_off, post_size};
} else if (post_size == 0) {
sp.free_ranges[i] = {r.offset, pad};
} else {
sp.free_ranges[i] = {r.offset, pad};
sp.free_ranges.insert(sp.free_ranges.begin() + i + 1,
{post_off, post_size});
}
sp.used += size;
out.buffer = sp.buffer;
out.offset = aligned;
out.size = size;
out.sub_idx = int(sp_idx);
return out;
}
}
// Existing sub-buffers can't fit. Grow once before giving up.
if (attempt == 0) {
if (!addSubBuffer()) break;
}
}
return out;
}
void BufferPool::free(const Slice& s) {
if (!s.valid()) return;
if (s.sub_idx < 0 || size_t(s.sub_idx) >= sub_pools_.size()) return;
SubPool& sp = sub_pools_[size_t(s.sub_idx)];
assert(s.offset + s.size <= sp.capacity);
size_t i = 0;
while (i < sp.free_ranges.size() && sp.free_ranges[i].offset < s.offset) ++i;
sp.free_ranges.insert(sp.free_ranges.begin() + i, {s.offset, s.size});
sp.used -= s.size;
if (i + 1 < sp.free_ranges.size()
&& sp.free_ranges[i].offset + sp.free_ranges[i].size == sp.free_ranges[i + 1].offset) {
sp.free_ranges[i].size += sp.free_ranges[i + 1].size;
sp.free_ranges.erase(sp.free_ranges.begin() + i + 1);
}
if (i > 0
&& sp.free_ranges[i - 1].offset + sp.free_ranges[i - 1].size == sp.free_ranges[i].offset) {
sp.free_ranges[i - 1].size += sp.free_ranges[i].size;
sp.free_ranges.erase(sp.free_ranges.begin() + i);
}
}
uint64_t BufferPool::total_capacity_bytes() const {
uint64_t s = 0;
for (const auto& sp : sub_pools_) s += sp.capacity;
return s;
}
uint64_t BufferPool::total_used_bytes() const {
uint64_t s = 0;
for (const auto& sp : sub_pools_) s += sp.used;
return s;
}
uint64_t BufferPool::largest_free_run_bytes() const {
uint64_t m = 0;
for (const auto& sp : sub_pools_) {
for (const auto& r : sp.free_ranges) {
if (r.size > m) m = r.size;
}
}
return m;
}
+154
View File
@@ -0,0 +1,154 @@
/********************************************************************************
* *
* 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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef WGPUBUFFERPOOL_H
#define WGPUBUFFERPOOL_H
#include <webgpu/webgpu.h>
#include <cstdint>
#include <string>
#include <vector>
// Multi-sub-buffer sub-allocator. Owns one or more fixed-size WGPUBuffers
// and hands out byte ranges within them.
//
// Why multiple sub-buffers: WebGPU caps any single buffer at
// `limits.maxBufferSize`, which on wgpu-native + Vulkan tops out
// around 2 GB regardless of how much GPU memory exists. The GL backend
// reaches 4+ GB by letting the driver sub-allocate across many
// VkDeviceMemory blocks behind one logical GL buffer; here we do the
// same explicitly — `per_sub_buffer_capacity` (set from a probe) is the
// largest single buffer that allocates cleanly, and the pool grows
// lazily by adding more sub-buffers of that size when alloc demand
// exceeds what existing sub-buffers can fit.
//
// Lifetime model: alloc/free are immediate. WebGPU guarantees that
// queue.writeBuffer to a just-freed range is correctly serialised against
// any prior submitted GPU reads — we never need to fence frees ourselves.
//
// Allocator: per-sub-buffer sorted free list with adjacent-range
// coalescing, first-fit across sub-buffers. Adequate for the chunk
// workload (a few hundred allocations of broadly similar size).
class BufferPool {
public:
// A handle to a previously-allocated range. Includes the underlying
// sub-buffer so callers (bind-group builders, queueWriteBuffer) can
// address the correct buffer; includes sub_idx so free() knows which
// sub-pool's bookkeeping to update.
struct Slice {
WGPUBuffer buffer = nullptr;
uint64_t offset = 0;
uint64_t size = 0;
int sub_idx = -1;
bool valid() const { return size > 0 && buffer != nullptr; }
};
BufferPool() = default;
~BufferPool();
BufferPool(const BufferPool&) = delete;
BufferPool& operator=(const BufferPool&) = delete;
// Record the device + usage + sub-buffer size. Does NOT allocate any
// sub-buffer here — that happens lazily on first alloc(). `instance`
// is needed so the pool can drain async PopErrorScope events when
// probing whether a new sub-buffer can be created.
void configure(WGPUInstance instance, WGPUDevice device,
WGPUBufferUsage usage,
uint64_t per_sub_buffer_capacity,
const char* label_prefix);
void destroy();
// Sub-allocate a range of `size` bytes, aligned to `align` (must be
// a power of two; typical: 256 for storage-buffer binding offsets).
// Tries every existing sub-buffer; if none can fit, attempts to add
// a new sub-buffer at per_sub_buffer_capacity. Returns an invalid
// Slice (size == 0) if no sub-buffer fits and growth fails.
Slice alloc(uint64_t size, uint64_t align);
// Return a slice to the free list. Coalesces with adjacent free
// ranges in the same sub-buffer.
void free(const Slice& s);
// Tally summed across every sub-buffer.
uint64_t total_capacity_bytes() const;
uint64_t total_used_bytes() const;
uint64_t total_free_bytes() const { return total_capacity_bytes() - total_used_bytes(); }
// Largest contiguous free run across all sub-buffers. Useful for
// evictor heuristics ("can this allocation even fit, ever, without
// eviction or growth?").
uint64_t largest_free_run_bytes() const;
// Per-sub-buffer count, for diagnostics / logging.
size_t sub_buffer_count() const { return sub_pools_.size(); }
uint64_t per_sub_buffer_capacity_bytes() const { return per_sub_buffer_capacity_; }
// Best estimate of the size a *future* sub-buffer would land at:
// last_growth_size_ if we've ever grown (or just been configured),
// else the configured per_sub_buffer_capacity. After the driver
// refuses a size, halve-on-failure in addSubBuffer pushes this down
// so callers' "can this chunk fit via growth?" check stays honest.
uint64_t next_growth_size_bytes() const {
return last_growth_size_ > 0 ? last_growth_size_ : per_sub_buffer_capacity_;
}
// Whether the pool can still attempt to add a sub-buffer. Flips to
// false the first time addSubBuffer is refused even at the floor
// size — eviction callers need this to know whether a future alloc
// could rescue them, or whether eviction is the only path.
bool can_grow() const { return !growth_disabled_ && per_sub_buffer_capacity_ > 0; }
private:
struct FreeRange { uint64_t offset; uint64_t size; };
struct SubPool {
WGPUBuffer buffer = nullptr;
uint64_t capacity = 0;
uint64_t used = 0;
std::vector<FreeRange> free_ranges;
};
// Append a new sub-buffer to the pool. Starts at last_growth_size_
// (initially per_sub_buffer_capacity_) and halves on driver refusal
// before giving up — many Vulkan drivers cap single VkDeviceMemory
// allocations at a couple GB (e.g. NVIDIA: maxStorageBufferBindingSize
// is exactly 2 GB on consumer GeForce cards) or refuse big contiguous
// allocations once heap is fragmented, but happily grant smaller ones.
// Halving turns "stop at first refused 2 GB" into "2 GB + 1 GB + …",
// which on a 4 GB card lets us reach 3 GB total instead of 2 GB.
// Wrapped in OOM/Validation error scopes so failed attempts don't
// take the device down. Returns true on success at some size
// ≥ MIN_SUB_BUFFER_BYTES; false only when even the minimum size is
// refused, at which point growth_disabled_ latches.
bool addSubBuffer();
std::vector<SubPool> sub_pools_;
WGPUInstance instance_ = nullptr;
WGPUDevice device_ = nullptr;
WGPUBufferUsage usage_ = 0;
uint64_t per_sub_buffer_capacity_ = 0;
// The largest size addSubBuffer last *succeeded* at, in bytes.
// Starts at per_sub_buffer_capacity_ (the probe's discovered max)
// and decays as the driver refuses larger allocations. Future grow
// attempts start from here rather than re-trying the max every
// time — once the driver has refused 2 GB, retrying 2 GB on every
// subsequent grow is wasted work.
uint64_t last_growth_size_ = 0;
bool growth_disabled_ = false;
std::string label_prefix_;
};
#endif // WGPUBUFFERPOOL_H
+129 -10
View File
@@ -20,15 +20,105 @@
message("Running CMakeLists.txt in /src/ifcviewer")
set(QT_VERSION 6 CACHE STRING "Qt version")
# IfcViewerLib always needs OpenGL in addition to Core/Gui/Widgets. We don't
# use the CACHE'd QT_COMPONENTS here because it may have been set by another
# target (e.g. qtviewer) without the OpenGL component.
find_package(Qt${QT_VERSION} COMPONENTS Core Gui REQUIRED PATHS ${QT_DIR})
# Eigen3 — used by Federation matrices, ViewportWindow's instance compose,
# the per-model coordinate-operation matrices stored on ModelGpuData, and
# everywhere a 4x4 transform shows up.
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")
# Pick the right release archive for the host platform.
if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
if(CMAKE_SYSTEM_PROCESSOR MATCHES "ARM64|aarch64")
set(_wgpu_archive "wgpu-windows-aarch64-msvc-release.zip")
else()
set(_wgpu_archive "wgpu-windows-x86_64-msvc-release.zip")
endif()
set(_wgpu_lib "wgpu_native.dll.lib")
set(_wgpu_runtime "wgpu_native.dll")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
if(CMAKE_SYSTEM_PROCESSOR MATCHES "arm64|aarch64")
set(_wgpu_archive "wgpu-macos-aarch64-release.zip")
else()
set(_wgpu_archive "wgpu-macos-x86_64-release.zip")
endif()
set(_wgpu_lib "libwgpu_native.dylib")
else() # Linux + BSDs
if(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64|arm64")
set(_wgpu_archive "wgpu-linux-aarch64-release.zip")
else()
set(_wgpu_archive "wgpu-linux-x86_64-release.zip")
endif()
set(_wgpu_lib "libwgpu_native.so")
endif()
include(FetchContent)
FetchContent_Declare(
wgpu_native
URL https://github.com/gfx-rs/wgpu-native/releases/download/${WGPU_NATIVE_VERSION}/${_wgpu_archive}
DOWNLOAD_NO_PROGRESS FALSE
)
FetchContent_MakeAvailable(wgpu_native)
# Release archive layout: include/webgpu/*.h and lib/<libname>.
#
# The Linux .so shipped in the v29 release has no DT_SONAME, which causes
# CMake to bake the relative IMPORTED_LOCATION path into DT_NEEDED. We patch
# the SONAME in once at configure time so dependents get a clean
# libwgpu_native.so reference, and pin the executable's rpath to the lib dir.
if(UNIX AND NOT APPLE)
find_program(PATCHELF_EXECUTABLE patchelf)
if(PATCHELF_EXECUTABLE)
execute_process(
COMMAND ${PATCHELF_EXECUTABLE} --set-soname "${_wgpu_lib}"
"${wgpu_native_SOURCE_DIR}/lib/${_wgpu_lib}"
RESULT_VARIABLE _patchelf_rc
)
if(NOT _patchelf_rc EQUAL 0)
message(WARNING "patchelf --set-soname failed on libwgpu_native.so")
endif()
else()
message(WARNING
"patchelf not found; libwgpu_native.so will be linked with a "
"relative DT_NEEDED. Install patchelf to fix.")
endif()
endif()
add_library(wgpu_native SHARED IMPORTED GLOBAL)
set_target_properties(wgpu_native PROPERTIES
IMPORTED_LOCATION "${wgpu_native_SOURCE_DIR}/lib/${_wgpu_lib}"
INTERFACE_INCLUDE_DIRECTORIES "${wgpu_native_SOURCE_DIR}/include"
)
if(WIN32)
# On Windows the .lib is the import library; the .dll is the runtime.
set_target_properties(wgpu_native PROPERTIES
IMPORTED_IMPLIB "${wgpu_native_SOURCE_DIR}/lib/${_wgpu_lib}"
IMPORTED_LOCATION "${wgpu_native_SOURCE_DIR}/lib/${_wgpu_runtime}"
)
endif()
# Expose the lib dir so dependents can put it on their rpath.
set(WGPU_NATIVE_LIB_DIR "${wgpu_native_SOURCE_DIR}/lib" CACHE INTERNAL
"Directory containing the wgpu-native shared library")
file(GLOB IFCVIEWER_CPP_FILES ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp)
file(GLOB IFCVIEWER_H_FILES ${CMAKE_CURRENT_SOURCE_DIR}/*.h)
file(GLOB IFCVIEWER_H_FILES ${CMAKE_CURRENT_SOURCE_DIR}/*.h)
set(IFCVIEWER_FILES ${IFCVIEWER_CPP_FILES} ${IFCVIEWER_H_FILES})
# Cocoa bridge for the CAMetalLayer surface attach — Objective-C++.
# Only compiled into the target on Apple platforms; CMake handles `.mm`
# natively once OBJCXX is enabled.
if(APPLE)
enable_language(OBJCXX)
list(APPEND IFCVIEWER_FILES
${CMAKE_CURRENT_SOURCE_DIR}/MetalSurface_mac.mm
)
endif()
add_library(IfcViewer STATIC ${IFCVIEWER_FILES})
set_target_properties(IfcViewer PROPERTIES
@@ -38,9 +128,9 @@ set_target_properties(IfcViewer PROPERTIES
)
if (WITH_MESH_OPTIMIZER)
find_package(meshoptimizer REQUIRED)
set(MESH_OPTIMIZER_LIB meshoptimizer::meshoptimizer)
target_compile_definitions(IfcViewer PUBLIC -DWITH_MESH_OPTIMIZER)
find_package(meshoptimizer REQUIRED)
set(MESH_OPTIMIZER_LIB meshoptimizer::meshoptimizer)
target_compile_definitions(IfcViewer PUBLIC -DWITH_MESH_OPTIMIZER)
endif()
# Consumers include headers as `#include "ViewportWindow.h"`, so expose this
@@ -57,23 +147,52 @@ target_link_libraries(IfcViewer PUBLIC
${CGAL_LIBRARIES}
Qt${QT_VERSION}::Core
Qt${QT_VERSION}::Gui
Eigen3::Eigen
wgpu_native
${MESH_OPTIMIZER_LIB}
# SceneLoader drives WgpuViewportWindow; the wgpu lib also provides
# the include path for WgpuViewportWindow.h that SceneLoader.h pulls in.
IfcViewerWgpu
)
# Qt platform-handle access (QNativeInterface::QX11Application etc.) is in
# the public Gui headers in Qt 6.2+, no PRIVATE_INCLUDE_DIRS needed.
if(UNIX AND NOT APPLE)
find_package(Threads REQUIRED)
target_link_libraries(IfcViewer PUBLIC Threads::Threads)
endif()
# Cocoa + QuartzCore for MetalSurface_mac.mm (NSView, CAMetalLayer).
if(APPLE)
target_link_libraries(IfcViewer PUBLIC
"-framework Cocoa"
"-framework QuartzCore"
)
endif()
install(TARGETS IfcViewer EXPORT ${IFCOPENSHELL_EXPORT_TARGETS})
install(FILES ${IFCVIEWER_H_FILES}
DESTINATION ${INCLUDEDIR}/ifcviewer
)
# Install the wgpu_native shared library so the deployed runtime can find it.
# At build/run-from-build-tree time CMake adds wgpu_native_SOURCE_DIR to the
# binary's rpath automatically (IMPORTED_LOCATION dirname).
#
# macOS: drop libwgpu_native.dylib straight into BonsaiViewer.app's
# Frameworks/. The exe has `LC_LOAD_DYLIB @rpath/libwgpu_native.dylib`
# (baked from the dylib's install_name), and BonsaiViewer's
# INSTALL_RPATH is set to @executable_path/../Frameworks — together
# they resolve at launch without depending on macdeployqt to follow
# non-Qt @rpath references.
if(WIN32)
install(FILES "${wgpu_native_SOURCE_DIR}/lib/${_wgpu_runtime}" DESTINATION bin)
elseif(APPLE AND BUILD_BONSAIVIEWER)
install(FILES "${wgpu_native_SOURCE_DIR}/lib/${_wgpu_lib}"
DESTINATION "BonsaiViewer.app/Contents/Frameworks")
else()
install(FILES "${wgpu_native_SOURCE_DIR}/lib/${_wgpu_lib}" DESTINATION lib)
endif()
if(BUILD_BONSAIVIEWER_TESTS)
add_subdirectory(tests)
endif()
+738
View File
@@ -0,0 +1,738 @@
/********************************************************************************
* *
* 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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "LengthMeasurement.h"
#include "OverlayRenderer.h"
#include <QDebug>
#include <QStringList>
#include <QtGlobal>
#include <QtMath>
#include <algorithm>
#include <cmath>
#include <cstring>
#include <limits>
#include <queue>
#include <unordered_map>
#include <unordered_set>
namespace {
double dist3(const std::array<float, 3>& a, const std::array<float, 3>& b) {
const double dx = double(b[0]) - a[0];
const double dy = double(b[1]) - a[1];
const double dz = double(b[2]) - a[2];
return std::sqrt(dx*dx + dy*dy + dz*dz);
}
double triArea3(const std::array<float, 3>& a,
const std::array<float, 3>& b,
const std::array<float, 3>& c) {
const double bax = double(b[0]) - a[0];
const double bay = double(b[1]) - a[1];
const double baz = double(b[2]) - a[2];
const double cax = double(c[0]) - a[0];
const double cay = double(c[1]) - a[1];
const double caz = double(c[2]) - a[2];
const double nx = bay * caz - baz * cay;
const double ny = baz * cax - bax * caz;
const double nz = bax * cay - bay * cax;
return 0.5 * std::sqrt(nx*nx + ny*ny + nz*nz);
}
// Symmetric 3×3 eigendecomposition via Jacobi rotations. Tiny inline
// alternative to pulling Eigen into the wgpu module for the single
// polygon-planarity check. Converges in <10 sweeps for 3×3.
void jacobiEigen3(double m00, double m01, double m02,
double m11, double m12, double m22,
double eigvals[3], double eigvecs[3][3]) {
double a[3][3] = {{m00, m01, m02},
{m01, m11, m12},
{m02, m12, m22}};
double v[3][3] = {{1, 0, 0}, {0, 1, 0}, {0, 0, 1}};
for (int iter = 0; iter < 50; ++iter) {
// Pick largest off-diagonal magnitude.
int p = 0, q = 1;
double off = std::abs(a[0][1]);
if (std::abs(a[0][2]) > off) { p = 0; q = 2; off = std::abs(a[0][2]); }
if (std::abs(a[1][2]) > off) { p = 1; q = 2; off = std::abs(a[1][2]); }
if (off < 1e-12) break;
const double app = a[p][p], aqq = a[q][q], apq = a[p][q];
const double theta = (aqq - app) / (2.0 * apq);
double t = (theta >= 0.0) ? 1.0 / (theta + std::sqrt(1.0 + theta*theta))
: 1.0 / (theta - std::sqrt(1.0 + theta*theta));
const double c = 1.0 / std::sqrt(1.0 + t*t);
const double s = t * c;
a[p][p] = app - t * apq;
a[q][q] = aqq + t * apq;
a[p][q] = a[q][p] = 0.0;
for (int k = 0; k < 3; ++k) {
if (k == p || k == q) continue;
const double akp = a[k][p], akq = a[k][q];
a[k][p] = a[p][k] = c * akp - s * akq;
a[k][q] = a[q][k] = s * akp + c * akq;
}
for (int k = 0; k < 3; ++k) {
const double vkp = v[k][p], vkq = v[k][q];
v[k][p] = c * vkp - s * vkq;
v[k][q] = s * vkp + c * vkq;
}
}
eigvals[0] = a[0][0];
eigvals[1] = a[1][1];
eigvals[2] = a[2][2];
std::memcpy(eigvecs, v, sizeof(v));
}
struct PolygonAreaResult {
double area_m2;
const char* method;
};
PolygonAreaResult polygonArea(const std::vector<std::array<float, 3>>& pts) {
const size_t n = pts.size();
// Centroid + bounding box (for the planarity threshold).
double centroid[3] = {0, 0, 0};
double bbox_min[3] = { std::numeric_limits<double>::infinity(),
std::numeric_limits<double>::infinity(),
std::numeric_limits<double>::infinity() };
double bbox_max[3] = {-std::numeric_limits<double>::infinity(),
-std::numeric_limits<double>::infinity(),
-std::numeric_limits<double>::infinity() };
for (const auto& p : pts) {
centroid[0] += p[0]; centroid[1] += p[1]; centroid[2] += p[2];
bbox_min[0] = std::min(bbox_min[0], double(p[0]));
bbox_min[1] = std::min(bbox_min[1], double(p[1]));
bbox_min[2] = std::min(bbox_min[2], double(p[2]));
bbox_max[0] = std::max(bbox_max[0], double(p[0]));
bbox_max[1] = std::max(bbox_max[1], double(p[1]));
bbox_max[2] = std::max(bbox_max[2], double(p[2]));
}
centroid[0] /= double(n);
centroid[1] /= double(n);
centroid[2] /= double(n);
const double bbx = bbox_max[0] - bbox_min[0];
const double bby = bbox_max[1] - bbox_min[1];
const double bbz = bbox_max[2] - bbox_min[2];
const double bbox_diag = std::sqrt(bbx*bbx + bby*bby + bbz*bbz);
// 3×3 symmetric covariance. Smallest eigenvector → plane normal.
double c00 = 0, c01 = 0, c02 = 0, c11 = 0, c12 = 0, c22 = 0;
for (const auto& p : pts) {
const double dx = double(p[0]) - centroid[0];
const double dy = double(p[1]) - centroid[1];
const double dz = double(p[2]) - centroid[2];
c00 += dx*dx; c01 += dx*dy; c02 += dx*dz;
c11 += dy*dy; c12 += dy*dz; c22 += dz*dz;
}
double eigvals[3];
double eigvecs[3][3];
jacobiEigen3(c00, c01, c02, c11, c12, c22, eigvals, eigvecs);
int min_i = 0;
if (eigvals[1] < eigvals[min_i]) min_i = 1;
if (eigvals[2] < eigvals[min_i]) min_i = 2;
const double normal[3] = { eigvecs[0][min_i],
eigvecs[1][min_i],
eigvecs[2][min_i] };
double sq_sum = 0.0;
for (const auto& p : pts) {
const double d = (double(p[0]) - centroid[0]) * normal[0]
+ (double(p[1]) - centroid[1]) * normal[1]
+ (double(p[2]) - centroid[2]) * normal[2];
sq_sum += d * d;
}
const double rms = std::sqrt(sq_sum / double(n));
const bool planar = bbox_diag > 0.0 && (rms / bbox_diag) < 1e-3;
if (planar) {
// In-plane orthonormal basis. Cross with whichever world axis is
// least parallel to the normal so u doesn't collapse.
double u[3];
u[0] = normal[1] * 0.0 - normal[2] * 0.0; // normal × X
u[1] = normal[2] * 1.0 - normal[0] * 0.0;
u[2] = normal[0] * 0.0 - normal[1] * 1.0;
double ul2 = u[0]*u[0] + u[1]*u[1] + u[2]*u[2];
if (ul2 < 1e-6) {
u[0] = normal[1] * 0.0 - normal[2] * 1.0; // normal × Y
u[1] = normal[2] * 0.0 - normal[0] * 0.0;
u[2] = normal[0] * 1.0 - normal[1] * 0.0;
ul2 = u[0]*u[0] + u[1]*u[1] + u[2]*u[2];
}
const double ul = std::sqrt(ul2);
u[0] /= ul; u[1] /= ul; u[2] /= ul;
const double v[3] = {
normal[1]*u[2] - normal[2]*u[1],
normal[2]*u[0] - normal[0]*u[2],
normal[0]*u[1] - normal[1]*u[0]
};
// Project + shoelace.
std::vector<std::array<double, 2>> uv(n);
for (size_t i = 0; i < n; ++i) {
const double dx = double(pts[i][0]) - centroid[0];
const double dy = double(pts[i][1]) - centroid[1];
const double dz = double(pts[i][2]) - centroid[2];
uv[i][0] = dx*u[0] + dy*u[1] + dz*u[2];
uv[i][1] = dx*v[0] + dy*v[1] + dz*v[2];
}
double s = 0.0;
for (size_t i = 0; i < n; ++i) {
const auto& a = uv[i];
const auto& b = uv[(i + 1) % n];
s += a[0] * b[1] - b[0] * a[1];
}
return { 0.5 * std::abs(s), "planar" };
}
// Fan from p0 — heuristic for non-planar / star-shaped 3D loops.
double area = 0.0;
for (size_t i = 1; i + 1 < n; ++i) {
area += triArea3(pts[0], pts[i], pts[i + 1]);
}
return { area, "fan-triangulated (non-planar)" };
}
uint64_t edgeKey(uint32_t a, uint32_t b) {
if (a > b) std::swap(a, b);
return (uint64_t(a) << 32) | uint64_t(b);
}
double pointTriangleDistSq(const float p[3],
const float a[3], const float b[3], const float c[3]) {
auto sub = [](const float u[3], const float v[3], double r[3]) {
r[0] = double(u[0]) - v[0];
r[1] = double(u[1]) - v[1];
r[2] = double(u[2]) - v[2];
};
auto dot = [](const double u[3], const double v[3]) {
return u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
};
double ab[3], ac[3], ap[3];
sub(b, a, ab);
sub(c, a, ac);
sub(p, a, ap);
const double d1 = dot(ab, ap);
const double d2 = dot(ac, ap);
if (d1 <= 0.0 && d2 <= 0.0) {
return ap[0]*ap[0] + ap[1]*ap[1] + ap[2]*ap[2];
}
double bp[3];
sub(p, b, bp);
const double d3 = dot(ab, bp);
const double d4 = dot(ac, bp);
if (d3 >= 0.0 && d4 <= d3) {
return bp[0]*bp[0] + bp[1]*bp[1] + bp[2]*bp[2];
}
const double vc = d1 * d4 - d3 * d2;
if (vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0) {
const double v = d1 / (d1 - d3);
const double qx = ap[0] - v * ab[0];
const double qy = ap[1] - v * ab[1];
const double qz = ap[2] - v * ab[2];
return qx*qx + qy*qy + qz*qz;
}
double cp[3];
sub(p, c, cp);
const double d5 = dot(ab, cp);
const double d6 = dot(ac, cp);
if (d6 >= 0.0 && d5 <= d6) {
return cp[0]*cp[0] + cp[1]*cp[1] + cp[2]*cp[2];
}
const double vb = d5 * d2 - d1 * d6;
if (vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0) {
const double w = d2 / (d2 - d6);
const double qx = ap[0] - w * ac[0];
const double qy = ap[1] - w * ac[1];
const double qz = ap[2] - w * ac[2];
return qx*qx + qy*qy + qz*qz;
}
const double va = d3 * d6 - d5 * d4;
if (va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0) {
const double w = (d4 - d3) / ((d4 - d3) + (d5 - d6));
const double qx = double(b[0]) + w * (double(c[0]) - b[0]) - p[0];
const double qy = double(b[1]) + w * (double(c[1]) - b[1]) - p[1];
const double qz = double(b[2]) + w * (double(c[2]) - b[2]) - p[2];
return qx*qx + qy*qy + qz*qz;
}
const double denom = 1.0 / (va + vb + vc);
const double v = vb * denom;
const double w = vc * denom;
const double qx = double(a[0]) + v * ab[0] + w * ac[0] - p[0];
const double qy = double(a[1]) + v * ab[1] + w * ac[1] - p[1];
const double qz = double(a[2]) + v * ab[2] + w * ac[2] - p[2];
return qx*qx + qy*qy + qz*qz;
}
constexpr double kCoplanarDot = 0.9999; // ~0.81° tolerance
// Visual style — reused across all length-tool overlay paths.
constexpr float LINE_WIDTH = 1.5f;
constexpr float LINE_HALO = 0.5f;
constexpr float DOT_SIZE = 6.0f;
constexpr float DOT_HALO = 1.0f;
constexpr float DASH_PERIOD = 9.0f; // px
constexpr float DASH_ON_RATIO = 0.55f; // 5 on, 4 off
OverlayRenderer::LineGroup makeGroup(std::vector<float> xyz,
float r, float g, float b,
bool dashed = false) {
OverlayRenderer::LineGroup gp;
gp.world_xyz = std::move(xyz);
gp.color[0] = r; gp.color[1] = g; gp.color[2] = b; gp.color[3] = 1.0f;
gp.stroke_color[0] = 0.0f; gp.stroke_color[1] = 0.0f;
gp.stroke_color[2] = 0.0f; gp.stroke_color[3] = 1.0f;
gp.line_width = LINE_WIDTH;
gp.stroke_extra = LINE_HALO;
gp.dash_period_px = dashed ? DASH_PERIOD : 0.0f;
gp.dash_on_ratio = DASH_ON_RATIO;
return gp;
}
void pushDot(std::vector<float>& xyz, const std::array<float, 3>& p) {
xyz.push_back(p[0]); xyz.push_back(p[1]); xyz.push_back(p[2]);
}
void pushSeg(std::vector<float>& xyz,
const std::array<float, 3>& a,
const std::array<float, 3>& b) {
xyz.insert(xyz.end(), a.begin(), a.end());
xyz.insert(xyz.end(), b.begin(), b.end());
}
OverlayRenderer::Label makeLabel(const std::array<float, 3>& a,
const std::array<float, 3>& b,
const QString& text) {
OverlayRenderer::Label lbl;
lbl.world_pos[0] = 0.5f * (a[0] + b[0]);
lbl.world_pos[1] = 0.5f * (a[1] + b[1]);
lbl.world_pos[2] = 0.5f * (a[2] + b[2]);
lbl.text = text;
return lbl;
}
void pushDots(ViewportWindow& vp, const std::vector<float>& xyz) {
vp.setOverlayPoints(xyz,
/*inner*/ 1.0f, 1.0f, 1.0f, 1.0f,
/*size*/ DOT_SIZE,
/*stroke*/ 0.0f, 0.0f, 0.0f, 1.0f,
/*extra*/ DOT_HALO);
}
const char* dominantAxisLabel(const float v[3]) {
const float ax = std::abs(v[0]);
const float ay = std::abs(v[1]);
const float az = std::abs(v[2]);
if (az >= ax && az >= ay) return "Z";
if (ax >= ay) return "X";
return "Y";
}
} // namespace
LengthMeasurement::LengthMeasurement() = default;
void LengthMeasurement::clear(ViewportWindow& vp) {
points_.clear();
normals_.clear();
vp.setOverlayPoints({}, 0,0,0,0, 0, 0,0,0,0, 0);
vp.setOverlayLines({});
vp.setOverlayLabels({});
vp.setHudText(QString());
}
void LengthMeasurement::onPick(ViewportWindow& vp,
int x_phys, int y_phys, bool /*alt*/) {
ViewportWindow::MeshLocalPick pick;
if (!vp.pickMeshLocalAt(x_phys, y_phys, pick)) return;
points_.push_back({pick.world_pos[0], pick.world_pos[1], pick.world_pos[2]});
normals_.push_back({pick.world_normal[0], pick.world_normal[1], pick.world_normal[2]});
if (points_.size() == 1) {
first_pick_ = pick; // record info the laser BFS needs
}
rebuildOverlay(vp);
}
void LengthMeasurement::removeLastPoint(ViewportWindow& vp) {
if (points_.empty()) return;
points_.pop_back();
if (!normals_.empty()) normals_.pop_back();
rebuildOverlay(vp);
}
void LengthMeasurement::rebuildOverlay(ViewportWindow& vp) {
if (points_.size() == 1 && normals_.size() == 1) {
rebuildLaserOverlay(vp);
return;
}
std::vector<float> pts_xyz;
pts_xyz.reserve(points_.size() * 3);
for (const auto& p : points_) pushDot(pts_xyz, p);
pushDots(vp, pts_xyz);
std::vector<OverlayRenderer::LineGroup> groups;
std::vector<OverlayRenderer::Label> labels;
const size_t n = points_.size();
if (n == 2) {
const auto& a = points_[0];
const auto& b = points_[1];
groups.push_back(makeGroup({a[0], a[1], a[2], b[0], b[1], b[2]},
1.0f, 1.0f, 1.0f));
labels.push_back(makeLabel(a, b,
QString::number(dist3(a, b), 'f', 3) + QStringLiteral(" m")));
const std::array<float, 3> kx = {b[0], a[1], a[2]};
const std::array<float, 3> ky = {b[0], b[1], a[2]};
const double dx = std::abs(double(b[0]) - a[0]);
const double dy = std::abs(double(b[1]) - a[1]);
const double dz = std::abs(double(b[2]) - a[2]);
if (dx > 1e-6) {
groups.push_back(makeGroup({a[0],a[1],a[2], kx[0],kx[1],kx[2]},
1.00f, 0.30f, 0.30f));
labels.push_back(makeLabel(a, kx,
QStringLiteral("ΔX: ") + QString::number(dx, 'f', 3) + QStringLiteral(" m")));
}
if (dy > 1e-6) {
groups.push_back(makeGroup({kx[0],kx[1],kx[2], ky[0],ky[1],ky[2]},
0.30f, 0.90f, 0.30f));
labels.push_back(makeLabel(kx, ky,
QStringLiteral("ΔY: ") + QString::number(dy, 'f', 3) + QStringLiteral(" m")));
}
if (dz > 1e-6) {
groups.push_back(makeGroup({ky[0],ky[1],ky[2], b[0],b[1],b[2]},
0.30f, 0.55f, 1.00f));
labels.push_back(makeLabel(ky, b,
QStringLiteral("ΔZ: ") + QString::number(dz, 'f', 3) + QStringLiteral(" m")));
}
if (normals_.size() == 2) {
const auto& na = normals_[0];
const auto& nb = normals_[1];
const double dot_nn = double(na[0])*nb[0]
+ double(na[1])*nb[1]
+ double(na[2])*nb[2];
if (std::abs(dot_nn) > 0.95) {
const float sign = dot_nn >= 0.0 ? 1.0f : -1.0f;
float n_avg[3] = {
0.5f * (na[0] + sign * nb[0]),
0.5f * (na[1] + sign * nb[1]),
0.5f * (na[2] + sign * nb[2]),
};
const float len = std::sqrt(n_avg[0]*n_avg[0]
+ n_avg[1]*n_avg[1]
+ n_avg[2]*n_avg[2]);
if (len > 1e-6f) {
n_avg[0] /= len; n_avg[1] /= len; n_avg[2] /= len;
}
const double abx = double(b[0]) - a[0];
const double aby = double(b[1]) - a[1];
const double abz = double(b[2]) - a[2];
const double perp = abx*n_avg[0] + aby*n_avg[1] + abz*n_avg[2];
const double abs_perp = std::abs(perp);
constexpr double kAxisCollapseTol = 1e-3;
const bool redundant =
std::abs(abs_perp - dx) < kAxisCollapseTol
|| std::abs(abs_perp - dy) < kAxisCollapseTol
|| std::abs(abs_perp - dz) < kAxisCollapseTol;
if (abs_perp > 1e-6 && !redundant) {
const std::array<float, 3> tip = {
float(a[0] + perp * n_avg[0]),
float(a[1] + perp * n_avg[1]),
float(a[2] + perp * n_avg[2]),
};
auto perp_grp = makeGroup(
{a[0],a[1],a[2], tip[0],tip[1],tip[2]},
1.0f, 1.0f, 1.0f, /*dashed*/ true);
groups.push_back(perp_grp);
labels.push_back(makeLabel(a, tip,
QStringLiteral("perp: ") + QString::number(abs_perp, 'f', 3) + QStringLiteral(" m")));
}
}
}
} else if (n >= 3) {
std::vector<float> seg_xyz;
seg_xyz.reserve(n * 6);
labels.reserve(n);
auto addSeg = [&](const std::array<float, 3>& a,
const std::array<float, 3>& b) {
pushSeg(seg_xyz, a, b);
labels.push_back(makeLabel(a, b,
QString::number(dist3(a, b), 'f', 3) + QStringLiteral(" m")));
};
for (size_t i = 0; i + 1 < n; ++i) addSeg(points_[i], points_[i + 1]);
if (n >= 4) addSeg(points_[n - 1], points_[0]);
groups.push_back(makeGroup(std::move(seg_xyz), 1.0f, 1.0f, 1.0f));
}
vp.setOverlayLines(groups);
vp.setOverlayLabels(labels);
vp.setHudText(formatReadout());
}
void LengthMeasurement::rebuildLaserOverlay(ViewportWindow& vp) {
const auto& wp = first_pick_.world_pos;
const auto& n = first_pick_.world_normal;
// Tangent basis in world space.
constexpr float WORLD_UP[3] = {0.0f, 0.0f, 1.0f};
const float dot_un = WORLD_UP[0]*n[0] + WORLD_UP[1]*n[1] + WORLD_UP[2]*n[2];
float t1[3] = {
WORLD_UP[0] - dot_un * n[0],
WORLD_UP[1] - dot_un * n[1],
WORLD_UP[2] - dot_un * n[2],
};
float t1_len = std::sqrt(t1[0]*t1[0] + t1[1]*t1[1] + t1[2]*t1[2]);
if (t1_len < 0.1f) {
constexpr float WORLD_X[3] = {1.0f, 0.0f, 0.0f};
const float dot_xn = WORLD_X[0]*n[0] + WORLD_X[1]*n[1] + WORLD_X[2]*n[2];
t1[0] = WORLD_X[0] - dot_xn * n[0];
t1[1] = WORLD_X[1] - dot_xn * n[1];
t1[2] = WORLD_X[2] - dot_xn * n[2];
t1_len = std::sqrt(t1[0]*t1[0] + t1[1]*t1[1] + t1[2]*t1[2]);
}
if (t1_len > 1e-6f) {
t1[0] /= t1_len; t1[1] /= t1_len; t1[2] /= t1_len;
}
const float t2[3] = {
n[1]*t1[2] - n[2]*t1[1],
n[2]*t1[0] - n[0]*t1[2],
n[0]*t1[1] - n[1]*t1[0],
};
std::vector<OverlayRenderer::LineGroup> groups;
std::vector<OverlayRenderer::Label> labels;
QStringList hud_lines;
hud_lines << QStringLiteral("Laser measure (click another point for distance)");
double enh[3] = {0.0, 0.0, 0.0};
if (vp.meshLocalToGlobal(first_pick_.object_id, first_pick_.mesh_local, enh)) {
hud_lines << QStringLiteral("ENH: %1, %2, %3")
.arg(enh[0], 0, 'f', 3)
.arg(enh[1], 0, 'f', 3)
.arg(enh[2], 0, 'f', 3);
}
// Coplanar-patch BFS for face extent.
ViewportWindow::MeshTriangles tris;
bool have_extent = false;
double min_t1 = 0.0, max_t1 = 0.0, min_t2 = 0.0, max_t2 = 0.0;
if (vp.readbackMeshTriangles(first_pick_.model_id, first_pick_.mesh_id, tris)) {
const size_t n_verts = tris.positions.size() / 3;
const size_t n_tris = tris.indices.size() / 3;
if (n_tris > 0) {
std::vector<float> wv(n_verts * 3);
const float* M = first_pick_.composed_transform;
for (size_t i = 0; i < n_verts; ++i) {
const float* p = &tris.positions[i * 3];
wv[i*3 + 0] = M[0]*p[0] + M[4]*p[1] + M[8]*p[2] + M[12];
wv[i*3 + 1] = M[1]*p[0] + M[5]*p[1] + M[9]*p[2] + M[13];
wv[i*3 + 2] = M[2]*p[0] + M[6]*p[1] + M[10]*p[2] + M[14];
}
std::vector<std::array<float, 3>> tri_n(n_tris);
std::unordered_map<uint64_t, std::vector<uint32_t>> edges;
edges.reserve(n_tris * 3);
for (size_t t = 0; t < n_tris; ++t) {
const uint32_t ia = tris.indices[3*t + 0];
const uint32_t ib = tris.indices[3*t + 1];
const uint32_t ic = tris.indices[3*t + 2];
const float* a = &wv[3*ia];
const float* b = &wv[3*ib];
const float* c = &wv[3*ic];
const float bax = b[0]-a[0], bay = b[1]-a[1], baz = b[2]-a[2];
const float cax = c[0]-a[0], cay = c[1]-a[1], caz = c[2]-a[2];
float nx = bay*caz - baz*cay;
float ny = baz*cax - bax*caz;
float nz = bax*cay - bay*cax;
const float nl = std::sqrt(nx*nx + ny*ny + nz*nz);
if (nl > 0.0f) { nx /= nl; ny /= nl; nz /= nl; }
tri_n[t] = {nx, ny, nz};
edges[edgeKey(ia, ib)].push_back(uint32_t(t));
edges[edgeKey(ib, ic)].push_back(uint32_t(t));
edges[edgeKey(ic, ia)].push_back(uint32_t(t));
}
uint32_t seed = 0;
double best = std::numeric_limits<double>::infinity();
for (size_t t = 0; t < n_tris; ++t) {
const uint32_t ia = tris.indices[3*t + 0];
const uint32_t ib = tris.indices[3*t + 1];
const uint32_t ic = tris.indices[3*t + 2];
const double d = pointTriangleDistSq(
wp, &wv[3*ia], &wv[3*ib], &wv[3*ic]);
if (d < best) { best = d; seed = uint32_t(t); }
}
const auto& sn = tri_n[seed];
std::unordered_set<uint32_t> in_patch;
in_patch.insert(seed);
std::queue<uint32_t> frontier;
frontier.push(seed);
while (!frontier.empty()) {
const uint32_t t = frontier.front(); frontier.pop();
for (int e = 0; e < 3; ++e) {
const uint32_t ia = tris.indices[3*t + e];
const uint32_t ib = tris.indices[3*t + (e + 1) % 3];
auto it = edges.find(edgeKey(ia, ib));
if (it == edges.end()) continue;
for (uint32_t nt : it->second) {
if (nt == t || in_patch.count(nt)) continue;
const auto& nn = tri_n[nt];
const double dot = double(sn[0])*nn[0]
+ double(sn[1])*nn[1]
+ double(sn[2])*nn[2];
if (dot < kCoplanarDot) continue;
in_patch.insert(nt);
frontier.push(nt);
}
}
}
std::unordered_set<uint32_t> patch_verts;
for (uint32_t t : in_patch) {
patch_verts.insert(tris.indices[3*t + 0]);
patch_verts.insert(tris.indices[3*t + 1]);
patch_verts.insert(tris.indices[3*t + 2]);
}
for (uint32_t vi : patch_verts) {
const float* v = &wv[3 * vi];
const double dx = double(v[0]) - wp[0];
const double dy = double(v[1]) - wp[1];
const double dz = double(v[2]) - wp[2];
const double a1 = dx*t1[0] + dy*t1[1] + dz*t1[2];
const double a2 = dx*t2[0] + dy*t2[1] + dz*t2[2];
if (!have_extent) {
min_t1 = max_t1 = a1;
min_t2 = max_t2 = a2;
have_extent = true;
} else {
min_t1 = std::min(min_t1, a1); max_t1 = std::max(max_t1, a1);
min_t2 = std::min(min_t2, a2); max_t2 = std::max(max_t2, a2);
}
}
}
}
auto pushBar = [&](const float t[3], double mn, double mx) {
const std::array<float, 3> a = {
float(wp[0] + mn * t[0]),
float(wp[1] + mn * t[1]),
float(wp[2] + mn * t[2]),
};
const std::array<float, 3> b = {
float(wp[0] + mx * t[0]),
float(wp[1] + mx * t[1]),
float(wp[2] + mx * t[2]),
};
const double extent = mx - mn;
const QString axis = QString::fromLatin1(dominantAxisLabel(t));
groups.push_back(makeGroup({a[0],a[1],a[2], b[0],b[1],b[2]},
1.0f, 1.0f, 1.0f, /*dashed*/ true));
labels.push_back(makeLabel(a, b,
QStringLiteral("%1 extent: %2 m").arg(axis).arg(extent, 0, 'f', 3)));
hud_lines << QStringLiteral("%1 extent: %2 m").arg(axis).arg(extent, 0, 'f', 3);
};
if (have_extent && (max_t1 - min_t1) > 1e-6) pushBar(t1, min_t1, max_t1);
if (have_extent && (max_t2 - min_t2) > 1e-6) pushBar(t2, min_t2, max_t2);
// Hybrid vertical raycast for floors / ceilings.
if (std::abs(n[2]) > 0.85f) {
constexpr float NUDGE = 1e-3f;
const float ro[3] = {
wp[0] + NUDGE * n[0],
wp[1] + NUDGE * n[1],
wp[2] + NUDGE * n[2],
};
ViewportWindow::RaycastHit hit;
if (vp.raycast(ro, n, hit)) {
const double dist = double(hit.distance) + double(NUDGE);
const std::array<float, 3> a = {wp[0], wp[1], wp[2]};
const std::array<float, 3> b = {hit.world_pos[0],
hit.world_pos[1],
hit.world_pos[2]};
const QString tag = (n[2] > 0.0f)
? QStringLiteral("ceiling height")
: QStringLiteral("floor distance");
groups.push_back(makeGroup({a[0],a[1],a[2], b[0],b[1],b[2]},
1.0f, 1.0f, 1.0f, /*dashed*/ true));
labels.push_back(makeLabel(a, b,
QStringLiteral("%1: %2 m").arg(tag).arg(dist, 0, 'f', 3)));
hud_lines << QStringLiteral("%1: %2 m").arg(tag).arg(dist, 0, 'f', 3);
}
}
pushDots(vp, std::vector<float>(wp, wp + 3));
vp.setOverlayLines(groups);
vp.setOverlayLabels(labels);
vp.setHudText(hud_lines.join('\n'));
}
QString LengthMeasurement::formatReadout() const {
const size_t n = points_.size();
if (n == 0) return QStringLiteral("Length tool: click first point");
if (n == 1) return QStringLiteral("1 point (click another)");
if (n == 2) {
const auto& a = points_[0];
const auto& b = points_[1];
const double d = dist3(a, b);
const double dx = std::abs(double(b[0]) - a[0]);
const double dy = std::abs(double(b[1]) - a[1]);
const double dz = std::abs(double(b[2]) - a[2]);
return QStringLiteral("Length: %1 m\nΔX: %2 ΔY: %3 ΔZ: %4 m")
.arg(d, 0, 'f', 4)
.arg(dx, 0, 'f', 4)
.arg(dy, 0, 'f', 4)
.arg(dz, 0, 'f', 4);
}
if (n == 3) {
const auto& a = points_[0];
const auto& b = points_[1];
const auto& c = points_[2];
const double bax = double(a[0]) - b[0];
const double bay = double(a[1]) - b[1];
const double baz = double(a[2]) - b[2];
const double bcx = double(c[0]) - b[0];
const double bcy = double(c[1]) - b[1];
const double bcz = double(c[2]) - b[2];
const double la = std::sqrt(bax*bax + bay*bay + baz*baz);
const double lc = std::sqrt(bcx*bcx + bcy*bcy + bcz*bcz);
double angle_deg = 0.0;
if (la > 0.0 && lc > 0.0) {
const double cosang = std::clamp(
(bax*bcx + bay*bcy + baz*bcz) / (la * lc), -1.0, 1.0);
angle_deg = std::acos(cosang) * 180.0 / M_PI;
}
return QStringLiteral("Angle at pt 2: %1°\nTriangle area: %2 m²\nPerimeter: %3 m")
.arg(angle_deg, 0, 'f', 2)
.arg(triArea3(a, b, c), 0, 'f', 4)
.arg(dist3(a, b) + dist3(b, c) + dist3(c, a), 0, 'f', 4);
}
const PolygonAreaResult r = polygonArea(points_);
double perimeter = 0.0;
for (size_t i = 0; i < n; ++i) {
perimeter += dist3(points_[i], points_[(i + 1) % n]);
}
return QStringLiteral("Polygon (%1 pts, %2)\nArea: %3 m²\nPerimeter: %4 m")
.arg(n)
.arg(r.method)
.arg(r.area_m2, 0, 'f', 4)
.arg(perimeter, 0, 'f', 4);
}
+75
View File
@@ -0,0 +1,75 @@
/********************************************************************************
* *
* 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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef WGPULENGTHMEASUREMENT_H
#define WGPULENGTHMEASUREMENT_H
#include "ViewportWindow.h"
#include <QString>
#include <array>
#include <vector>
// Click-to-place length / angle / area measurement for the wgpu viewport.
// Mirrors src/bonsaiviewer/Measurement.h's LengthMeasurement — the
// readout adapts to the running point count:
//
// 1 point → laser-measure: BFS the coplanar surface patch the click
// landed on, project its vertices into the surface's own
// tangent basis to get the face extent, plus a vertical
// raycast for floor/ceiling distance on horizontal surfaces.
// 2 points → straight-line distance + ΔX/ΔY/ΔZ stair-step + optional
// perpendicular projection when both picks landed on
// near-parallel surfaces.
// 3 points → angle at the middle vertex + triangle area + perimeter.
// 4+ pts → polygon area via best-fit-plane shoelace if near-planar,
// fan-triangulated otherwise; perimeter on the closed loop.
//
// Pushes the running set as overlay points + the connecting polyline +
// per-segment labels to the viewport; the multi-line HUD carries the
// adaptive readout.
class LengthMeasurement {
public:
LengthMeasurement();
// Pixel coords are physical (post-DPR). `alt` is currently unused
// (kept for API symmetry with the Area tool).
void onPick(ViewportWindow& vp, int x_phys, int y_phys, bool alt);
void removeLastPoint(ViewportWindow& vp);
void clear(ViewportWindow& vp);
size_t pointCount() const { return points_.size(); }
private:
void rebuildOverlay(ViewportWindow& vp);
void rebuildLaserOverlay(ViewportWindow& vp);
QString formatReadout() const;
std::vector<std::array<float, 3>> points_;
std::vector<std::array<float, 3>> normals_; // surface normal at each pick
// Captured at the very first pick of a fresh sequence and never
// updated afterwards. Used by the 1-pt laser BFS to locate the
// mesh-local position of points_[0] without re-picking. Stays valid
// while points_[0] does (pop_back never touches the first element).
ViewportWindow::MeshLocalPick first_pick_{};
};
#endif // WGPULENGTHMEASUREMENT_H
+32
View File
@@ -0,0 +1,32 @@
/**
* Objective-C++ bridge between ViewportWindow (pure C++) and Cocoa /
* QuartzCore (Objective-C). Compiled only on macOS — see CMakeLists.txt.
*
* Qt's QWindow::winId() returns the backing NSView* (as a WId) on macOS;
* we need a CAMetalLayer attached to that view to hand to wgpu-native
* via WGPUSurfaceSourceMetalLayer. Doing that requires Objective-C, so
* the actual layer attach lives in MetalSurface_mac.mm.
*/
#ifndef WGPU_METAL_SURFACE_MAC_H
#define WGPU_METAL_SURFACE_MAC_H
#if defined(__APPLE__)
#ifdef __cplusplus
extern "C" {
#endif
/// Ensures the given NSView has a CAMetalLayer as its backing layer.
/// Returns the CAMetalLayer pointer (`void*` so callers don't need to
/// pull QuartzCore into their TU); the layer is owned by the NSView.
/// Returns nullptr if `nsview_ptr` is null.
void* wgpu_macos_attach_metal_layer(void* nsview_ptr);
#ifdef __cplusplus
}
#endif
#endif // __APPLE__
#endif // WGPU_METAL_SURFACE_MAC_H
+45
View File
@@ -0,0 +1,45 @@
/**
* Objective-C++ implementation of the Cocoa bridge declared in
* MetalSurface_mac.h. Compiled only on macOS.
*/
#include "MetalSurface_mac.h"
#if defined(__APPLE__)
// The AppKit umbrella header pulls in NSWindow so `view.window`'s
// `backingScaleFactor` resolves — `<AppKit/NSView.h>` alone only
// forward-declares NSWindow.
#import <AppKit/AppKit.h>
#import <QuartzCore/CAMetalLayer.h>
void* wgpu_macos_attach_metal_layer(void* nsview_ptr) {
if (!nsview_ptr) {
return nullptr;
}
NSView* view = (__bridge NSView*)nsview_ptr;
// When QWindow::surfaceType is QSurface::MetalSurface, Qt already
// backs the NSView with a CAMetalLayer — just hand it back. Otherwise
// attach one ourselves (defensive: Qt's behaviour can change between
// major versions and 6.x has occasionally regressed this).
CAMetalLayer* layer = nil;
if ([view.layer isKindOfClass:[CAMetalLayer class]]) {
layer = (CAMetalLayer*)view.layer;
} else {
layer = [CAMetalLayer layer];
view.wantsLayer = YES;
view.layer = layer;
}
// Track the screen's backing scale so we get retina-resolution
// drawables. wgpu's surface configure picks the drawable size up
// from layer.drawableSize at present-time.
if (view.window) {
layer.contentsScale = view.window.backingScaleFactor;
}
return (__bridge void*)layer;
}
#endif // __APPLE__
+362
View File
@@ -0,0 +1,362 @@
/********************************************************************************
* *
* 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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef WGPUMODELGPUDATA_H
#define WGPUMODELGPUDATA_H
#include <webgpu/webgpu.h>
#include <Eigen/Dense>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <string>
#include <unordered_map>
#include <vector>
#include "InstancedGeometry.h"
#include "BufferPool.h"
// Per-model wgpu state. Mirrors the GL backend's ModelGpuData but with
// wgpu handles. Stage 2 only allocates and uploads the four core buffers;
// bind groups, pipelines, BVH and cull scratch land in later stages.
//
// All vertex/index/mesh/instance bytes are uploaded once at load time via
// wgpuQueueWriteBuffer. The vertex storage buffer is read by the vertex
// shader (vertex pulling), not used as a classic vertex buffer — there is
// no input-assembler vertex layout to match.
// Web (WebGPU) mandates `maxStorageBufferBindingSize` ≥ 128 MB; some browsers
// grant more, but we plan for the floor. Applied identically on desktop —
// the cost is a few extra draws per frame (1 per chunk; typical models =
// 13 chunks), which is invisible compared to per-frame GPU work.
//
// At INSTANCED_VERTEX_STRIDE_BYTES = 12 B/vertex this caps a chunk at
// ~1.4 M vertices. 16 MB is the sweet spot once background-thread I/O
// (StreamingThread) is in place: scatter-gather per-mesh seeks
// happen on the worker, not the render thread, so smaller chunks
// (and thus more per-frame loads as orbit shifts) no longer stall
// rendering. The win is much finer pool-allocation granularity —
// a 3 GB pool fits ~190 chunks vs ~21 at 128 MB — so visible
// geometry is far less likely to get "trapped" behind invisible
// chunkmates. Pre-async this size gave 7 fps (the sync loads blocked
// the render thread); now it's bounded by cull cost not stream cost.
//
// Sidecar v14 (on-disk spatial reorder) would let us go smaller still
// (~4 MB) with single-fread chunk loads, but the difference between
// 16 MB and 4 MB is much smaller than the difference between 128 MB
// and 16 MB.
static constexpr uint64_t WGPU_CHUNK_VERTEX_BYTES_LIMIT = 16ull * 1024 * 1024;
struct ModelGpuData {
// std430 layout: 16 bytes per entry, naturally aligned. base_vertex is
// CHUNK-LOCAL — the bound vertex_storage on that chunk's bind group
// gives the right slice when the shader indexes vertices[].
struct alignas(16) VisibleDrawGpu {
uint32_t mesh_id; // -> meshes[] for quantisation basis
uint32_t instance_idx; // -> instances[] for transform + ids
uint32_t ebo_first_u32; // start of this entry's slice in indices[] (global)
uint32_t base_vertex; // chunk-local start of this mesh's slice in vertex_storage
};
static_assert(sizeof(VisibleDrawGpu) == 16, "VisibleDrawGpu must be 16 bytes");
// Per-chunk state. Each chunk references a vertex range and an
// index range inside ViewportWindow::pool_, plus a small set of
// per-frame buffers (visible_draws, prefix_sums, uniform) and a bind
// group that binds the pool ranges alongside the model-shared
// mesh/instance storage. Rendering issues one drawcall per non-empty
// chunk.
//
// Streaming (task #16): a chunk may be marked is_resident=false; its
// pool ranges (pool_*_size == 0) and bind_group are then unclaimed
// until the streaming loader brings it in. Other per-chunk buffers
// (visible_draws etc.) stay allocated regardless because cull still
// needs them. Non-streaming path always sets is_resident=true and
// populates pool ranges at applyCachedModel time.
struct Chunk {
// Pool-allocated vertex + index bytes. Both slices land in the
// shared ViewportWindow::pool_; the slice tells us which
// sub-buffer they live in (the pool may span multiple sub-buffers
// when scenes exceed wgpu's single-buffer cap). When non-resident,
// both .size are 0.
BufferPool::Slice vertex_slice;
BufferPool::Slice index_slice;
WGPUBuffer visible_draws_buffer = nullptr;
WGPUBuffer prefix_sums_buffer = nullptr;
WGPUBuffer per_chunk_uniform = nullptr;
WGPUBindGroup bind_group = nullptr;
uint32_t vertex_count = 0; // chunk capacity (vertices)
size_t visible_draws_capacity = 0;
size_t prefix_sums_capacity = 0;
// Per-frame, populated by cullModelCpuCompute and consumed by render().
// total_visible_* are post-frustum + contribution + HiZ — used to size
// the actual draw call. frustum_visible_count is bumped immediately
// after the frustum check (before contribution / HiZ), and is what
// driveStreamingLoads keys on for residency decisions. Streaming
// must NOT use the HiZ-post counters: HiZ visibility flips
// frame-to-frame as occluders shift, which would otherwise thrash
// the loader (evict-then-reload every frame even with the camera
// stationary, killing FPS and producing visible flicker).
uint32_t total_visible_vertices = 0;
uint32_t total_visible_draws = 0;
uint32_t frustum_visible_count = 0;
std::vector<VisibleDrawGpu> visible_draws_scratch;
std::vector<uint32_t> prefix_sums_scratch;
// Residency. Streaming sets is_resident=false at applyCachedModel
// and flips true once the chunk's vertex bytes are uploaded.
// Render and pick skip chunks where !is_resident.
bool is_resident = true;
// Set true while a worker-thread read is in flight for this
// chunk. Prevents driveStreamingLoads from re-enqueueing it
// every frame until its result is drained. Cleared when the
// result is applied (or dropped on failure / stale model).
// Eviction is not gated on this (eviction only acts on resident
// chunks; a loading chunk has no slice to free yet).
bool is_loading = false;
// Aggregate vertex / index sizes across all meshes in this chunk
// (sum of mesh.vertex_count * stride / mesh.index_count for each
// mesh in mesh_ids). Used to size the pool allocation and to
// compute the cull's per-chunk free-room check. Per-mesh layout
// is recovered by walking mesh_ids and the model's MeshInfo[].
uint64_t vertex_byte_size = 0;
uint64_t index_count = 0;
// Of `index_count`, how many are LOD1 indices. LOD0 indices occupy
// chunk-local u32 offsets [0, index_count - lod1_index_count); LOD1
// indices occupy [index_count - lod1_index_count, index_count). 0
// when no mesh in this chunk had a baked LOD1 slice.
uint32_t lod1_index_count = 0;
// World-space AABB covering every instance whose mesh lives in
// this chunk. With spatial chunk planning this AABB is tight
// (chunks group meshes by world centroid, not mesh-id), so the
// distance-based evictor can meaningfully tell chunks apart.
// Used by cull to reject whole chunks against the frustum before
// iterating instances — and by the streaming loader to
// prioritise which non-resident chunks to fetch first.
float aabb_min[3] = { std::numeric_limits<float>::infinity(),
std::numeric_limits<float>::infinity(),
std::numeric_limits<float>::infinity() };
float aabb_max[3] = { -std::numeric_limits<float>::infinity(),
-std::numeric_limits<float>::infinity(),
-std::numeric_limits<float>::infinity() };
// Mesh IDs assigned to this chunk, in chunk-local layout order.
// Spatial chunk planning sorts meshes by world centroid first,
// so this list is not in mesh-id order in general — each mesh's
// bytes live at scattered offsets in the sidecar file. The
// loader walks this list to scatter-gather the chunk's vertex
// + index bytes; mesh_chunk_local_base_vertex /
// mesh_chunk_local_ebo_first_u32 are computed in this same
// order at planning time so the cull's VisibleDrawGpu entries
// point at the correct chunk-local offsets.
std::vector<uint32_t> mesh_ids;
// Instance indices belonging to this chunk (i.e. whose mesh lives
// in this chunk). Built at chunk-planning time. Lets cull iterate
// chunks as the outer loop, frustum-test the chunk AABB once,
// and skip every instance inside in one shot when the chunk is
// off-screen — far cheaper than the per-instance frustum check
// on flat-scan culls of 1M+ instance scenes.
std::vector<uint32_t> instance_ids;
// LRU marker for streaming eviction. Updated to the window's
// streaming_frame_idx_ every frame the chunk is rendered (i.e.
// total_visible_draws > 0). The evictor picks the smallest value
// among non-visible resident chunks when it needs to free VRAM.
uint64_t last_visible_frame_idx = 0;
// EMA-smoothed visibility score, in [0, 1]. Bumped each frame
// toward 1 when total_visible_draws > 0 (the chunk's instances
// passed frustum + contribution + HiZ), toward 0 otherwise.
// Time constant ~30 frames. Used by the streaming evictor to
// de-prioritise chunks that are technically in the frustum but
// consistently HiZ-occluded — e.g. interior pipes behind a
// building's exterior walls. The smoothing prevents thrash from
// momentary HiZ flicker (a wall briefly visible behind a panning
// window doesn't displace the window from the pool).
float visibility_history = 0.0f;
// streaming_frame_idx_ when this chunk was last loaded. The
// evictor grants newly-loaded chunks ~30 frames of grace at
// full priority (max history factor = 1.0) so they have time
// for visibility_history to develop. Without this, a just-
// loaded chunk's effective priority drops to contribution ×
// 0.05 next frame, and the chunk it displaced — back as a
// candidate at full priority — re-displaces it: infinite
// cycle between equal-priority chunks. The cycle prevents any
// lower-priority candidate (e.g. a structural-brace chunk
// ranked position 20 in the missing list) from ever getting
// attempted.
uint64_t loaded_frame_idx = 0;
// How many times this chunk has been (re-)loaded over the
// session. Bumped each successful applyStreamedChunk. A chunk
// with load_count >> 1 has been cycling — used by the stream
// debug log (WGPU_STREAM_DEBUG=1) to surface thrash.
uint32_t load_count = 0;
// Eviction attribution — who pushed this chunk out the last
// time? Filled by evict_lowest_priority_than when the chunk is
// unloaded. Read by the cycle-detection logger when this chunk
// re-enters as a candidate so we can spot A→B→A 2-cycles. Zero
// for chunks that were never evicted or were LRU-evicted (the
// latter doesn't have an obvious "evictor" — just a slot
// pressure event).
uint32_t last_evicted_by_model_id = 0;
uint32_t last_evicted_by_chunk_idx = UINT32_MAX;
float last_evicted_by_priority = 0.0f;
// Frame at which this chunk was most recently evicted, so the
// cycle log only fires when re-entry is "soon" (cache thrash)
// rather than "minutes later" (legitimate camera move).
uint64_t last_evicted_frame_idx = 0;
// Cooldown frame: if streaming_frame_idx_ < this, skip the
// chunk in the candidate gather. Set when a candidate is
// blocked OOM (eviction exhausted, still doesn't fit) OR when
// applyStreamedChunk fails on the drained worker result. Caps
// web bandwidth waste at one fetch per cooldown for chunks
// that genuinely can't fit in the current pool state; the
// cooldown expires naturally so the chunk re-enters when
// pool layout has had a chance to change.
uint64_t blocked_cooldown_until_frame_idx = 0;
// Per-frame instance-aware priority. Sum of px² projected
// contributions of every instance owned by this chunk —
// captures the chunk's actual on-screen footprint, not the
// (often loose) AABB union projection. Computed once per
// frame at the top of driveStreamingLoads from the camera
// state; the candidate/resident priority lambdas just read
// this. See task #57 for the rationale.
float current_priority = 0.0f;
};
std::vector<Chunk> chunks;
// Streaming source. Non-empty path means this model was loaded via the
// streaming path: chunks may be non-resident and need byte-range reads
// from this file. Empty path = legacy non-streaming load.
std::string streaming_file_path;
uint64_t streaming_vertex_section_offset = 0;
uint64_t streaming_index_section_offset = 0;
// For each mesh in meshes[], the chunk it lives in plus the chunk-local
// offsets into that chunk's vertex_storage and index_buffer. Populated
// at applyCachedModel time; consumed by cullModelCpuCompute when it
// populates VisibleDrawGpu entries.
std::vector<uint32_t> mesh_chunk_idx;
std::vector<uint32_t> mesh_chunk_local_base_vertex;
std::vector<uint32_t> mesh_chunk_local_ebo_first_u32;
// Where in the chunk's index slice this mesh's LOD1 indices start
// (in u32 units). Only meaningful when m.meshes[mi].lod1_index_count > 0;
// entries for meshes without LOD1 are 0 and unused.
std::vector<uint32_t> mesh_chunk_local_lod1_first_u32;
// Per-INSTANCE chunk lookup tables. Mirror the per-mesh arrays above,
// but resolved at planning time so cull can read them directly without
// routing through mesh_id. The split exists because the spatial-
// bucketing planner (#55) can place the same mesh in multiple chunks
// (mesh data duplicated when its instances live in different buckets)
// — under that scheme `mesh_chunk_idx[mesh_id]` is ambiguous, but
// `instance_chunk_idx[instance_id]` is always exactly one chunk.
// The mesh-keyed planner populates these by translation
// (instance_chunk_idx[i] = mesh_chunk_idx[instances[i].mesh_id]);
// the spatial-bucket planner populates them directly.
std::vector<uint32_t> instance_chunk_idx;
std::vector<uint32_t> instance_base_vertex;
std::vector<uint32_t> instance_ebo_first_u32;
std::vector<uint32_t> instance_lod1_first_u32;
// Model-shared buffers. Mesh + instance storage are small (<10 MB on
// any real scene we've seen); the chunked index buffer lives in Chunk
// alongside vertex_storage so streaming can defer both together.
WGPUBuffer mesh_storage = nullptr; // MeshGpu[]: aabb_min/max
WGPUBuffer instance_storage = nullptr; // InstanceGpu[]: transform + ids
// Cumulative VRAM accounting (bytes), populated at applyCachedModel
// time. Sum of vertex_storage across chunks + index_buffer + mesh_storage
// + instance_storage + per-chunk visible_draws + prefix_sums + uniforms.
// Used by the per-frame stats log to attribute total VRAM.
uint64_t vram_bytes_vbo = 0; // vertex storage total
uint64_t vram_bytes_ebo = 0; // index buffer
uint64_t vram_bytes_ssbo = 0; // mesh + instance + per-chunk small buffers
// Size mirrors for stats / range checks. vertex_bytes is the sum across
// all chunks; index_count / mesh_count / instance_count are unchanged.
size_t vertex_bytes = 0;
uint32_t index_count = 0;
uint32_t mesh_count = 0;
uint32_t instance_count = 0;
// CPU side, kept for cull / picking / federation recompose.
std::vector<MeshInfo> meshes;
std::vector<InstanceCpu> instances;
// Local-frame volume (m³) of every mesh, indexed by mesh_id. Computed
// once at applyCachedModel via signed-tetrahedra-from-origin on the
// raw vertex+index data; reused by the Volume measurement tool to
// avoid re-reading the GPU buffers per click. Empty in streaming mode
// until the chunk holding the mesh has been delivered.
std::vector<double> mesh_local_volumes;
// CPU shadow of each mesh's mesh-local positions + LOD0 indices.
// Populated at applyCachedModel (or per-chunk in streaming) from
// the same raw vertex bytes the volume calc dequantises. The Area
// measurement tool reads this directly — no GPU readback, matching
// the Volume tool's policy.
//
// Doubles per-vertex memory (12 B/vert GPU + 12 B/vert CPU). The
// alternative is a wgpu mapAsync readback per first-touched mesh,
// which adds async plumbing and a per-click stall; pay the memory
// upfront instead. Trim by sizing each entry down at population
// (reserve exact). For huge federations this can be a real
// working-set cost — revisit if it shows up in profiles.
struct MeshTriangles {
std::vector<float> positions; // 3 * vertex_count, mesh-local
std::vector<uint32_t> indices; // 3 * triangle_count, LOD0
};
std::vector<MeshTriangles> mesh_triangles_cache;
// object_id (globally rebased) → instance index in `instances`.
// Populated alongside the instance vector so the Volume tool can do
// O(1) instance lookup instead of linear-scanning every model.
std::unordered_map<uint32_t, uint32_t> object_id_to_instance;
// Spatial chunk-cull replaced the per-model BVH walk — chunks are
// already a one-level spatial partition of the instances, so a
// single frustum test per chunk gives the same wholesale-reject
// win without the BVH's per-node traversal overhead. The BVH field
// 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
// ranges via `pool.free()`) and clear its size mirrors. Safe to call
// repeatedly; idempotent on already-released entries.
void releaseWgpuModelGpuData(ModelGpuData& m, BufferPool& pool);
#endif // WGPUMODELGPUDATA_H
File diff suppressed because it is too large Load Diff
+339
View File
@@ -0,0 +1,339 @@
/********************************************************************************
* *
* 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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef WGPUOVERLAYRENDERER_H
#define WGPUOVERLAYRENDERER_H
#include <QHash>
#include <QMatrix4x4>
#include <QPoint>
#include <QString>
#include <QVector3D>
#include <webgpu/webgpu.h>
#include <cstdint>
#include <vector>
// Per-frame snapshot of viewport state that every overlay needs. Built once
// at the top of render() and passed by const-ref to each encodeX() call so
// the overlay renderer never reaches back into the viewport.
struct OverlayFrame {
QMatrix4x4 view_proj;
QVector3D camera_target;
float camera_distance = 5.0f;
float camera_yaw_deg = 0.0f;
float camera_pitch_deg = 0.0f;
float camera_fov_y_deg = 45.0f;
int viewport_w_px = 0;
int viewport_h_px = 0;
int device_pixel_ratio = 1;
};
// One section plane as the visualizer consumes it. The viewport owns the
// authoritative state vector (the section tool mutates it); the overlay
// reads from a non-owning span every frame. Held by value because the
// struct is small and copies happen at most six times per frame.
struct SectionPlane {
QVector3D n; // unit normal (camera-facing after auto-flip)
float d; // -dot(n, origin)
QVector3D origin; // surface point at the moment the plane was added
float visual_radius; // unused by the visualizer (kept here so the
// section tool's state struct round-trips
// through this overlay-facing definition).
};
// All viewport overlays in one place: axis indicator (corner + pivot),
// section plane gizmos, and the marquee drag rect. Mirrors GL's
// OverlayRenderer split so ViewportWindow.cpp doesn't have to
// carry ~1.5k lines of pipeline plumbing.
//
// Lifecycle: init() once after the device is up, destroy() before the
// device dies. Pipelines are immutable after init; only per-frame
// uniforms get re-written.
//
// Threading: all calls are main-thread only — they touch the wgpu queue.
class OverlayRenderer {
public:
OverlayRenderer() = default;
~OverlayRenderer();
OverlayRenderer(const OverlayRenderer&) = delete;
OverlayRenderer& operator=(const OverlayRenderer&) = delete;
bool init(WGPUInstance instance, WGPUDevice device, WGPUQueue queue,
WGPUTextureFormat surface_format, int sample_count);
void destroy();
// ---- Inside the main MSAA pass, after geometry ----
// Both share depth with the scene so they're correctly occluded.
// Orbit pivot indicator. `visible` is the viewport's UI gate (orbit
// drag / wheel-zoom afterglow). When false this is a cheap no-op.
void encodePivot(WGPURenderPassEncoder pass,
const OverlayFrame& f,
bool visible);
// Per-plane wireframe gizmo (2 × 2 m quad outline + arrow shaft +
// arrow head). Drawn at each plane's origin in its local basis;
// colour comes from Bonsai's decorator_color_error.
void encodeSectionGizmos(WGPURenderPassEncoder pass,
const OverlayFrame& f,
const std::vector<SectionPlane>& planes);
// Replace the highlight-triangle list. `world_xyz` is 3 floats per
// vertex, 3 vertices per triangle, in world space (post-composed-
// transform). Empty disables the overlay. Color is RGBA in [0, 1] —
// alpha < 1 gives the translucent patch shading the Area tool uses.
// Drawn inside the main MSAA pass so depth-test hides patches
// behind closer geometry; depth-write stays off so later overlays
// can still draw over the highlight.
void setHighlightTriangles(const std::vector<float>& world_xyz,
float r, float g, float b, float a);
void encodeHighlightTriangles(WGPURenderPassEncoder pass,
const OverlayFrame& f);
// One stylistic group of world-space line segments. Mirrors GL
// OverlayRenderer::LineGroup so callers can target either backend
// with one struct.
struct LineGroup {
std::vector<float> world_xyz; // 6 floats per segment (a, b)
float color[4] = {1, 1, 1, 1}; // inner color
float stroke_color[4] = {0, 0, 0, 1}; // halo (alpha 0 = no stroke)
float line_width = 1.5f; // inner full-width (px)
float stroke_extra = 0.5f; // halo per side (px)
float dash_period_px = 0.0f; // 0 = solid
float dash_on_ratio = 0.6f; // [0..1], only when period > 0
};
// Replace the overlay-line set. Each call CPU-expands every segment
// into six vertices (two triangles), uploads the concatenated
// expanded buffer once, and writes one uniform slot per group; the
// next encodeOverlayLines() draws them in order. Empty `groups`
// clears the set so subsequent encodes are no-ops.
void setOverlayLines(const std::vector<LineGroup>& groups);
// Encode the most-recently set line groups. One draw per group with
// a dynamic uniform offset; the shader handles stroke + dash from
// per-group uniforms. Drawn inside the main MSAA pass so the lines
// are depth-tested against geometry.
void encodeOverlayLines(WGPURenderPassEncoder pass,
const OverlayFrame& f);
// Replace the overlay-point set. World-space positions are CPU-
// expanded into screen-space quads at encode time. `pixel_size` is
// the inner-disc diameter (px); when `stroke_a > 0` each quad picks
// up a `stroke_extra`-pixel halo per side. Empty `world_xyz` clears
// the set. Mirrors GL OverlayRenderer::setOverlayPoints.
void setOverlayPoints(const std::vector<float>& world_xyz,
float r, float g, float b, float a,
float pixel_size,
float stroke_r, float stroke_g,
float stroke_b, float stroke_a,
float stroke_extra);
// Encode the most-recently set point list. Single draw covering all
// points; the shader does the sprite-distance pick + AA. Drawn
// inside the main MSAA pass so depth-test correctly hides points
// behind closer geometry.
void encodeOverlayPoints(WGPURenderPassEncoder pass,
const OverlayFrame& f);
// World-anchored text label. Mirrors GL OverlayRenderer::Label so
// measure-tool readouts can target either backend.
struct Label {
float world_pos[3];
QString text;
};
void setOverlayLabels(const std::vector<Label>& labels);
// Top-left HUD text (tool prompts, length / area readouts). Empty
// string hides it. Each newline starts a new line in the same rect.
void setHudText(const QString& text);
// Encode all currently-set labels + the HUD. Per-string textures are
// rasterised via QPainter into a small QImage on first sight and
// cached by content; per-frame work is just projection, vertex
// assembly, and one draw per visible label. Drawn on the resolved
// surface (no depth test, no MSAA), so labels stack on top of every
// overlay above.
void encodeLabels(WGPUCommandEncoder enc,
WGPUTextureView surface_view,
const OverlayFrame& f);
// ---- After the edge silhouette pass, on the resolved surface ----
// Corner axis gizmo (bottom-left, 110×110 px). Independent ortho
// projection — only the camera direction matters.
void encodeCornerAxis(WGPUCommandEncoder enc,
WGPUTextureView surface_view,
const OverlayFrame& f);
// Marquee box-select drag rect (translucent fill + thick outline).
// No-op when `active` is false.
void encodeMarquee(WGPUCommandEncoder enc,
WGPUTextureView surface_view,
const OverlayFrame& f,
QPoint start_logical_px,
QPoint current_logical_px,
bool active);
// Shared with the viewport's main FrameUniforms: same cap so the
// viewport's clip-plane array and the gizmo visualiser agree on
// how many planes can ever be active.
static constexpr int kMaxSectionPlanes = 6;
private:
bool buildAxisIndicator();
bool buildSectionVisualizer();
bool buildMarquee();
bool buildOverlayLines();
bool buildOverlayPoints();
bool buildHighlightTriangles();
bool buildLabels();
// Rasterise a single string at `font_pt` with dark-grey padded
// background + white text, upload as an RGBA8 texture, build the
// matching bind group. Width/height are the texture's physical-pixel
// dimensions and are also what encodeLabels uses to size the quad.
struct LabelTexture {
WGPUTexture texture = nullptr;
WGPUTextureView view = nullptr;
WGPUBindGroup bind_group = nullptr;
int width_px = 0;
int height_px = 0;
};
LabelTexture* getOrCreateLabelTexture(const QString& cache_key,
const QString& text,
int font_pt,
int dpr);
void releaseLabelTextures();
WGPUInstance instance_ = nullptr;
WGPUDevice device_ = nullptr;
WGPUQueue queue_ = nullptr;
WGPUTextureFormat surface_format_ = WGPUTextureFormat_Undefined;
int sample_count_ = 1;
// ---- Axis indicator (shared shape, three pipelines) ----
// Slot 0 = corner gizmo. Slots 1/2 = pivot visible/x-ray.
WGPUShaderModule axis_shader_module_ = nullptr;
WGPUBindGroupLayout axis_bgl_ = nullptr;
WGPUPipelineLayout axis_pipeline_layout_ = nullptr;
WGPURenderPipeline axis_pivot_pipeline_ = nullptr;
WGPURenderPipeline axis_pivot_xray_pipeline_ = nullptr;
WGPURenderPipeline axis_corner_pipeline_ = nullptr;
WGPUBuffer axis_vertex_buffer_ = nullptr;
WGPUBuffer axis_uniform_buffer_ = nullptr;
WGPUBindGroup axis_bind_group_ = nullptr;
static constexpr uint32_t kAxisUniformSlotSize = 256;
// ---- Section plane gizmos (1 pipeline, dynamic offset per plane) ----
WGPUShaderModule section_shader_module_ = nullptr;
WGPUBindGroupLayout section_bgl_ = nullptr;
WGPUPipelineLayout section_pipeline_layout_ = nullptr;
WGPURenderPipeline section_pipeline_ = nullptr;
WGPUBuffer section_vertex_buffer_ = nullptr;
WGPUBuffer section_uniform_buffer_ = nullptr;
WGPUBindGroup section_bind_group_ = nullptr;
static constexpr uint32_t kSectionUniformSlotSize = 256;
// ---- Marquee (fill + outline pipelines, one uniform buffer) ----
WGPUShaderModule marquee_shader_module_ = nullptr;
WGPUBindGroupLayout marquee_bgl_ = nullptr;
WGPUPipelineLayout marquee_pipeline_layout_ = nullptr;
WGPURenderPipeline marquee_pipeline_ = nullptr;
WGPURenderPipeline marquee_fill_pipeline_ = nullptr;
WGPUBuffer marquee_vertex_buffer_ = nullptr;
WGPUBuffer marquee_fill_vertex_buffer_ = nullptr;
WGPUBuffer marquee_uniform_buffer_ = nullptr;
WGPUBindGroup marquee_bind_group_ = nullptr;
// ---- Overlay lines (per-group dynamic offset, resizable buffers) ----
// Vertex buffer holds the concatenated expansion of every group's
// segments (8 floats × 6 verts per segment). Uniform buffer holds
// one 256-byte slot per group; the bind group binds a single 128-byte
// window that the encoder rebinds via dynamic offset.
WGPUShaderModule overlay_line_shader_module_ = nullptr;
WGPUBindGroupLayout overlay_line_bgl_ = nullptr;
WGPUPipelineLayout overlay_line_pipeline_layout_ = nullptr;
WGPURenderPipeline overlay_line_pipeline_ = nullptr;
WGPUBuffer overlay_line_vertex_buffer_ = nullptr;
uint64_t overlay_line_vertex_capacity_ = 0;
WGPUBuffer overlay_line_uniform_buffer_ = nullptr;
uint32_t overlay_line_uniform_slots_ = 0;
WGPUBindGroup overlay_line_bind_group_ = nullptr;
static constexpr uint32_t kOverlayLineUniformSlotSize = 256;
// Per-group draw record. setOverlayLines() populates one per
// LineGroup; encodeOverlayLines() iterates and issues one draw each.
struct OverlayLineDraw {
uint32_t first_vertex = 0;
uint32_t vertex_count = 0;
};
std::vector<OverlayLineDraw> overlay_line_draws_;
// ---- Overlay points (sprite-style, quad-expanded per point) ----
// Single draw per encode covering every point in the set. Vertex
// buffer holds 6 verts × 8 bytes per point (vec3 world + vec2 corner).
// Uniforms are global to the set (one inner + one stroke color).
WGPUShaderModule overlay_point_shader_module_ = nullptr;
WGPUBindGroupLayout overlay_point_bgl_ = nullptr;
WGPUPipelineLayout overlay_point_pipeline_layout_ = nullptr;
WGPURenderPipeline overlay_point_pipeline_ = nullptr;
WGPUBuffer overlay_point_vertex_buffer_ = nullptr;
uint64_t overlay_point_vertex_capacity_ = 0;
WGPUBuffer overlay_point_uniform_buffer_ = nullptr;
WGPUBindGroup overlay_point_bind_group_ = nullptr;
uint32_t overlay_point_vertex_count_ = 0;
// ---- Highlight triangles (translucent world-space triangle list) ----
// One pipeline + one uniform buffer (view_proj + RGBA tint). Vertex
// buffer grows on demand to fit the current set; empty set ⇒ encode
// is a no-op.
WGPUShaderModule highlight_shader_module_ = nullptr;
WGPUBindGroupLayout highlight_bgl_ = nullptr;
WGPUPipelineLayout highlight_pipeline_layout_ = nullptr;
WGPURenderPipeline highlight_pipeline_ = nullptr;
WGPUBuffer highlight_vertex_buffer_ = nullptr;
uint64_t highlight_vertex_capacity_ = 0;
WGPUBuffer highlight_uniform_buffer_ = nullptr;
WGPUBindGroup highlight_bind_group_ = nullptr;
uint32_t highlight_vertex_count_ = 0;
float highlight_color_[4] = {0, 0, 0, 0};
// ---- Labels + HUD text (textured quads, cached by content) ----
// One QPainter-rasterised QImage per unique text string, uploaded as
// an RGBA8 texture and re-used across frames. Per-frame work is
// projection + vertex assembly + draws; no allocation in the steady
// state. Bind groups are layout-shared across all label textures so
// every cache entry holds its own bind_group ready to bind.
WGPUShaderModule label_shader_module_ = nullptr;
WGPUBindGroupLayout label_bgl_ = nullptr;
WGPUPipelineLayout label_pipeline_layout_ = nullptr;
WGPURenderPipeline label_pipeline_ = nullptr;
WGPUSampler label_sampler_ = nullptr;
WGPUBuffer label_vertex_buffer_ = nullptr;
uint64_t label_vertex_capacity_ = 0;
QHash<QString, LabelTexture> label_tex_cache_;
std::vector<Label> labels_;
QString hud_text_;
};
#endif // WGPUOVERLAYRENDERER_H
+1 -1
View File
@@ -37,7 +37,7 @@ void SceneLoader::setShouldWriteSidecar(bool enabled) {
should_write_sidecar_ = enabled;
}
SceneLoader::SceneLoader(WgpuViewportWindow* viewport, QObject* parent)
SceneLoader::SceneLoader(ViewportWindow* viewport, QObject* parent)
: QObject(parent), viewport_(viewport)
{
connect(&element_poll_timer_, &QTimer::timeout,
+5 -5
View File
@@ -35,13 +35,13 @@
#include <vector>
#include "Federation.h"
#include "../ifcviewer-wgpu/WgpuViewportWindow.h"
#include "../ifcviewer-wgpu/WgpuStreamingLoader.h"
#include "../ifcviewer/ViewportWindow.h"
#include "../ifcviewer/StreamingLoader.h"
#include "GeometryStreamer.h"
#include "SidecarBuilder.h"
#include "SidecarCache.h"
// Drives IFC file loading into a WgpuViewportWindow. Owns the per-model
// Drives IFC file loading into a ViewportWindow. Owns the per-model
// GeometryStreamer, the load queue, the sidecar read thread, and the
// next-free object_id counter used to rebase cached models onto the
// current session's ID space.
@@ -55,7 +55,7 @@
class SceneLoader : public QObject {
Q_OBJECT
public:
explicit SceneLoader(WgpuViewportWindow* viewport, QObject* parent = nullptr);
explicit SceneLoader(ViewportWindow* viewport, QObject* parent = nullptr);
~SceneLoader();
// Sidecar cache use is opt-in per direction. Embedders that don't care
@@ -165,7 +165,7 @@ private:
void applySidecarData(uint32_t mid, StreamingSidecar metadata);
void startDataSourceLoad(uint32_t mid);
WgpuViewportWindow* viewport_ = nullptr;
ViewportWindow* viewport_ = nullptr;
bool should_read_sidecar_ = false;
bool should_write_sidecar_ = false;
std::map<uint32_t, Model> models_;
+105
View File
@@ -0,0 +1,105 @@
/********************************************************************************
* *
* 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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef WGPUSELECTIONSTATE_H
#define WGPUSELECTIONSTATE_H
#include <cstdint>
#include <unordered_set>
#include <vector>
// CPU-side selection tracking. Mirrors src/ifcviewer/Selection.h shape but
// without Qt deps (kept pure stdlib so it can move into ifcviewer-core
// later without dragging Qt along).
//
// Two flavours of "selected":
// - the multi-set (ids()): every object the user has Shift-added.
// - active (activeId()): the *last* single-clicked object. UIs typically
// use this to drive the properties panel; the renderer tints it
// slightly more strongly than the rest of the multi-set.
//
// The GPU consumes a flat u32 array indexed by object_id: bit 0 = selected,
// bit 1 = active. Sized to (max_object_id + 1) by the caller.
class SelectionState {
public:
void clear() {
if (ids_.empty() && active_ == 0) return;
ids_.clear();
active_ = 0;
dirty_ = true;
}
// Replace the selection with a single object. id == 0 clears.
void replace(uint32_t id) {
ids_.clear();
if (id != 0) ids_.insert(id);
active_ = id;
dirty_ = true;
}
void add(uint32_t id) {
if (id == 0) return;
ids_.insert(id);
active_ = id;
dirty_ = true;
}
void remove(uint32_t id) {
if (id == 0) return;
if (ids_.erase(id) == 0) return;
if (active_ == id) {
active_ = ids_.empty() ? 0 : *ids_.begin();
}
dirty_ = true;
}
void toggle(uint32_t id) {
if (id == 0) return;
if (ids_.count(id)) remove(id);
else add(id);
}
bool contains(uint32_t id) const { return ids_.count(id) > 0; }
uint32_t activeId() const { return active_; }
// 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_; }
void markClean() { dirty_ = false; }
// Fill `out` (sized to entries u32s) with bit-packed flags:
// bit 0 = selected (in ids_), bit 1 = active. out[0] is always 0
// because object_id 0 is the "miss" sentinel.
void fillFlagsArray(std::vector<uint32_t>& out, uint32_t entries) const {
out.assign(entries, 0);
for (uint32_t id : ids_) {
if (id < entries) out[id] |= 1u;
}
if (active_ != 0 && active_ < entries) out[active_] |= 2u;
}
private:
std::unordered_set<uint32_t> ids_;
uint32_t active_ = 0;
bool dirty_ = false;
};
#endif // WGPUSELECTIONSTATE_H
+320
View File
@@ -0,0 +1,320 @@
/********************************************************************************
* *
* 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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
// v13 sidecar layout (matched against SidecarCache.cpp):
//
// SidecarHeader (12 bytes)
// uint32 num_vertex_bytes
// uint8[num_vertex_bytes] vertex data <-- streaming skips
// uint32 num_indices
// uint32[num_indices] index data <-- streaming skips
// uint32 num_meshes + MeshInfo[] <-- streaming reads
// uint32 num_instances + InstanceCpu[] <-- streaming reads
// uint32 has_coord_op + double[16] + 2× double <-- streaming reads
// uint32 num_elements + PackedElementInfo[] <-- streaming reads
// uint32 string_table_bytes + char[] <-- streaming reads
//
// Streaming reader returns offsets to the two skipped sections so chunks
// can be range-read on demand. File handle is closed before return.
#include "StreamingLoader.h"
#include <algorithm>
#include <cstdio>
#include <cstring>
namespace {
struct SidecarHeaderRaw {
uint32_t magic;
uint32_t version;
uint32_t endian;
};
template<typename T>
bool readVec(FILE* f, std::vector<T>& v) {
uint32_t n;
if (std::fread(&n, 4, 1, f) != 1) return false;
v.resize(n);
if (n > 0 && std::fread(v.data(), sizeof(T), n, f) != n) return false;
return true;
}
std::string sidecarPath(const std::string& ifc_path) {
std::string p = ifc_path;
while (!p.empty() && (p.back() == '/' || p.back() == '\\')) p.pop_back();
auto slash = p.find_last_of("/\\");
auto dot = p.find_last_of('.');
std::string stem = (dot != std::string::npos &&
(slash == std::string::npos || dot > slash))
? p.substr(0, dot)
: p;
return stem + ".ifcview";
}
} // namespace
std::optional<StreamingSidecar> readSidecarMetadataOnly(const std::string& ifc_path) {
const std::string path = sidecarPath(ifc_path);
FILE* f = std::fopen(path.c_str(), "rb");
if (!f) return std::nullopt;
auto fail = [&]() -> std::optional<StreamingSidecar> {
std::fclose(f);
return std::nullopt;
};
SidecarHeaderRaw hdr;
if (std::fread(&hdr, sizeof(hdr), 1, f) != 1) return fail();
if (hdr.magic != SIDECAR_MAGIC) return fail();
if (hdr.version != SIDECAR_VERSION) return fail();
if (hdr.endian != SIDECAR_ENDIAN) return fail();
StreamingSidecar out;
out.file_path = path;
// Vertex section: read count, record offset of data, seek past.
uint32_t num_vertex_bytes = 0;
if (std::fread(&num_vertex_bytes, 4, 1, f) != 1) return fail();
out.vertex_section_offset = uint64_t(std::ftell(f));
out.vertex_total_bytes = num_vertex_bytes;
if (std::fseek(f, long(num_vertex_bytes), SEEK_CUR) != 0) return fail();
// Index section: same dance, in u32 units.
uint32_t num_indices = 0;
if (std::fread(&num_indices, 4, 1, f) != 1) return fail();
out.index_section_offset = uint64_t(std::ftell(f));
out.index_total_count = num_indices;
if (std::fseek(f, long(num_indices) * 4, SEEK_CUR) != 0) return fail();
// Mesh dict + instance dict — small, load into meta.
if (!readVec(f, out.meta.meshes)) return fail();
if (!readVec(f, out.meta.instances)) return fail();
// v11 georef block (148 bytes total).
if (std::fread(&out.meta.has_coordinate_operation, 4, 1, f) != 1) return fail();
if (std::fread(out.meta.coordinate_operation_meters,
sizeof(double), 16, f) != 16) return fail();
if (std::fread(&out.meta.project_length_to_meters,
sizeof(double), 1, f) != 1) return fail();
if (std::fread(&out.meta.map_unit_to_meters,
sizeof(double), 1, f) != 1) return fail();
// Element table + string table.
if (!readVec(f, out.meta.elements)) return fail();
uint32_t stbl_len = 0;
if (std::fread(&stbl_len, 4, 1, f) != 1) return fail();
out.meta.string_table.resize(stbl_len);
if (stbl_len > 0 &&
std::fread(out.meta.string_table.data(), 1, stbl_len, f) != stbl_len)
return fail();
std::fclose(f);
return out;
}
bool readSidecarVertexChunk(const std::string& ifc_path,
uint64_t vertex_section_offset,
uint64_t chunk_byte_offset,
uint64_t chunk_byte_size,
std::vector<uint8_t>& out_bytes) {
if (chunk_byte_size == 0) { out_bytes.clear(); return true; }
const std::string path = sidecarPath(ifc_path);
FILE* f = std::fopen(path.c_str(), "rb");
if (!f) return false;
if (std::fseek(f, long(vertex_section_offset + chunk_byte_offset), SEEK_SET) != 0) {
std::fclose(f);
return false;
}
out_bytes.resize(size_t(chunk_byte_size));
const size_t got = std::fread(out_bytes.data(), 1, size_t(chunk_byte_size), f);
std::fclose(f);
return got == size_t(chunk_byte_size);
}
bool readSidecarIndexChunk(const std::string& ifc_path,
uint64_t index_section_offset,
uint64_t chunk_first_index,
uint64_t chunk_index_count,
std::vector<uint32_t>& out_indices) {
if (chunk_index_count == 0) { out_indices.clear(); return true; }
const std::string path = sidecarPath(ifc_path);
FILE* f = std::fopen(path.c_str(), "rb");
if (!f) return false;
const uint64_t byte_offset = index_section_offset + chunk_first_index * 4u;
if (std::fseek(f, long(byte_offset), SEEK_SET) != 0) {
std::fclose(f);
return false;
}
out_indices.resize(size_t(chunk_index_count));
const size_t got = std::fread(out_indices.data(), sizeof(uint32_t),
size_t(chunk_index_count), f);
std::fclose(f);
return got == size_t(chunk_index_count);
}
// Coalesce ranges that are close in file order into single reads. The
// input order is preserved in the destination buffer; we just merge
// reads on the file side. A `max_gap_bytes` tolerance lets us swallow
// small file gaps when reading would be cheaper than seeking.
//
// SIDE EFFECT: callers must give the dst buffer in INPUT order; the
// reader scatters bytes via per-input-range dst offsets after a single
// coalesced fread. Returns false on any I/O failure.
namespace {
struct ReadPlan {
uint64_t file_offset; // absolute file offset
uint64_t read_size; // total bytes to read
// Per input range: where its bytes land in this read, and where to
// copy them into the destination buffer.
struct Slice {
uint64_t src_offset; // offset within the read buffer
uint64_t dst_offset; // offset within the destination buffer
uint64_t bytes;
};
std::vector<Slice> slices;
};
// Build a plan that merges adjacent file ranges into single reads.
// `ranges` are (section-relative offset, size). `max_gap_bytes` is the
// largest "wasted bytes" we'll read to bridge two ranges into one read.
std::vector<ReadPlan> buildReadPlan(
uint64_t section_offset,
const std::vector<std::pair<uint64_t, uint64_t>>& ranges,
uint64_t max_gap_bytes) {
// Sort by file offset, remembering original order so we can scatter
// to the destination correctly.
struct Indexed { uint64_t off, size, dst; };
std::vector<Indexed> sorted;
sorted.reserve(ranges.size());
uint64_t dst_cursor = 0;
for (const auto& [off, sz] : ranges) {
sorted.push_back({off, sz, dst_cursor});
dst_cursor += sz;
}
std::sort(sorted.begin(), sorted.end(),
[](const Indexed& a, const Indexed& b) { return a.off < b.off; });
std::vector<ReadPlan> plans;
for (const auto& r : sorted) {
if (r.size == 0) continue;
if (!plans.empty()) {
ReadPlan& back = plans.back();
const uint64_t end_of_back = back.file_offset + back.read_size;
const uint64_t r_file = section_offset + r.off;
if (r_file >= end_of_back && r_file - end_of_back <= max_gap_bytes) {
// Merge: extend the read to include r (plus any gap).
const uint64_t new_size = (r_file + r.size) - back.file_offset;
back.slices.push_back({
r_file - back.file_offset, // src within read
r.dst,
r.size,
});
back.read_size = new_size;
continue;
}
}
ReadPlan np;
np.file_offset = section_offset + r.off;
np.read_size = r.size;
np.slices.push_back({0, r.dst, r.size});
plans.push_back(std::move(np));
}
return plans;
}
} // namespace
bool readSidecarVertexRanges(const std::string& ifc_path,
uint64_t vertex_section_offset,
const std::vector<std::pair<uint64_t, uint64_t>>& ranges,
std::vector<uint8_t>& out_bytes) {
uint64_t total = 0;
for (const auto& r : ranges) total += r.second;
out_bytes.resize(size_t(total));
if (total == 0) return true;
// 64 KB max gap: on SSDs a small contiguous read is much cheaper
// than a seek + fresh read, even if some bytes are discarded.
auto plans = buildReadPlan(vertex_section_offset, ranges, 64 * 1024);
const std::string path = sidecarPath(ifc_path);
FILE* f = std::fopen(path.c_str(), "rb");
if (!f) return false;
std::vector<uint8_t> scratch;
for (const auto& p : plans) {
scratch.resize(size_t(p.read_size));
if (std::fseek(f, long(p.file_offset), SEEK_SET) != 0) { std::fclose(f); return false; }
if (std::fread(scratch.data(), 1, scratch.size(), f) != scratch.size()) {
std::fclose(f); return false;
}
for (const auto& s : p.slices) {
std::memcpy(out_bytes.data() + s.dst_offset,
scratch.data() + s.src_offset, size_t(s.bytes));
}
}
std::fclose(f);
return true;
}
bool readSidecarIndexRanges(const std::string& ifc_path,
uint64_t index_section_offset,
const std::vector<std::pair<uint64_t, uint64_t>>& ranges,
std::vector<uint32_t>& out_indices) {
uint64_t total = 0;
for (const auto& r : ranges) total += r.second;
out_indices.resize(size_t(total));
if (total == 0) return true;
// Convert u32-range (first_u32, count_u32) to byte-range
// (file_offset, byte_size). Then coalesce + read.
std::vector<std::pair<uint64_t, uint64_t>> byte_ranges;
byte_ranges.reserve(ranges.size());
uint64_t out_byte_cursor = 0;
for (const auto& [first_u32, count] : ranges) {
// Store byte offsets relative to the index section.
byte_ranges.emplace_back(first_u32 * 4u, count * 4u);
out_byte_cursor += count * 4u;
}
auto plans = buildReadPlan(index_section_offset, byte_ranges, 64 * 1024);
const std::string path = sidecarPath(ifc_path);
FILE* f = std::fopen(path.c_str(), "rb");
if (!f) return false;
std::vector<uint8_t> scratch;
uint8_t* out_bytes = reinterpret_cast<uint8_t*>(out_indices.data());
for (const auto& p : plans) {
scratch.resize(size_t(p.read_size));
if (std::fseek(f, long(p.file_offset), SEEK_SET) != 0) { std::fclose(f); return false; }
if (std::fread(scratch.data(), 1, scratch.size(), f) != scratch.size()) {
std::fclose(f); return false;
}
for (const auto& s : p.slices) {
std::memcpy(out_bytes + s.dst_offset,
scratch.data() + s.src_offset, size_t(s.bytes));
}
}
std::fclose(f);
return true;
}
+107
View File
@@ -0,0 +1,107 @@
/********************************************************************************
* *
* 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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef WGPUSTREAMINGLOADER_H
#define WGPUSTREAMINGLOADER_H
#include "SidecarCache.h"
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
// Metadata-only sidecar load — the foundation for streaming. Reads the v13
// header + mesh dict + instance dict + georef + element table from disk but
// *skips* the bulky vertex and index byte sections, leaving file offsets +
// sizes for later random-access reads.
//
// On a typical real-scene sidecar this returns in milliseconds even when the
// full readSidecar would block on hundreds of MB of vertex bytes. Lets the
// renderer set up cull / instance state immediately and load vertex chunks
// on demand as they become frustum-visible.
//
// Backwards-compatible with v13 sidecars on disk (the format isn't changing
// in this step — we're just reading less of it). v14 with an explicit
// per-chunk TOC arrives in a follow-up; this layer abstracts the chunk
// boundaries so the upgrade is internal.
struct StreamingSidecar {
// Everything except vertices + indices — same shape as SidecarData but
// with empty vertices / indices vectors. The renderer uses meshes /
// instances / georef / elements immediately.
SidecarData meta;
// Byte offsets in the on-disk file where the vertex and index sections
// start (after their 4-byte count headers). Pair with vertex_total_bytes
// / index_total_bytes for the section length; per-chunk reads slice
// arbitrary ranges within these.
uint64_t vertex_section_offset = 0;
uint64_t vertex_total_bytes = 0;
uint64_t index_section_offset = 0;
uint64_t index_total_count = 0; // u32 indices, NOT bytes
// Resolved on-disk path so subsequent chunk reads can re-open / seek.
std::string file_path;
};
// Read just the metadata + section offsets. Returns nullopt on any I/O or
// version error (same failure modes as readSidecar). The file is closed
// before return — callers re-open for per-chunk reads.
std::optional<StreamingSidecar> readSidecarMetadataOnly(const std::string& ifc_path);
// Read a byte range from a sidecar's vertex section. `chunk_byte_offset` is
// RELATIVE to vertex_section_offset (i.e. 0 = first vertex byte). Returns
// false on I/O error or out-of-range request.
//
// Synchronous; intended to be called from a worker thread for async
// streaming or from the main thread for stage-1 on-demand load.
bool readSidecarVertexChunk(const std::string& ifc_path,
uint64_t vertex_section_offset,
uint64_t chunk_byte_offset,
uint64_t chunk_byte_size,
std::vector<uint8_t>& out_bytes);
// Read a u32-index range. `chunk_first_index` is RELATIVE to the start of
// the index section (i.e. 0 = first u32 index). `chunk_index_count` is in
// indices (multiply by 4 internally).
bool readSidecarIndexChunk(const std::string& ifc_path,
uint64_t index_section_offset,
uint64_t chunk_first_index,
uint64_t chunk_index_count,
std::vector<uint32_t>& out_indices);
// Multi-range vertex read. `ranges` is a list of (section-relative
// byte_offset, byte_size) tuples; their contents are concatenated into
// out_bytes in input order. Single fopen across all ranges, so it's
// far cheaper than calling readSidecarVertexChunk N times when a
// spatially-grouped chunk needs to scatter-gather meshes that aren't
// adjacent in the sidecar. out_bytes is resized to the total size.
bool readSidecarVertexRanges(const std::string& ifc_path,
uint64_t vertex_section_offset,
const std::vector<std::pair<uint64_t, uint64_t>>& ranges,
std::vector<uint8_t>& out_bytes);
// Same for the index section. Ranges are (first_u32, count_u32);
// concatenated into out_indices in input order.
bool readSidecarIndexRanges(const std::string& ifc_path,
uint64_t index_section_offset,
const std::vector<std::pair<uint64_t, uint64_t>>& ranges,
std::vector<uint32_t>& out_indices);
#endif // WGPUSTREAMINGLOADER_H
+122
View File
@@ -0,0 +1,122 @@
/********************************************************************************
* *
* 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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "StreamingThread.h"
#include "StreamingLoader.h"
StreamingThread::~StreamingThread() {
stop();
}
void StreamingThread::start() {
std::unique_lock lk(mu_);
if (running_) return;
shutdown_ = false;
running_ = true;
lk.unlock();
worker_ = std::thread(&StreamingThread::workerLoop, this);
}
void StreamingThread::stop() {
{
std::unique_lock lk(mu_);
if (!running_) return;
shutdown_ = true;
}
cv_.notify_all();
if (worker_.joinable()) worker_.join();
std::unique_lock lk(mu_);
running_ = false;
requests_.clear();
results_.clear();
}
bool StreamingThread::enqueue(Request req) {
{
std::unique_lock lk(mu_);
if (!running_ || shutdown_) return false;
requests_.push_back(std::move(req));
}
cv_.notify_one();
return true;
}
std::vector<StreamingThread::Result> StreamingThread::drainResults() {
std::vector<Result> out;
{
std::unique_lock lk(mu_);
out.reserve(results_.size());
while (!results_.empty()) {
out.push_back(std::move(results_.front()));
results_.pop_front();
}
}
return out;
}
std::size_t StreamingThread::inFlightApprox() const {
std::unique_lock lk(mu_);
return requests_.size() + (in_progress_ ? 1u : 0u);
}
void StreamingThread::workerLoop() {
for (;;) {
Request req;
{
std::unique_lock lk(mu_);
cv_.wait(lk, [this]() { return shutdown_ || !requests_.empty(); });
if (shutdown_ && requests_.empty()) return;
req = std::move(requests_.front());
requests_.pop_front();
in_progress_ = true;
}
// Disk reads happen off-thread. Each Request carries everything
// the reader needs; the viewport keeps the corresponding chunk
// marked is_loading so eviction won't yank the slot underneath
// us. The vbytes / idx buffers are allocated here on the worker
// thread — they cross back to the main thread when the result
// is drained and applied (pool.alloc + queueWriteBuffer).
Result res;
res.model_id = req.model_id;
res.chunk_idx = req.chunk_idx;
res.success = true;
if (!req.v_ranges.empty()) {
if (!readSidecarVertexRanges(req.file_path,
req.vertex_section_offset,
req.v_ranges, res.vbytes)) {
res.success = false;
}
}
if (res.success && !req.i_ranges.empty()) {
if (!readSidecarIndexRanges(req.file_path,
req.index_section_offset,
req.i_ranges, res.idx)) {
res.success = false;
}
}
{
std::unique_lock lk(mu_);
results_.push_back(std::move(res));
in_progress_ = false;
}
}
}
+100
View File
@@ -0,0 +1,100 @@
/********************************************************************************
* *
* 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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef WGPUSTREAMINGTHREAD_H
#define WGPUSTREAMINGTHREAD_H
#include <condition_variable>
#include <cstdint>
#include <deque>
#include <mutex>
#include <string>
#include <thread>
#include <utility>
#include <vector>
// Worker thread for scatter-gather chunk reads. Decouples disk I/O
// (~tens of ms per chunk on SSD, hundreds on slower media) from the
// render thread. The viewport's per-frame driveStreamingLoads enqueues
// requests for non-resident-frustum-visible chunks, drains any
// completed Results on subsequent frames, and only performs the
// GPU-side (pool.alloc + queueWriteBuffer + bind-group build) work
// on the main thread — wgpu queue ops aren't thread-safe.
//
// Lifetime: start() spawns the worker; stop() signals shutdown and
// joins. The Result destructor releases its byte vectors back to the
// heap, so dropping unclaimed Results (e.g. when their model was
// unloaded mid-flight) is a free operation.
class StreamingThread {
public:
struct Request {
uint32_t model_id;
std::size_t chunk_idx;
std::string file_path;
uint64_t vertex_section_offset;
uint64_t index_section_offset;
// (section-relative byte_offset, byte_size)
std::vector<std::pair<uint64_t, uint64_t>> v_ranges;
// (first_u32, count_u32)
std::vector<std::pair<uint64_t, uint64_t>> i_ranges;
};
struct Result {
uint32_t model_id;
std::size_t chunk_idx;
bool success;
std::vector<uint8_t> vbytes;
std::vector<uint32_t> idx;
};
~StreamingThread();
// Spawn the worker thread. Safe to call once; subsequent calls are
// no-ops while the worker is alive.
void start();
// Signal shutdown, wake the worker, join. Idempotent. Must be
// called before the BufferPool the results would upload into
// is destroyed.
void stop();
// Enqueue a request. Returns false if the worker has stopped.
bool enqueue(Request req);
// Move all completed results out of the result queue. Always
// non-blocking; if nothing is ready, returns an empty vector.
std::vector<Result> drainResults();
// Approximate count of requests still in flight (in queue or
// currently being processed). Useful for the bench warm gate to
// know when streaming has truly settled.
std::size_t inFlightApprox() const;
private:
void workerLoop();
std::thread worker_;
mutable std::mutex mu_;
std::condition_variable cv_;
std::deque<Request> requests_;
std::deque<Result> results_;
bool in_progress_ = false;
bool shutdown_ = false;
bool running_ = false;
};
#endif // WGPUSTREAMINGTHREAD_H
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
/********************************************************************************
* *
* 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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef WGPUVISIBILITYSTATE_H
#define WGPUVISIBILITYSTATE_H
#include <cstdint>
#include <unordered_set>
// CPU-side per-element visibility. Mirrors src/ifcviewer/Visibility.h shape
// but pure stdlib so it can move into ifcviewer-core later.
//
// Consulted in cullModelCpuCompute: instances whose object_id is in
// hidden_ids_ are dropped from the visible-draws list entirely (no GPU
// work, no triangle in the depth buffer, no pick hit). Concurrent reads
// from multiple cull worker threads are safe as long as no mutations
// happen during render — which is the case here (input handlers
// requestUpdate after mutating, render then reads).
class VisibilityState {
public:
bool isHidden(uint32_t object_id) const {
return hidden_ids_.count(object_id) > 0;
}
void hide(uint32_t object_id) {
if (object_id == 0) return;
hidden_ids_.insert(object_id);
}
void show(uint32_t object_id) {
hidden_ids_.erase(object_id);
}
void clear() { hidden_ids_.clear(); }
size_t hiddenCount() const { return hidden_ids_.size(); }
const std::unordered_set<uint32_t>& hiddenIds() const { return hidden_ids_; }
private:
std::unordered_set<uint32_t> hidden_ids_;
};
#endif // WGPUVISIBILITYSTATE_H
+5
View File
@@ -52,6 +52,11 @@ add_ifcviewer_unit_test(test_sidecar_cache
add_ifcviewer_unit_test(test_instanced_geometry)
# Header-only state-machine tests for the renderer subsystems (selection,
# visibility). Subjects are inline in their .h files, so no SOURCES needed.
add_ifcviewer_unit_test(test_selection)
add_ifcviewer_unit_test(test_visibility)
# Federation is Qt-derived (QObject + signals). It has to pull Qt6 in
# directly and enable AUTOMOC for the Q_OBJECT moc-generation.
find_package(Qt${QT_VERSION} COMPONENTS Core Gui Test REQUIRED PATHS ${QT_DIR})
+256
View File
@@ -0,0 +1,256 @@
/********************************************************************************
* *
* 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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
// Tier-1 coverage of SelectionState — the CPU-side selection set + active
// id used by the wgpu viewport. The class is pure stdlib (no Qt, no QObject),
// so the test exercises the state machine directly. The GPU-side flags SSBO
// is filled via fillFlagsArray; that pure-function path is also covered.
#include "SelectionState.h"
#include <catch2/catch_test_macros.hpp>
#include <vector>
TEST_CASE("SelectionState starts empty with no active id", "[wgpu-selection]") {
SelectionState sel;
REQUIRE(sel.count() == 0);
REQUIRE(sel.activeId() == 0);
REQUIRE_FALSE(sel.contains(1));
REQUIRE_FALSE(sel.dirty());
}
TEST_CASE("replace(id) selects a single id and makes it active",
"[wgpu-selection]") {
SelectionState sel;
sel.replace(5);
REQUIRE(sel.count() == 1);
REQUIRE(sel.contains(5));
REQUIRE(sel.activeId() == 5);
REQUIRE(sel.dirty());
}
TEST_CASE("replace(0) clears the selection", "[wgpu-selection]") {
SelectionState sel;
sel.replace(5);
sel.markClean();
sel.replace(0);
REQUIRE(sel.count() == 0);
REQUIRE(sel.activeId() == 0);
REQUIRE(sel.dirty());
}
TEST_CASE("add(id) appends to the set and steals active", "[wgpu-selection]") {
SelectionState sel;
sel.replace(1);
sel.markClean();
sel.add(2);
REQUIRE(sel.count() == 2);
REQUIRE(sel.contains(1));
REQUIRE(sel.contains(2));
// Each click should drive the properties panel to the most recently
// touched object, so active follows the last add — distinct from GL's
// addToSelection (which kept the prior active).
REQUIRE(sel.activeId() == 2);
REQUIRE(sel.dirty());
}
TEST_CASE("add(0) is ignored", "[wgpu-selection]") {
SelectionState sel;
sel.replace(1);
sel.markClean();
sel.add(0);
REQUIRE(sel.count() == 1);
REQUIRE(sel.activeId() == 1);
REQUIRE_FALSE(sel.dirty()); // no-op didn't flip the flag
}
TEST_CASE("remove(non-active) keeps active", "[wgpu-selection]") {
SelectionState sel;
sel.replace(1);
sel.add(2);
sel.add(3);
REQUIRE(sel.activeId() == 3);
sel.markClean();
sel.remove(1);
REQUIRE(sel.count() == 2);
REQUIRE_FALSE(sel.contains(1));
REQUIRE(sel.activeId() == 3); // still the most recently touched
REQUIRE(sel.dirty());
}
TEST_CASE("remove(active) falls back to some remaining id", "[wgpu-selection]") {
SelectionState sel;
sel.replace(1);
sel.add(2);
sel.add(3);
REQUIRE(sel.activeId() == 3);
sel.remove(3);
REQUIRE(sel.count() == 2);
REQUIRE_FALSE(sel.contains(3));
// Active falls back to *some* remaining id (implementation picks the
// unordered_set's first element; documenting non-determinism rather
// than the specific choice).
const uint32_t a = sel.activeId();
REQUIRE((a == 1 || a == 2));
REQUIRE(sel.contains(a));
}
TEST_CASE("remove(last id) clears active", "[wgpu-selection]") {
SelectionState sel;
sel.replace(7);
REQUIRE(sel.activeId() == 7);
sel.remove(7);
REQUIRE(sel.count() == 0);
REQUIRE(sel.activeId() == 0);
}
TEST_CASE("remove(non-existent) is a no-op for state, no dirty flag",
"[wgpu-selection]") {
SelectionState sel;
sel.replace(1);
sel.markClean();
sel.remove(99);
REQUIRE(sel.count() == 1);
REQUIRE(sel.activeId() == 1);
REQUIRE_FALSE(sel.dirty());
}
TEST_CASE("remove(0) is ignored", "[wgpu-selection]") {
SelectionState sel;
sel.replace(1);
sel.markClean();
sel.remove(0);
REQUIRE(sel.count() == 1);
REQUIRE_FALSE(sel.dirty());
}
TEST_CASE("toggle adds when absent, removes when present", "[wgpu-selection]") {
SelectionState sel;
sel.toggle(5);
REQUIRE(sel.contains(5));
REQUIRE(sel.activeId() == 5);
sel.toggle(6);
REQUIRE(sel.contains(6));
REQUIRE(sel.activeId() == 6); // last add steals active
sel.toggle(5); // remove non-active — active unchanged
REQUIRE_FALSE(sel.contains(5));
REQUIRE(sel.activeId() == 6);
sel.toggle(6); // remove active — fallback (set empty → 0)
REQUIRE(sel.count() == 0);
REQUIRE(sel.activeId() == 0);
}
TEST_CASE("toggle(0) is ignored", "[wgpu-selection]") {
SelectionState sel;
sel.markClean();
sel.toggle(0);
REQUIRE(sel.count() == 0);
REQUIRE_FALSE(sel.dirty());
}
TEST_CASE("clear empties; no-op when already empty", "[wgpu-selection]") {
SelectionState sel;
sel.clear(); // already empty
REQUIRE_FALSE(sel.dirty());
sel.replace(1);
sel.markClean();
sel.clear();
REQUIRE(sel.count() == 0);
REQUIRE(sel.activeId() == 0);
REQUIRE(sel.dirty());
}
TEST_CASE("markClean clears the dirty flag", "[wgpu-selection]") {
SelectionState sel;
sel.replace(5);
REQUIRE(sel.dirty());
sel.markClean();
REQUIRE_FALSE(sel.dirty());
// Subsequent mutation re-arms the flag.
sel.add(6);
REQUIRE(sel.dirty());
}
TEST_CASE("selectionIds returns the live set", "[wgpu-selection]") {
SelectionState sel;
sel.add(1);
sel.add(2);
sel.add(3);
const auto& ids = sel.selectionIds();
REQUIRE(ids.size() == 3);
REQUIRE(ids.count(1) == 1);
REQUIRE(ids.count(2) == 1);
REQUIRE(ids.count(3) == 1);
}
TEST_CASE("fillFlagsArray packs selected/active bits per object_id",
"[wgpu-selection]") {
SelectionState sel;
sel.add(1);
sel.add(3); // active is now 3
std::vector<uint32_t> flags;
sel.fillFlagsArray(flags, 8);
REQUIRE(flags.size() == 8);
REQUIRE(flags[0] == 0u); // sentinel
REQUIRE(flags[1] == 1u); // selected, not active
REQUIRE(flags[2] == 0u);
REQUIRE(flags[3] == (1u | 2u)); // selected + active
REQUIRE(flags[4] == 0u);
REQUIRE(flags[5] == 0u);
REQUIRE(flags[6] == 0u);
REQUIRE(flags[7] == 0u);
}
TEST_CASE("fillFlagsArray drops ids past the entries cap",
"[wgpu-selection]") {
SelectionState sel;
sel.add(1);
sel.add(100); // active is 100
std::vector<uint32_t> flags;
sel.fillFlagsArray(flags, 4);
REQUIRE(flags.size() == 4);
REQUIRE(flags[1] == 1u);
// id 100 is out of range — should not write to flags[2]/[3]/etc.
REQUIRE(flags[2] == 0u);
REQUIRE(flags[3] == 0u);
}
+118
View File
@@ -0,0 +1,118 @@
/********************************************************************************
* *
* 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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
// Tier-1 coverage of VisibilityState — the per-element hidden-id set
// consulted in cull. The class is pure stdlib; this test exercises its
// primitives directly. Bulk hide/isolate/show-all semantics live in
// ViewportWindow (which composes VisibilityState + the model
// instance lists) and would need an integration test, not a Tier-1 unit.
#include "VisibilityState.h"
#include <catch2/catch_test_macros.hpp>
TEST_CASE("VisibilityState starts empty", "[wgpu-visibility]") {
VisibilityState vis;
REQUIRE(vis.hiddenCount() == 0);
REQUIRE_FALSE(vis.isHidden(0)); // 0 is the "no object" sentinel
REQUIRE_FALSE(vis.isHidden(1));
REQUIRE_FALSE(vis.isHidden(1u << 20));
}
TEST_CASE("hide(id) records the id; isHidden reflects it",
"[wgpu-visibility]") {
VisibilityState vis;
vis.hide(1);
vis.hide(2);
vis.hide(3);
REQUIRE(vis.hiddenCount() == 3);
REQUIRE(vis.isHidden(1));
REQUIRE(vis.isHidden(2));
REQUIRE(vis.isHidden(3));
REQUIRE_FALSE(vis.isHidden(4));
}
TEST_CASE("hide is idempotent", "[wgpu-visibility]") {
VisibilityState vis;
vis.hide(5);
vis.hide(5);
vis.hide(5);
REQUIRE(vis.hiddenCount() == 1);
REQUIRE(vis.isHidden(5));
}
TEST_CASE("hide(0) is ignored", "[wgpu-visibility]") {
VisibilityState vis;
vis.hide(0);
REQUIRE(vis.hiddenCount() == 0);
REQUIRE_FALSE(vis.isHidden(0));
}
TEST_CASE("show(id) removes a previously hidden id",
"[wgpu-visibility]") {
VisibilityState vis;
vis.hide(1);
vis.hide(2);
vis.show(1);
REQUIRE(vis.hiddenCount() == 1);
REQUIRE_FALSE(vis.isHidden(1));
REQUIRE(vis.isHidden(2));
}
TEST_CASE("show(non-hidden) is a no-op", "[wgpu-visibility]") {
VisibilityState vis;
vis.hide(1);
vis.show(99); // never hidden
REQUIRE(vis.hiddenCount() == 1);
REQUIRE(vis.isHidden(1));
}
TEST_CASE("clear() drops every hidden id", "[wgpu-visibility]") {
VisibilityState vis;
vis.hide(1);
vis.hide(2);
vis.hide(3);
vis.clear();
REQUIRE(vis.hiddenCount() == 0);
REQUIRE_FALSE(vis.isHidden(1));
REQUIRE_FALSE(vis.isHidden(2));
REQUIRE_FALSE(vis.isHidden(3));
}
TEST_CASE("hiddenIds returns the live set", "[wgpu-visibility]") {
VisibilityState vis;
vis.hide(10);
vis.hide(20);
vis.hide(30);
const auto& ids = vis.hiddenIds();
REQUIRE(ids.size() == 3);
REQUIRE(ids.count(10) == 1);
REQUIRE(ids.count(20) == 1);
REQUIRE(ids.count(30) == 1);
REQUIRE(ids.count(40) == 0);
}