commit c8e553e9532fb06d795900601c6a5e3afe7f3aad Author: rusprus Date: Wed Apr 15 10:18:59 2026 +0300 Initial commit with correct .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2102f3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Build directories +build/ +debug/ +release/ + +# Qt generated files +Makefile* +*.pro.user +*.pro.user.* +*.qmake.stash +*.qmake.cache +moc_*.cpp +ui_*.h +qrc_*.cpp + +# Object and library files +*.o +*.obj +*.so +*.a +*.lib +*.dll +*.dylib + +# Executables +*.exe +*.out +*.app + +# Qt deployment folders +platforms/ +styles/ +imageformats/ +iconengines/ +translations/ +bearer/ + +# Common logs/temp files +*.log +*.tmp +*.temp + +# OS/editor files +.DS_Store +Thumbs.db +*.swp +*.swo diff --git a/DotsToSirface.pro b/DotsToSirface.pro new file mode 100644 index 0000000..1788aa1 --- /dev/null +++ b/DotsToSirface.pro @@ -0,0 +1,17 @@ +QT += core gui widgets opengl + +CONFIG += c++11 +TEMPLATE = app +TARGET = DotsToSirface +LIBS += -lopengl32 + +SOURCES += \ + src/main.cpp \ + src/geometry/surface_reconstruction.cpp \ + src/ui/mainwindow.cpp \ + src/ui/glview.cpp + +HEADERS += \ + src/geometry/surface_reconstruction.h \ + src/ui/mainwindow.h \ + src/ui/glview.h diff --git a/README.md b/README.md new file mode 100644 index 0000000..45cf5a8 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# DotsToSirface (Qt 5.11) + +Демонстрационная программа на Qt 5.11 C++, которая: +- принимает массив 3D-точек `QVector`; +- строит массив треугольников `QVector`; +- визуализирует точки и полученную треугольную поверхность. + +## Вход/выход API + +`src/geometry/surface_reconstruction.h`: + +- `struct Point3f { float x, y, z; };` +- `struct Triangle { int i0, i1, i2; };` +- `QVector buildSurfaceTriangles(const QVector& points);` + +`Triangle` хранит индексы вершин в исходном массиве `points`. + +## Как это работает + +Реализация использует инкрементальный `Convex Hull 3D`: +- удаление дубликатов точек (epsilon-сравнение); +- поиск стартового тетраэдра; +- поочередное добавление точек с пересчетом видимых граней и горизонта; +- поддержание ориентированных наружу треугольников. + +## Ограничения + +Текущая реализация строит **выпуклую оболочку** облака точек. +Для невыпуклых объектов и детальной реконструкции произвольной поверхности нужны более сложные алгоритмы (например, alpha-shapes, Poisson reconstruction и т.п.). + +## Сборка + +Пример для Qt 5.11: + +```bash +qmake DotsToSirface.pro +make +``` + +Для Windows/MSVC используйте соответствующий `nmake`/`jom`. + +## Демо + +При запуске приложение: +- генерирует тестовое облако точек (приближенная сфера с небольшим шумом); +- строит триангуляцию; +- показывает статистику: количество точек, количество треугольников и время построения; +- отображает сцену в `QOpenGLWidget` (ЛКМ - вращение, колесо - зум). diff --git a/src/geometry/surface_reconstruction.cpp b/src/geometry/surface_reconstruction.cpp new file mode 100644 index 0000000..92c9af5 --- /dev/null +++ b/src/geometry/surface_reconstruction.cpp @@ -0,0 +1,186 @@ +#include "surface_reconstruction.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +const double kEps = 1e-6; +float gNeighborRadiusScale = 3.5f; + +struct Point2d +{ + double x; + double y; +}; + +struct UniquePoint +{ + Point3f p; + int originalIndex; +}; + +QString xyKey(const Point3f &p) +{ + const qint64 qx = qRound64(static_cast(p.x) / kEps); + const qint64 qy = qRound64(static_cast(p.y) / kEps); + return QString::number(qx) + "_" + QString::number(qy); +} + +QVector uniquePointsByXY(const QVector &input) +{ + QVector out; + out.reserve(input.size()); + QSet seen; + + for (int i = 0; i < input.size(); ++i) { + const QString key = xyKey(input[i]); + if (!seen.contains(key)) { + seen.insert(key); + out.push_back({input[i], i}); + } + } + return out; +} + +double orient2d(const Point2d &a, const Point2d &b, const Point2d &c) +{ + return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); +} + +struct Neighbor +{ + int idx; + double d2; + double angle; +}; + +} // namespace + +void setNeighborRadiusScale(const float scale) +{ + gNeighborRadiusScale = qBound(0.5f, scale, 20.0f); +} + +float neighborRadiusScale() +{ + return gNeighborRadiusScale; +} + +QVector buildSurfaceTriangles(const QVector &points) +{ + QVector triangles; + QVector pts = uniquePointsByXY(points); + if (pts.size() < 4) { + return triangles; + } + + QVector pts2d; + pts2d.reserve(pts.size()); + for (const UniquePoint &up : pts) { + pts2d.push_back({up.p.x, up.p.y}); + } + + auto dist2xyz = [&](const int a, const int b) -> double { + const double dx = static_cast(pts[a].p.x) - static_cast(pts[b].p.x); + const double dy = static_cast(pts[a].p.y) - static_cast(pts[b].p.y); + const double dz = static_cast(pts[a].p.z) - static_cast(pts[b].p.z); + return dx * dx + dy * dy + dz * dz; + }; + + QVector nearestDist; + nearestDist.reserve(pts.size()); + for (int i = 0; i < pts.size(); ++i) { + double best = std::numeric_limits::max(); + for (int j = 0; j < pts.size(); ++j) { + if (i == j) { + continue; + } + const double d2 = dist2xyz(i, j); + if (d2 < best) { + best = d2; + } + } + if (best < std::numeric_limits::max()) { + nearestDist.push_back(qSqrt(best)); + } + } + if (nearestDist.isEmpty()) { + return triangles; + } + std::sort(nearestDist.begin(), nearestDist.end()); + const double medianNearest = nearestDist[nearestDist.size() / 2]; + const double maxNeighborDistance = qMax(1e-6, medianNearest * static_cast(gNeighborRadiusScale)); + const double maxNeighborDistance2 = maxNeighborDistance * maxNeighborDistance; + const int kNeighbors = 10; + + QSet uniqueTriangles; + triangles.reserve(pts.size() * 2); + + auto triangleKey = [](int a, int b, int c) -> QString { + int v[3] = {a, b, c}; + std::sort(v, v + 3); + return QString::number(v[0]) + "_" + QString::number(v[1]) + "_" + QString::number(v[2]); + }; + + for (int i = 0; i < pts.size(); ++i) { + QVector neighbors; + neighbors.reserve(pts.size() - 1); + for (int j = 0; j < pts.size(); ++j) { + if (i == j) { + continue; + } + const double d2 = dist2xyz(i, j); + if (d2 <= maxNeighborDistance2) { + const double angle = qAtan2(pts2d[j].y - pts2d[i].y, pts2d[j].x - pts2d[i].x); + neighbors.push_back({j, d2, angle}); + } + } + if (neighbors.size() < 3) { + continue; + } + std::sort(neighbors.begin(), neighbors.end(), [](const Neighbor &a, const Neighbor &b) { + return a.d2 < b.d2; + }); + if (neighbors.size() > kNeighbors) { + neighbors.resize(kNeighbors); + } + std::sort(neighbors.begin(), neighbors.end(), [](const Neighbor &a, const Neighbor &b) { + return a.angle < b.angle; + }); + + for (int n = 0; n < neighbors.size(); ++n) { + const int j = neighbors[n].idx; + const int k = neighbors[(n + 1) % neighbors.size()].idx; + if (j == k) { + continue; + } + if (dist2xyz(j, k) > maxNeighborDistance2) { + continue; + } + + const double area2 = orient2d(pts2d[i], pts2d[j], pts2d[k]); + if (qAbs(area2) < kEps) { + continue; + } + + const QString key = triangleKey(i, j, k); + if (uniqueTriangles.contains(key)) { + continue; + } + uniqueTriangles.insert(key); + + if (area2 > 0.0) { + triangles.push_back({pts[i].originalIndex, pts[j].originalIndex, pts[k].originalIndex}); + } else { + triangles.push_back({pts[i].originalIndex, pts[k].originalIndex, pts[j].originalIndex}); + } + } + } + + return triangles; +} diff --git a/src/geometry/surface_reconstruction.h b/src/geometry/surface_reconstruction.h new file mode 100644 index 0000000..e952099 --- /dev/null +++ b/src/geometry/surface_reconstruction.h @@ -0,0 +1,24 @@ +#ifndef SURFACE_RECONSTRUCTION_H +#define SURFACE_RECONSTRUCTION_H + +#include + +struct Point3f +{ + float x; + float y; + float z; +}; + +struct Triangle +{ + int i0; + int i1; + int i2; +}; + +void setNeighborRadiusScale(float scale); +float neighborRadiusScale(); +QVector buildSurfaceTriangles(const QVector &points); + +#endif // SURFACE_RECONSTRUCTION_H diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..25298b5 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,13 @@ +#include + +#include "ui/mainwindow.h" + +int main(int argc, char *argv[]) +{ + QCoreApplication::setAttribute(Qt::AA_UseSoftwareOpenGL); + QApplication app(argc, argv); + MainWindow window; + window.resize(1100, 800); + window.show(); + return app.exec(); +} diff --git a/src/ui/glview.cpp b/src/ui/glview.cpp new file mode 100644 index 0000000..c284ec6 --- /dev/null +++ b/src/ui/glview.cpp @@ -0,0 +1,192 @@ +#include "glview.h" + +#include +#include +#include +#include +#include + +GlView::GlView(QWidget *parent) + : QOpenGLWidget(parent) + , m_yawDeg(-30.0f) + , m_pitchDeg(25.0f) + , m_distance(4.0f) + , m_minDistance(0.5f) + , m_maxDistance(2000.0f) + , m_center(0.0f, 0.0f, 0.0f) + , m_radius(1.0f) + , m_panX(0.0f) + , m_panY(0.0f) + , m_surfaceVisible(true) +{ +} + +void GlView::setData(const QVector &points, const QVector &triangles) +{ + m_points = points; + m_triangles = triangles; + + if (!m_points.isEmpty()) { + QVector3D bmin(m_points[0].x, m_points[0].y, m_points[0].z); + QVector3D bmax = bmin; + for (const Point3f &p : m_points) { + bmin.setX(qMin(bmin.x(), p.x)); + bmin.setY(qMin(bmin.y(), p.y)); + bmin.setZ(qMin(bmin.z(), p.z)); + bmax.setX(qMax(bmax.x(), p.x)); + bmax.setY(qMax(bmax.y(), p.y)); + bmax.setZ(qMax(bmax.z(), p.z)); + } + + m_center = (bmin + bmax) * 0.5f; + m_radius = qMax(1e-3f, (bmax - bmin).length() * 0.5f); + m_distance = qMax(2.5f * m_radius, 1.0f); + m_minDistance = qMax(0.05f * m_radius, 0.01f); + m_maxDistance = qMax(100.0f * m_radius, m_distance * 2.0f); + m_panX = 0.0f; + m_panY = 0.0f; + } + + update(); +} + +void GlView::setSurfaceVisible(const bool visible) +{ + if (m_surfaceVisible == visible) { + return; + } + m_surfaceVisible = visible; + update(); +} + +void GlView::initializeGL() +{ + initializeOpenGLFunctions(); + glEnable(GL_DEPTH_TEST); + glDisable(GL_CULL_FACE); + + m_program.addShaderFromSourceCode(QOpenGLShader::Vertex, + "attribute vec3 aPos;\n" + "uniform mat4 uMvp;\n" + "void main() {\n" + " gl_Position = uMvp * vec4(aPos, 1.0);\n" + "}\n"); + m_program.addShaderFromSourceCode(QOpenGLShader::Fragment, + "uniform vec3 uColor;\n" + "void main() {\n" + " gl_FragColor = vec4(uColor, 1.0);\n" + "}\n"); + m_program.link(); +} + +void GlView::resizeGL(int w, int h) +{ + glViewport(0, 0, w, h); +} + +void GlView::paintGL() +{ + glClearColor(0.08f, 0.09f, 0.11f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + if (m_points.isEmpty()) { + return; + } + + QVector vertices; + vertices.reserve(m_points.size() * 3); + for (const Point3f &p : m_points) { + vertices.push_back(p.x - m_center.x()); + vertices.push_back(p.y - m_center.y()); + vertices.push_back(p.z - m_center.z()); + } + + QVector indices; + indices.reserve(m_triangles.size() * 3); + QVector edgeIndices; + edgeIndices.reserve(m_triangles.size() * 6); + for (const Triangle &t : m_triangles) { + indices.push_back(static_cast(t.i0)); + indices.push_back(static_cast(t.i1)); + indices.push_back(static_cast(t.i2)); + + edgeIndices.push_back(static_cast(t.i0)); + edgeIndices.push_back(static_cast(t.i1)); + edgeIndices.push_back(static_cast(t.i1)); + edgeIndices.push_back(static_cast(t.i2)); + edgeIndices.push_back(static_cast(t.i2)); + edgeIndices.push_back(static_cast(t.i0)); + } + + QMatrix4x4 projection; + projection.perspective(45.0f, + static_cast(width()) / qMax(1, height()), + qMax(0.001f, m_distance * 0.001f), + qMax(m_distance + 4.0f * m_radius, m_distance * 2.0f)); + + QMatrix4x4 view; + view.translate(m_panX, m_panY, -m_distance); + view.rotate(m_pitchDeg, 1.0f, 0.0f, 0.0f); + view.rotate(m_yawDeg, 0.0f, 1.0f, 0.0f); + + const QMatrix4x4 mvp = projection * view; + + m_program.bind(); + m_program.setUniformValue("uMvp", mvp); + m_program.enableAttributeArray("aPos"); + m_program.setAttributeArray("aPos", GL_FLOAT, vertices.constData(), 3); + + if (m_surfaceVisible) { + glEnable(GL_POLYGON_OFFSET_FILL); + glPolygonOffset(1.0f, 1.0f); + m_program.setUniformValue("uColor", QVector3D(0.20f, 0.72f, 0.95f)); + if (!indices.isEmpty()) { + glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, indices.constData()); + } + glDisable(GL_POLYGON_OFFSET_FILL); + + glLineWidth(2.2f); + m_program.setUniformValue("uColor", QVector3D(1.0f, 0.2f, 0.08f)); + if (!edgeIndices.isEmpty()) { + glDrawElements(GL_LINES, edgeIndices.size(), GL_UNSIGNED_INT, edgeIndices.constData()); + } + } + + glPointSize(4.0f); + m_program.setUniformValue("uColor", QVector3D(1.0f, 0.85f, 0.2f)); + glDrawArrays(GL_POINTS, 0, m_points.size()); + + m_program.disableAttributeArray("aPos"); + m_program.release(); +} + +void GlView::mousePressEvent(QMouseEvent *event) +{ + m_lastMousePos = event->pos(); +} + +void GlView::mouseMoveEvent(QMouseEvent *event) +{ + const QPoint delta = event->pos() - m_lastMousePos; + m_lastMousePos = event->pos(); + + if (event->buttons() & Qt::LeftButton) { + m_yawDeg += static_cast(delta.x()) * 0.5f; + m_pitchDeg += static_cast(delta.y()) * 0.5f; + m_pitchDeg = qBound(-89.0f, m_pitchDeg, 89.0f); + update(); + } else if (event->buttons() & (Qt::RightButton | Qt::MiddleButton)) { + const float panScale = qMax(0.0005f * m_distance, 0.0005f); + m_panX += static_cast(delta.x()) * panScale; + m_panY -= static_cast(delta.y()) * panScale; + update(); + } +} + +void GlView::wheelEvent(QWheelEvent *event) +{ + const float step = static_cast(event->angleDelta().y()) / 120.0f; + const float zoomFactor = qPow(1.15f, step); + m_distance /= zoomFactor; + m_distance = qBound(m_minDistance, m_distance, m_maxDistance); + update(); +} diff --git a/src/ui/glview.h b/src/ui/glview.h new file mode 100644 index 0000000..27dfd88 --- /dev/null +++ b/src/ui/glview.h @@ -0,0 +1,47 @@ +#ifndef GLVIEW_H +#define GLVIEW_H + +#include +#include +#include +#include +#include +#include + +#include "../geometry/surface_reconstruction.h" + +class GlView : public QOpenGLWidget, protected QOpenGLFunctions +{ + Q_OBJECT + +public: + explicit GlView(QWidget *parent = nullptr); + void setData(const QVector &points, const QVector &triangles); + void setSurfaceVisible(bool visible); + +protected: + void initializeGL() override; + void resizeGL(int w, int h) override; + void paintGL() override; + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + +private: + QVector m_points; + QVector m_triangles; + QOpenGLShaderProgram m_program; + float m_yawDeg; + float m_pitchDeg; + float m_distance; + float m_minDistance; + float m_maxDistance; + QVector3D m_center; + float m_radius; + float m_panX; + float m_panY; + bool m_surfaceVisible; + QPoint m_lastMousePos; +}; + +#endif // GLVIEW_H diff --git a/src/ui/mainwindow.cpp b/src/ui/mainwindow.cpp new file mode 100644 index 0000000..1135be7 --- /dev/null +++ b/src/ui/mainwindow.cpp @@ -0,0 +1,416 @@ +#include "mainwindow.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../geometry/surface_reconstruction.h" +#include "glview.h" + +namespace +{ +struct Dsu +{ + QVector parent; + QVector size; + + explicit Dsu(const int n) + : parent(n) + , size(n, 1) + { + for (int i = 0; i < n; ++i) { + parent[i] = i; + } + } + + int find(int x) + { + while (parent[x] != x) { + parent[x] = parent[parent[x]]; + x = parent[x]; + } + return x; + } + + void unite(const int a, const int b) + { + int ra = find(a); + int rb = find(b); + if (ra == rb) { + return; + } + if (size[ra] < size[rb]) { + qSwap(ra, rb); + } + parent[rb] = ra; + size[ra] += size[rb]; + } +}; + +QVector generateDemoPoints(const int count) +{ + QVector points; + points.reserve(count); + QRandomGenerator rng(42u); + + for (int i = 0; i < count; ++i) { + const float u = rng.generateDouble() * 2.0f - 1.0f; + const float theta = rng.generateDouble() * 2.0f * static_cast(M_PI); + const float r = 1.0f + (rng.generateDouble() * 0.08f - 0.04f); + const float s = qSqrt(qMax(0.0f, 1.0f - u * u)); + + Point3f p; + p.x = r * s * qCos(theta); + p.y = r * s * qSin(theta); + p.z = r * u; + points.push_back(p); + } + + return points; +} + +QVector keepLargestCluster(const QVector &points, int &removedCount, int &clusterCount) +{ + removedCount = 0; + clusterCount = 1; + if (points.size() < 8) { + return points; + } + + QVector nearestDist; + nearestDist.reserve(points.size()); + for (int i = 0; i < points.size(); ++i) { + double best = std::numeric_limits::max(); + for (int j = 0; j < points.size(); ++j) { + if (i == j) { + continue; + } + const double dx = static_cast(points[i].x) - static_cast(points[j].x); + const double dy = static_cast(points[i].y) - static_cast(points[j].y); + const double dz = static_cast(points[i].z) - static_cast(points[j].z); + const double d = qSqrt(dx * dx + dy * dy + dz * dz); + if (d < best) { + best = d; + } + } + if (best < std::numeric_limits::max()) { + nearestDist.push_back(best); + } + } + + if (nearestDist.isEmpty()) { + return points; + } + + std::sort(nearestDist.begin(), nearestDist.end()); + const double medianNn = nearestDist[nearestDist.size() / 2]; + // Conservative threshold: keeps local neighborhood connected, + // while detached sparse groups are separated. + const double joinDistance = qMax(1e-9, medianNn * 5.0); + const double joinDistance2 = joinDistance * joinDistance; + + Dsu dsu(points.size()); + for (int i = 0; i < points.size(); ++i) { + for (int j = i + 1; j < points.size(); ++j) { + const double dx = static_cast(points[i].x) - static_cast(points[j].x); + const double dy = static_cast(points[i].y) - static_cast(points[j].y); + const double dz = static_cast(points[i].z) - static_cast(points[j].z); + const double d2 = dx * dx + dy * dy + dz * dz; + if (d2 <= joinDistance2) { + dsu.unite(i, j); + } + } + } + + QHash compSizes; + for (int i = 0; i < points.size(); ++i) { + const int root = dsu.find(i); + compSizes[root] = compSizes.value(root, 0) + 1; + } + clusterCount = compSizes.size(); + + int largestRoot = -1; + int largestSize = 0; + for (auto it = compSizes.constBegin(); it != compSizes.constEnd(); ++it) { + if (it.value() > largestSize) { + largestSize = it.value(); + largestRoot = it.key(); + } + } + + QVector filtered; + filtered.reserve(largestSize); + for (int i = 0; i < points.size(); ++i) { + if (dsu.find(i) == largestRoot) { + filtered.push_back(points[i]); + } + } + removedCount = points.size() - filtered.size(); + return filtered; +} + +QVector downsampleDenseAreas(const QVector &points, int &removedCount) +{ + removedCount = 0; + if (points.size() < 16) { + return points; + } + + QVector nearestXY; + nearestXY.reserve(points.size()); + for (int i = 0; i < points.size(); ++i) { + double best = std::numeric_limits::max(); + for (int j = 0; j < points.size(); ++j) { + if (i == j) { + continue; + } + const double dx = static_cast(points[i].x) - static_cast(points[j].x); + const double dy = static_cast(points[i].y) - static_cast(points[j].y); + const double d = qSqrt(dx * dx + dy * dy); + if (d < best) { + best = d; + } + } + if (best < std::numeric_limits::max()) { + nearestXY.push_back(best); + } + } + if (nearestXY.isEmpty()) { + return points; + } + + std::sort(nearestXY.begin(), nearestXY.end()); + const double medianXY = nearestXY[nearestXY.size() / 2]; + const double cellSize = qMax(1e-9, medianXY * 0.8); + + struct Bucket + { + double sx; + double sy; + double sz; + int n; + }; + QHash buckets; + buckets.reserve(points.size()); + + for (const Point3f &p : points) { + const qint64 ix = qFloor(static_cast(p.x) / cellSize); + const qint64 iy = qFloor(static_cast(p.y) / cellSize); + const QString key = QString::number(ix) + "_" + QString::number(iy); + if (!buckets.contains(key)) { + buckets.insert(key, {p.x, p.y, p.z, 1}); + } else { + Bucket &b = buckets[key]; + b.sx += p.x; + b.sy += p.y; + b.sz += p.z; + b.n += 1; + } + } + + QVector out; + out.reserve(buckets.size()); + for (auto it = buckets.constBegin(); it != buckets.constEnd(); ++it) { + const Bucket &b = it.value(); + out.push_back({ + static_cast(b.sx / b.n), + static_cast(b.sy / b.n), + static_cast(b.sz / b.n) + }); + } + + removedCount = points.size() - out.size(); + return out; +} +} // namespace + +MainWindow::MainWindow(QWidget *parent) + : QMainWindow(parent) + , m_glView(new GlView(this)) +{ + setWindowTitle("DotsToSirface - Surface Triangulation Demo"); + setCentralWidget(m_glView); + + QAction *loadAction = new QAction("Load points (.txt/.csv)...", this); + QAction *generateDemoAction = new QAction("Generate demo points", this); + QAction *showSurfaceAction = new QAction("Show surface", this); + QAction *neighborRadiusAction = new QAction("Neighbor radius...", this); + showSurfaceAction->setCheckable(true); + showSurfaceAction->setChecked(true); + menuBar()->addAction(loadAction); + menuBar()->addAction(generateDemoAction); + menuBar()->addAction(showSurfaceAction); + menuBar()->addAction(neighborRadiusAction); + + connect(generateDemoAction, &QAction::triggered, this, [this]() { + const QVector points = generateDemoPoints(350); + rebuildSurface(points, "built-in demo"); + }); + + connect(loadAction, &QAction::triggered, this, [this]() { + const QString filePath = QFileDialog::getOpenFileName( + this, + "Open points file", + QString(), + "Point files (*.txt *.csv *.bin);;Binary files (*.bin);;Text files (*.txt);;CSV files (*.csv);;All files (*.*)"); + if (filePath.isEmpty()) { + return; + } + + QVector points; + QString errorText; + if (!loadPointsFromFile(filePath, points, errorText)) { + QMessageBox::warning(this, "Load error", errorText); + return; + } + + int removedCount = 0; + int clusterCount = 1; + const QVector clustered = keepLargestCluster(points, removedCount, clusterCount); + + int denseRemoved = 0; + const QVector filteredPoints = downsampleDenseAreas(clustered, denseRemoved); + + const int totalRemoved = removedCount + denseRemoved; + const QString source = totalRemoved > 0 + ? QString("%1 (clusters: %2, removed: %3, dense: %4)") + .arg(QFileInfo(filePath).fileName()) + .arg(clusterCount) + .arg(totalRemoved) + .arg(denseRemoved) + : QFileInfo(filePath).fileName(); + rebuildSurface(filteredPoints, source); + }); + + connect(showSurfaceAction, &QAction::toggled, this, [this](const bool enabled) { + m_glView->setSurfaceVisible(enabled); + }); + + connect(neighborRadiusAction, &QAction::triggered, this, [this]() { + bool ok = false; + const double value = QInputDialog::getDouble( + this, + "Neighbor radius", + "Radius scale (0.5 .. 20.0):", + static_cast(neighborRadiusScale()), + 0.5, + 20.0, + 2, + &ok); + if (!ok) { + return; + } + setNeighborRadiusScale(static_cast(value)); + if (!m_currentPoints.isEmpty()) { + rebuildSurface(m_currentPoints, m_currentSourceLabel); + } else { + statusBar()->showMessage(QString("Neighbor radius scale set to %1").arg(value), 3500); + } + }); + + const QVector points = generateDemoPoints(350); + rebuildSurface(points, "built-in demo"); +} + +void MainWindow::rebuildSurface(const QVector &points, const QString &sourceLabel) +{ + m_currentPoints = points; + m_currentSourceLabel = sourceLabel; + + QElapsedTimer timer; + timer.start(); + const QVector triangles = buildSurfaceTriangles(points); + const qint64 elapsedMs = timer.elapsed(); + + m_glView->setData(points, triangles); + + statusBar()->showMessage( + QString("Source: %1 | Points: %2 | Triangles: %3 | Time: %4 ms") + .arg(sourceLabel) + .arg(points.size()) + .arg(triangles.size()) + .arg(elapsedMs)); +} + +bool MainWindow::loadPointsFromFile(const QString &filePath, QVector &points, QString &errorText) const +{ + const QString ext = QFileInfo(filePath).suffix().toLower(); + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly)) { + errorText = QString("Cannot open file: %1").arg(filePath); + return false; + } + + QTextStream stream(&file); + points.clear(); + if (ext == "bin") { + const QByteArray raw = file.readAll(); + if (raw.size() % static_cast(sizeof(float) * 3) != 0) { + errorText = "Invalid .bin size: expected multiples of 3 floats (x,y,z)."; + return false; + } + + const int pointCount = raw.size() / static_cast(sizeof(float) * 3); + points.reserve(pointCount); + const float *values = reinterpret_cast(raw.constData()); + for (int i = 0; i < pointCount; ++i) { + const int k = i * 3; + points.push_back({values[k], values[k + 1], values[k + 2]}); + } + } else { + stream.seek(0); + int lineNo = 0; + while (!stream.atEnd()) { + QString line = stream.readLine(); + ++lineNo; + line = line.trimmed(); + + if (line.isEmpty() || line.startsWith('#')) { + continue; + } + + line.replace(';', ' '); + line.replace(',', ' '); + const QStringList parts = line.split(QRegularExpression("\\s+"), QString::SkipEmptyParts); + if (parts.size() < 3) { + continue; + } + + bool okX = false; + bool okY = false; + bool okZ = false; + const float x = parts[0].toFloat(&okX); + const float y = parts[1].toFloat(&okY); + const float z = parts[2].toFloat(&okZ); + + if (!(okX && okY && okZ)) { + errorText = QString("Invalid numeric values at line %1").arg(lineNo); + points.clear(); + return false; + } + + points.push_back({x, y, z}); + } + } + + if (points.size() < 4) { + errorText = "Need at least 4 valid 3D points in file."; + return false; + } + + return true; +} diff --git a/src/ui/mainwindow.h b/src/ui/mainwindow.h new file mode 100644 index 0000000..6b761ef --- /dev/null +++ b/src/ui/mainwindow.h @@ -0,0 +1,28 @@ +#ifndef MAINWINDOW_H +#define MAINWINDOW_H + +#include +#include +#include + +#include "../geometry/surface_reconstruction.h" + +class GlView; + +class MainWindow : public QMainWindow +{ + Q_OBJECT + +public: + explicit MainWindow(QWidget *parent = nullptr); + +private: + void rebuildSurface(const QVector &points, const QString &sourceLabel); + bool loadPointsFromFile(const QString &filePath, QVector &points, QString &errorText) const; + + GlView *m_glView; + QVector m_currentPoints; + QString m_currentSourceLabel; +}; + +#endif // MAINWINDOW_H