From 9f27f3de1533f1af7c62f9e04ac925d68e8aaabd Mon Sep 17 00:00:00 2001 From: Alan Morris Date: Mon, 13 Apr 2026 11:58:11 -0600 Subject: [PATCH 1/3] Fix shared boundary donut: skip extract-largest-component for shared boundary domains, add empty mesh crash guards --- Libs/Groom/Groom.cpp | 6 ++++-- Libs/Groom/GroomParameters.cpp | 13 +++++++++++++ Libs/Groom/GroomParameters.h | 3 +++ Libs/Mesh/MeshUtils.cpp | 27 +++++++++++++++++++++------ Libs/Mesh/MeshUtils.h | 4 ++-- Libs/Optimize/OptimizeParameters.cpp | 3 +++ 6 files changed, 46 insertions(+), 10 deletions(-) diff --git a/Libs/Groom/Groom.cpp b/Libs/Groom/Groom.cpp index c50e444ad5..84be59d443 100644 --- a/Libs/Groom/Groom.cpp +++ b/Libs/Groom/Groom.cpp @@ -385,8 +385,10 @@ bool Groom::mesh_pipeline(std::shared_ptr subject, size_t domain) { //--------------------------------------------------------------------------- bool Groom::run_mesh_pipeline(Mesh& mesh, GroomParameters params, const std::string& filename) { - // Repair mesh: triangulate, extract largest component, clean, fix non-manifold, remove zero-area triangles - mesh = Mesh(MeshUtils::repair_mesh(mesh.getVTKMesh())); + // Repair mesh: triangulate, clean, fix non-manifold, remove zero-area triangles + // Skip extract-largest-component for shared boundary domains to avoid removing fragments at the cut surface + bool extract_largest = !params.is_shared_boundary_domain(); + mesh = Mesh(MeshUtils::repair_mesh(mesh.getVTKMesh(), extract_largest)); if (params.get_fill_mesh_holes_tool()) { mesh.fillHoles(); diff --git a/Libs/Groom/GroomParameters.cpp b/Libs/Groom/GroomParameters.cpp index aaa2c57347..7678460f6a 100644 --- a/Libs/Groom/GroomParameters.cpp +++ b/Libs/Groom/GroomParameters.cpp @@ -544,6 +544,19 @@ bool GroomParameters::get_shared_boundaries_enabled() { //--------------------------------------------------------------------------- void GroomParameters::set_shared_boundaries_enabled(bool enabled) { params_.set(Keys::SHARED_BOUNDARY, enabled); } +//--------------------------------------------------------------------------- +bool GroomParameters::is_shared_boundary_domain() { + if (!get_shared_boundaries_enabled()) { + return false; + } + for (const auto& boundary : get_shared_boundaries()) { + if (boundary.first_domain == domain_name_ || boundary.second_domain == domain_name_) { + return true; + } + } + return false; +} + //--------------------------------------------------------------------------- std::vector GroomParameters::get_shared_boundaries() { std::vector boundaries; diff --git a/Libs/Groom/GroomParameters.h b/Libs/Groom/GroomParameters.h index 346d0f88f9..d86b4d0af2 100644 --- a/Libs/Groom/GroomParameters.h +++ b/Libs/Groom/GroomParameters.h @@ -151,6 +151,9 @@ class GroomParameters { bool get_shared_boundaries_enabled(); void set_shared_boundaries_enabled(bool enabled); + //! Check if the current domain participates in any shared boundary + bool is_shared_boundary_domain(); + std::vector get_shared_boundaries(); void set_shared_boundaries(const std::vector& boundaries); void add_shared_boundary(const std::string& first_domain, const std::string& second_domain, double tolerance); diff --git a/Libs/Mesh/MeshUtils.cpp b/Libs/Mesh/MeshUtils.cpp index 1bbae90c9f..35eabcd2e5 100644 --- a/Libs/Mesh/MeshUtils.cpp +++ b/Libs/Mesh/MeshUtils.cpp @@ -389,6 +389,12 @@ static std::tuple tree; tree.init(other_V, other_F); @@ -482,6 +488,10 @@ std::array MeshUtils::shared_boundary_extractor(const Mesh& mesh_l, con V_r = mesh_r.points(); F_r = mesh_r.faces(); + if (is_empty(V_l, F_l) || is_empty(V_r, F_r)) { + throw std::runtime_error("Input mesh is empty. Cannot extract shared boundary from empty meshes"); + } + Eigen::MatrixXd shared_V_l, shared_V_r, rem_V_l, rem_V_r; Eigen::MatrixXi shared_F_l, shared_F_r, rem_F_l, rem_F_r; std::tie(shared_V_l, shared_F_l, rem_V_l, rem_F_l) = find_shared_surface(V_l, F_l, V_r, F_r, tol); @@ -1073,18 +1083,23 @@ vtkSmartPointer MeshUtils::recreate_mesh(vtkSmartPointer MeshUtils::repair_mesh(vtkSmartPointer mesh) { +vtkSmartPointer MeshUtils::repair_mesh(vtkSmartPointer mesh, bool extract_largest) { auto triangle_filter = vtkSmartPointer::New(); triangle_filter->SetInputData(mesh); triangle_filter->PassLinesOff(); triangle_filter->Update(); - auto connectivity = vtkSmartPointer::New(); - connectivity->SetInputConnection(triangle_filter->GetOutputPort()); - connectivity->SetExtractionModeToLargestRegion(); - connectivity->Update(); + vtkSmartPointer triangulated = triangle_filter->GetOutput(); + + if (extract_largest) { + auto connectivity = vtkSmartPointer::New(); + connectivity->SetInputData(triangulated); + connectivity->SetExtractionModeToLargestRegion(); + connectivity->Update(); + triangulated = connectivity->GetOutput(); + } - auto cleaned = MeshUtils::clean_mesh(connectivity->GetOutput()); + auto cleaned = MeshUtils::clean_mesh(triangulated); auto fixed = Mesh(cleaned).fixNonManifold(); diff --git a/Libs/Mesh/MeshUtils.h b/Libs/Mesh/MeshUtils.h index 7cf1b01eb0..62ca811c08 100644 --- a/Libs/Mesh/MeshUtils.h +++ b/Libs/Mesh/MeshUtils.h @@ -86,7 +86,7 @@ class MeshUtils { /// Recreate mesh, dropping deleted cells static vtkSmartPointer recreate_mesh(vtkSmartPointer mesh); - /// Repair mesh: triangulate, extract largest component, clean, fix non-manifold, remove zero-area triangles - static vtkSmartPointer repair_mesh(vtkSmartPointer mesh); + /// Repair mesh: triangulate, optionally extract largest component, clean, fix non-manifold, remove zero-area triangles + static vtkSmartPointer repair_mesh(vtkSmartPointer mesh, bool extract_largest = true); }; } // namespace shapeworks diff --git a/Libs/Optimize/OptimizeParameters.cpp b/Libs/Optimize/OptimizeParameters.cpp index d6e6f318ff..b4660be28f 100644 --- a/Libs/Optimize/OptimizeParameters.cpp +++ b/Libs/Optimize/OptimizeParameters.cpp @@ -328,6 +328,9 @@ std::vector>> OptimizeParameters::get_initial_poi for (auto s : subjects) { if (s->is_fixed()) { count++; + if (d >= s->get_world_particle_filenames().size()) { + throw std::runtime_error("Subject " + s->get_display_name() + " does not have enough world particle files"); + } // read the world points that are in the shared coordinate space auto filename = s->get_world_particle_filenames()[d]; auto particles = read_particles_as_vector(filename); From 940bb2339c6a67e8a6310f206e8015885dcad18f Mon Sep 17 00:00:00 2001 From: Alan Morris Date: Tue, 14 Apr 2026 00:05:36 -0600 Subject: [PATCH 2/3] Fix shared boundary donut and contour correspondence issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MeshUtils: replace fragile is_clockwise atan2 test with summed signed-area vector (uses all vertices, immune to branch-cut flips); rotate boundary loops to start at canonical max-Y vertex for consistent ordering across subjects. - ContourDomain: return length² from GetSurfaceArea so contours participate in sampling-scale auto-scaling at a magnitude comparable to meshes. - SamplingFunction: apply auto-scale uniformly; contours no longer require special-case skipping. - GroomParameters: skip phantom shared-boundary entry on migration when domain names are empty. - Regenerate shared boundary test ground truth for canonical start vertex. --- Libs/Groom/GroomParameters.cpp | 4 +- Libs/Mesh/MeshUtils.cpp | 52 +++++++++++++++++---- Libs/Optimize/Domain/ContourDomain.cpp | 4 +- Libs/Optimize/Domain/ContourDomain.h | 8 +++- Libs/Optimize/Function/SamplingFunction.cpp | 5 +- Testing/data/shared_boundary/00_out_c.vtp | 4 +- 6 files changed, 61 insertions(+), 16 deletions(-) diff --git a/Libs/Groom/GroomParameters.cpp b/Libs/Groom/GroomParameters.cpp index 7678460f6a..f0dd9ccaaa 100644 --- a/Libs/Groom/GroomParameters.cpp +++ b/Libs/Groom/GroomParameters.cpp @@ -589,7 +589,9 @@ std::vector GroomParameters::get_shared_boundar boundary.second_domain = std::string(params_.get(Keys::SHARED_BOUNDARY_SECOND_DOMAIN, Defaults::shared_boundary_second_domain)); boundary.tolerance = params_.get(Keys::SHARED_BOUNDARY_TOLERANCE, Defaults::shared_boundary_tolerance); - boundaries.push_back(boundary); + if (!boundary.first_domain.empty() && !boundary.second_domain.empty()) { + boundaries.push_back(boundary); + } // Migrate to new format set_shared_boundaries(boundaries); diff --git a/Libs/Mesh/MeshUtils.cpp b/Libs/Mesh/MeshUtils.cpp index 35eabcd2e5..3f2166060d 100644 --- a/Libs/Mesh/MeshUtils.cpp +++ b/Libs/Mesh/MeshUtils.cpp @@ -266,6 +266,10 @@ int MeshUtils::findReferenceMesh(std::vector& meshes, int random_subset_si */ //--------------------------------------------------------------------------- +// Robust orientation test: compute the loop's signed-area vector by summing edge +// cross products. The dominant axis of this vector gives the loop's normal; its sign +// determines orientation. Uses ALL vertices, so it is insensitive to where loop[0] +// happens to start and does not suffer from atan2 branch-cut flips. static bool is_clockwise(const Eigen::MatrixXd& V, const Eigen::MatrixXi& F, const std::vector& loop) { Eigen::RowVector3d centroid{0.0, 0.0, 0.0}; for (const auto& i : loop) { @@ -273,13 +277,20 @@ static bool is_clockwise(const Eigen::MatrixXd& V, const Eigen::MatrixXi& F, con } centroid /= loop.size(); - // todo this is arbitrary and works for the peanut data and initial tests on LA+Septum data - // it enforces a consistent ordering in the boundary loop - const auto v0 = V.row(loop[0]) - centroid; - const auto v1 = V.row(loop[1]) - centroid; - const double angle0 = atan2(v0.z(), v0.y()); - const double angle1 = atan2(v1.z(), v1.y()); - return angle0 > angle1; + Eigen::RowVector3d area_vec{0.0, 0.0, 0.0}; + for (size_t i = 0; i < loop.size(); i++) { + const Eigen::RowVector3d v0 = V.row(loop[i]) - centroid; + const Eigen::RowVector3d v1 = V.row(loop[(i + 1) % loop.size()]) - centroid; + area_vec += v0.cross(v1); + } + + // Use the dominant axis of the area vector as the reference normal. Calling "clockwise" + // the case where the area vector points in the negative dominant-axis direction yields + // consistent results across samples that share the same boundary plane. + int max_axis = 0; + if (std::abs(area_vec[1]) > std::abs(area_vec[max_axis])) max_axis = 1; + if (std::abs(area_vec[2]) > std::abs(area_vec[max_axis])) max_axis = 2; + return area_vec[max_axis] < 0; } //--------------------------------------------------------------------------- @@ -293,7 +304,32 @@ Mesh MeshUtils::extract_boundary_loop(Mesh mesh) { throw std::runtime_error("Expected at least one boundary loop in the mesh"); } - const auto& loop = loops[0]; + auto loop = loops[0]; // copy so we can rotate it to a canonical start vertex + + // Rotate the loop so it always starts at a canonical vertex (the one with the + // largest Y coordinate; lexicographic tiebreakers on Z then X). igl::boundary_loop + // returns loops starting at an arbitrary vertex, which produces per-subject + // rotational offsets that destroy inter-subject correspondence on contour domains. + { + size_t canonical = 0; + for (size_t i = 1; i < loop.size(); i++) { + const double cur_y = V(loop[i], 1); + const double cur_z = V(loop[i], 2); + const double cur_x = V(loop[i], 0); + const double best_y = V(loop[canonical], 1); + const double best_z = V(loop[canonical], 2); + const double best_x = V(loop[canonical], 0); + if (cur_y > best_y || + (cur_y == best_y && cur_z > best_z) || + (cur_y == best_y && cur_z == best_z && cur_x > best_x)) { + canonical = i; + } + } + if (canonical != 0) { + std::rotate(loop.begin(), loop.begin() + canonical, loop.end()); + } + } + const auto is_cw = is_clockwise(V, F, loop); auto pts = vtkSmartPointer::New(); diff --git a/Libs/Optimize/Domain/ContourDomain.cpp b/Libs/Optimize/Domain/ContourDomain.cpp index 84527a077b..d71c6aa2ac 100644 --- a/Libs/Optimize/Domain/ContourDomain.cpp +++ b/Libs/Optimize/Domain/ContourDomain.cpp @@ -344,13 +344,13 @@ int ContourDomain::NumberOfLinesIncidentOnPoint(int i) const { } void ContourDomain::ComputeAvgEdgeLength() { - const double total_length = std::accumulate(lines_.begin(), lines_.end(), + total_length_ = std::accumulate(lines_.begin(), lines_.end(), 0.0, [&](double s, const vtkSmartPointer &line) { const auto pt_a = GetPoint(line->GetPointId(0)); const auto pt_b = GetPoint(line->GetPointId(1)); return s + (pt_a - pt_b).norm(); }); - avg_edge_length_ = total_length / lines_.size(); + avg_edge_length_ = total_length_ / lines_.size(); } } diff --git a/Libs/Optimize/Domain/ContourDomain.h b/Libs/Optimize/Domain/ContourDomain.h index 6966356bb2..a492363b43 100644 --- a/Libs/Optimize/Domain/ContourDomain.h +++ b/Libs/Optimize/Domain/ContourDomain.h @@ -86,8 +86,11 @@ class ContourDomain : public ParticleDomain { } double GetSurfaceArea() const override { - // TODO: Implement something analogous for scaling purposes - return 1.0; + // Return length² as an area-equivalent for a contour so it participates in the + // sampling-scale auto-scaling. For a circle of radius r this is (2πr)² = 4π²r², + // which is π times the matching sphere's surface area — comparable magnitude so + // the scale factor doesn't crush the contour gradient. + return total_length_ * total_length_; } void DeleteImages() override { @@ -132,6 +135,7 @@ class ContourDomain : public ParticleDomain { mutable double geo_lq_dist_ = -1; double avg_edge_length_{0.0}; + double total_length_{0.0}; void ComputeBounds(); void ComputeGeodesics(vtkSmartPointer poly_data); diff --git a/Libs/Optimize/Function/SamplingFunction.cpp b/Libs/Optimize/Function/SamplingFunction.cpp index e8659c0280..54add28614 100644 --- a/Libs/Optimize/Function/SamplingFunction.cpp +++ b/Libs/Optimize/Function/SamplingFunction.cpp @@ -250,7 +250,10 @@ SamplingFunction::VectorType SamplingFunction::evaluate(unsigned int idx, unsign gradE = gradE / m_avgKappa; - // Apply sampling scale if enabled + // Apply sampling scale if enabled. Contour domains return length² from GetSurfaceArea, + // giving a scale factor comparable in magnitude to the equivalent mesh's — without this, + // contour particles would move at native gradient magnitudes (~1000× stronger than scaled + // mesh particles), causing them to spin rapidly around the loop instead of settling. if (m_SamplingScale) { double scale_factor = 1.0; diff --git a/Testing/data/shared_boundary/00_out_c.vtp b/Testing/data/shared_boundary/00_out_c.vtp index fe78f53510..284ee71432 100644 --- a/Testing/data/shared_boundary/00_out_c.vtp +++ b/Testing/data/shared_boundary/00_out_c.vtp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:06fb0a7bfbf2fe03d29f855a7156adacd2bd46596f26a36409df0531995daff6 -size 2851 +oid sha256:95657cfe31442ba0bdeaeb7116752883c8dae291077d5e0c5cf95cee52c41a23 +size 4074 From 072aa5f3a3242405d9ac7d5a993e9f2a09aacc46 Mon Sep 17 00:00:00 2001 From: Alan Morris Date: Tue, 14 Apr 2026 00:17:10 -0600 Subject: [PATCH 3/3] Fix optimize panel layout for long domain names and narrow textboxes - Add ElidedLabel class that truncates long domain names with "..." when column space is tight, while reporting full-text sizeHint so the layout allocates natural width when available. Full name shown as tooltip. - Cap optimize textboxes to 50-100px (min/max) so they don't expand and crowd out the label column; ensures values like "0.05" aren't truncated. --- Studio/Optimize/OptimizeTool.cpp | 48 ++++++++- Studio/Optimize/OptimizeTool.ui | 168 +++++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+), 1 deletion(-) diff --git a/Studio/Optimize/OptimizeTool.cpp b/Studio/Optimize/OptimizeTool.cpp index 26c5dede0d..4def54d7e4 100644 --- a/Studio/Optimize/OptimizeTool.cpp +++ b/Studio/Optimize/OptimizeTool.cpp @@ -2,8 +2,11 @@ // qt #include +#include #include +#include #include +#include #include #include @@ -22,6 +25,47 @@ using namespace shapeworks; +namespace { +// QLabel that elides its text with "..." when it's narrower than the text. +// The full text is kept as a tooltip so the user can see it on hover. +// sizeHint() reports the full-text width so the layout gives the label its natural +// space when available; elision only happens when the layout must shrink it. +class ElidedLabel : public QLabel { + public: + explicit ElidedLabel(const QString& text, QWidget* parent = nullptr) : QLabel(parent), full_text_(text) { + setToolTip(text); + updateElidedText(); + } + + QSize sizeHint() const override { + QFontMetrics metrics(font()); + return QSize(metrics.horizontalAdvance(full_text_) + 4, QLabel::sizeHint().height()); + } + + QSize minimumSizeHint() const override { + // Minimum: enough for ellipsis plus a couple characters so the label can shrink + // further if the panel is very narrow, but not to zero width. + QFontMetrics metrics(font()); + return QSize(metrics.horizontalAdvance(QStringLiteral("X...")), QLabel::minimumSizeHint().height()); + } + + protected: + void resizeEvent(QResizeEvent* event) override { + QLabel::resizeEvent(event); + updateElidedText(); + } + + private: + void updateElidedText() { + QFontMetrics metrics(font()); + QString elided = metrics.elidedText(full_text_, Qt::ElideRight, width()); + QLabel::setText(elided); + } + + QString full_text_; +}; +} // namespace + //--------------------------------------------------------------------------- OptimizeTool::OptimizeTool(Preferences& prefs, Telemetry& telemetry) : preferences_(prefs), telemetry_(telemetry) { ui_ = new Ui_OptimizeTool; @@ -473,6 +517,8 @@ void OptimizeTool::setup_domain_boxes() { QLineEdit* box = new QLineEdit(this); last_box = box; box->setAlignment(Qt::AlignHCenter); + box->setMinimumWidth(50); + box->setMaximumWidth(100); box->setValidator(above_zero); box->setText(ui_->number_of_particles->text()); connect(box, &QLineEdit::textChanged, this, &OptimizeTool::update_run_button); @@ -482,7 +528,7 @@ void OptimizeTool::setup_domain_boxes() { } else { auto domain_names = session_->get_project()->get_domain_names(); for (int i = 0; i < domain_names.size(); i++) { - auto label = new QLabel(QString::fromStdString(domain_names[i]), this); + auto label = new ElidedLabel(QString::fromStdString(domain_names[i]), this); label->setAlignment(Qt::AlignRight | Qt::AlignVCenter); grid->addWidget(label, i, 1); domain_grid_widgets_.push_back(label); diff --git a/Studio/Optimize/OptimizeTool.ui b/Studio/Optimize/OptimizeTool.ui index 0e7523536e..e52c39816d 100644 --- a/Studio/Optimize/OptimizeTool.ui +++ b/Studio/Optimize/OptimizeTool.ui @@ -320,6 +320,18 @@ QWidget#optimize_panel { 0.05 + + + 50 + 0 + + + + + 100 + 16777215 + + Qt::AlignCenter @@ -338,6 +350,18 @@ QWidget#optimize_panel { 1.0 + + + 50 + 0 + + + + + 100 + 16777215 + + Qt::AlignCenter @@ -356,6 +380,18 @@ QWidget#optimize_panel { 10 + + + 50 + 0 + + + + + 100 + 16777215 + + Qt::AlignCenter @@ -374,6 +410,18 @@ QWidget#optimize_panel { 1.0 + + + 50 + 0 + + + + + 100 + 16777215 + + Qt::AlignCenter @@ -392,6 +440,18 @@ QWidget#optimize_panel { 1000 + + + 50 + 0 + + + + + 100 + 16777215 + + Qt::AlignCenter @@ -410,6 +470,18 @@ QWidget#optimize_panel { 1000 + + + 50 + 0 + + + + + 100 + 16777215 + + Qt::AlignCenter @@ -428,6 +500,18 @@ QWidget#optimize_panel { 000 + + + 50 + 0 + + + + + 100 + 16777215 + + Qt::AlignCenter @@ -464,6 +548,18 @@ QWidget#optimize_panel { 100 + + + 50 + 0 + + + + + 100 + 16777215 + + Qt::AlignCenter @@ -492,6 +588,18 @@ QWidget#optimize_panel { 10 + + + 50 + 0 + + + + + 100 + 16777215 + + Qt::AlignCenter @@ -520,6 +628,18 @@ QWidget#optimize_panel { 1 + + + 50 + 0 + + + + + 100 + 16777215 + + Qt::AlignCenter @@ -548,6 +668,18 @@ QWidget#optimize_panel { 32 + + + 50 + 0 + + + + + 100 + 16777215 + + Qt::AlignCenter @@ -576,6 +708,18 @@ QWidget#optimize_panel { 0.1 + + + 50 + 0 + + + + + 100 + 16777215 + + Qt::AlignCenter @@ -620,6 +764,18 @@ QWidget#optimize_panel { 10 + + + 50 + 0 + + + + + 100 + 16777215 + + Qt::AlignCenter @@ -705,6 +861,18 @@ QWidget#optimize_panel { 1.0 + + + 50 + 0 + + + + + 100 + 16777215 + + Qt::AlignCenter