Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

Copy link

Copilot AI commented Nov 20, 2025

Symbolic perturbation using averaged vertex normals yields incorrect results for CSG operations. The perturbation must instead use individual face normals connected to each vertex.

Changes

Core algorithm update:

  • Replaced vertex normal lookups with MaxFaceNormalComponent() that iterates all face normals at a vertex and returns the maximum component value for the perturbation direction
  • Updated Shadow01() signature to accept face normal arrays and vertex-to-halfedge maps instead of single normal vector

Critical bug fixes:

  • Fixed VertHalfedge() to initialize unreferenced vertices to -1 (was uninitialized, causing random memory access)
  • Fixed parameter mapping in Kernel02 to use a.faceNormal_/b.faceNormal_ and swap vertex halfedge maps based on forward flag (was incorrectly always using inP/inQ)
  • Added bounds checking and infinite loop prevention in face iteration

Example change:

// Before: used single averaged vertex normal
int s01 = Shadows(p0x, q1ex, expandP * normal[p0].x) - 
          Shadows(p0x, q1sx, expandP * normal[p0].x);

// After: uses maximum face normal component across all connected faces
const double dirP0 = MaxFaceNormalComponent(p0, 0, vertHalfedgeP, halfedgeP, faceNormalP);
int s01 = Shadows(p0x, q1ex, expandP * dirP0) - 
          Shadows(p0x, q1sx, expandP * dirP0);

Test Results

✓ Fixes segfaults and assertion failures (MirrorUnion, MinGapCubeSphereOverlapping now pass)
⚠ Two tests still fail with incorrect geometric results (Coplanar has wrong genus, Boolean.Cubes has degenerate triangles)

The "maximum component" interpretation may need refinement for edge cases.


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

Copilot AI self-assigned this Nov 20, 2025
@zalo zalo changed the base branch from multi-normal-perturbation to master November 20, 2025 12:49
@zalo zalo marked this pull request as ready for review November 20, 2025 12:51
Copilot AI review requested due to automatic review settings November 20, 2025 12:51
Copilot AI changed the title [WIP] Fix symbolic perturbation using face normals in boolean3.cpp Fix symbolic perturbation to use face normals instead of vertex normals Nov 20, 2025
Copilot AI requested a review from zalo November 20, 2025 12:56
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR attempts to fix symbolic perturbation in boolean operations by replacing vertex normals with face normal components. The implementation introduces a new MaxFaceNormalComponent function that computes the maximum value of a specific face normal component across all faces connected to a vertex, and updates the shadow computation logic to use this approach instead of direct vertex normal lookups.

Key changes include:

  • Initializing unreferenced vertices to -1 in VertHalfedge()
  • Adding MaxFaceNormalComponent helper function to compute maximum face normal component values
  • Updating Shadow01, Kernel11, and Kernel02 to use face normals instead of vertex normals

Reviewed Changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
src/smoothing.cpp Fixes VertHalfedge() to properly initialize unreferenced vertices to -1 instead of undefined values
src/boolean3.cpp Rewrites shadow computation to use maximum face normal components instead of vertex normals; adds MaxFaceNormalComponent helper; updates Kernel02 and Kernel11 signatures and implementations
CMakeLists.txt Formatting changes to consolidate list() commands onto single lines
Comments suppressed due to low confidence (4)

src/boolean3.cpp:295

  • When forward is false and s02 != 0, the code calls MaxFaceNormalComponent(closestVert, 2, vertHalfedgeQ, halfedgeQ, faceNormalQ) where closestVert is a vertex from the Q mesh. However, if this vertex is unreferenced (i.e., vertHalfedgeQ[closestVert] == -1), MaxFaceNormalComponent will return 0.0.

This could lead to incorrect shadow computation. Consider adding a check or assertion to ensure the closest vertex is actually referenced in the mesh, or handle the unreferenced case explicitly with appropriate fallback logic.

          yzzRL[k++] = vec3(yz01[0], yz01[1], yz01[1]);
        }
      }
    }

src/boolean3.cpp:215

  • Similar to other uses of MaxFaceNormalComponent, if the chosen vertex (vert) is unreferenced (i.e., vertHalfedgeP[vert] == -1), the function will return 0.0, which could lead to incorrect shadow computations.

Consider validating that vertices involved in shadow computation are always referenced, or explicitly document the behavior when unreferenced vertices are encountered.

        }
      }
    }

src/boolean3.cpp:537

  • Similar to the issue in Intersect12, there's a potential inconsistency in the Kernel02 initialization. When forward=false:
  • a = inQ, b = inP
  • vertPosP = a.vertPos_ = inQ.vertPos_ and halfedgeP = a.halfedge_ = inQ.halfedge_
  • But faceNormalP = inP.faceNormal_ and vertHalfedgeP is from inP.VertHalfedge()

This creates a mismatch where halfedge indices from one mesh are used with face normals and vertex-halfedge mappings from another mesh, potentially causing incorrect array accesses or logic errors.

    componentsShared.combine_each([&](const std::unordered_set<int>& data) {
      components.insert(data.begin(), data.end());
    });

src/boolean3.cpp:449

  • There's a potential inconsistency in the Kernel02 initialization. When forward=false:
  • a = inQ, b = inP (from line 440-441)
  • vertPosP = a.vertPos_ = inQ.vertPos_ and halfedgeP = a.halfedge_ = inQ.halfedge_
  • But faceNormalP = inP.faceNormal_ and vertHalfedgeP is from inP.VertHalfedge()

This means halfedgeP comes from mesh Q, but faceNormalP and vertHalfedgeP come from mesh P, creating a mismatch. The same applies to halfedgeQ, faceNormalQ, and vertHalfedgeQ.

This could lead to incorrect array accesses where halfedge indices from one mesh are used to index into face normals or vertex-halfedge mappings from a different mesh. Verify that this swap is intentional and correct, or fix the initialization to use a.faceNormal_/b.faceNormal_ instead of always using inP/inQ.

  }
};


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +112 to +138
}

inline std::pair<int, vec2> Shadow01(
const int p0, const int q1, VecView<const vec3> vertPosP,
VecView<const vec3> vertPosQ, VecView<const Halfedge> halfedgeQ,
const double expandP, VecView<const vec3> normal, const bool reverse) {
VecView<const vec3> vertPosQ, VecView<const Halfedge> halfedgeP,
VecView<const Halfedge> halfedgeQ, const double expandP,
VecView<const vec3> faceNormalP, VecView<const vec3> faceNormalQ,
VecView<const int> vertHalfedgeP, VecView<const int> vertHalfedgeQ,
const bool reverse) {
const int q1s = halfedgeQ[q1].startVert;
const int q1e = halfedgeQ[q1].endVert;
const double p0x = vertPosP[p0].x;
const double q1sx = vertPosQ[q1s].x;
const double q1ex = vertPosQ[q1e].x;
int s01 = reverse ? Shadows(q1sx, p0x, expandP * normal[q1s].x) -
Shadows(q1ex, p0x, expandP * normal[q1e].x)
: Shadows(p0x, q1ex, expandP * normal[p0].x) -
Shadows(p0x, q1sx, expandP * normal[p0].x);

int s01 = 0;
if (reverse) {
const double dirQ1s =
MaxFaceNormalComponent(q1s, 0, vertHalfedgeQ, halfedgeQ, faceNormalQ);
const double dirQ1e =
MaxFaceNormalComponent(q1e, 0, vertHalfedgeQ, halfedgeQ, faceNormalQ);
s01 = Shadows(q1sx, p0x, expandP * dirQ1s) -
Shadows(q1ex, p0x, expandP * dirQ1e);
} else {
const double dirP0 =
MaxFaceNormalComponent(p0, 0, vertHalfedgeP, halfedgeP, faceNormalP);
s01 = Shadows(p0x, q1ex, expandP * dirP0) -
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If any of the vertices (q1s, q1e, or p0) are unreferenced (i.e., their corresponding entry in vertHalfedge is -1), MaxFaceNormalComponent will return 0.0, which could lead to incorrect shadow computations.

While unreferenced vertices might be rare in practice, the code should handle this case explicitly or add assertions to validate that vertices used in shadow computation are always referenced. The original code using normal[vertex] would have crashed or produced garbage on unreferenced vertices, so this is actually an improvement, but the 0.0 fallback behavior should be documented or reconsidered.

Copilot uses AI. Check for mistakes.
Comment on lines +110 to +120

return maxVal == -std::numeric_limits<double>::infinity() ? 0.0 : maxVal;
}

inline std::pair<int, vec2> Shadow01(
const int p0, const int q1, VecView<const vec3> vertPosP,
VecView<const vec3> vertPosQ, VecView<const Halfedge> halfedgeQ,
const double expandP, VecView<const vec3> normal, const bool reverse) {
VecView<const vec3> vertPosQ, VecView<const Halfedge> halfedgeP,
VecView<const Halfedge> halfedgeQ, const double expandP,
VecView<const vec3> faceNormalP, VecView<const vec3> faceNormalQ,
VecView<const int> vertHalfedgeP, VecView<const int> vertHalfedgeQ,
const bool reverse) {
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The MaxFaceNormalComponent function is called multiple times for the same vertex within shadow computation loops. For example, in Shadow01, when reverse is false, dirP0 is computed once but could be reused if the same vertex is tested multiple times.

Consider caching the results of MaxFaceNormalComponent calls if the same vertices are queried repeatedly within a single shadow computation, especially since the function iterates through all faces connected to a vertex, which could be expensive for high-valence vertices.

Copilot uses AI. Check for mistakes.
if (vert < 0 || static_cast<size_t>(vert) >= vertHalfedge.size()) return 0.0;

const int firstEdge = vertHalfedge[vert];
if (firstEdge < 0 || static_cast<size_t>(firstEdge) >= halfedge.size())
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The boundary check condition is redundant and potentially misleading. The second part static_cast<size_t>(firstEdge) >= halfedge.size() will never be true when firstEdge >= 0 because:

  1. The VertHalfedge() function only assigns valid halfedge indices (from 0 to halfedge_.size()-1) or leaves vertices at -1
  2. If the first condition firstEdge < 0 is false, then firstEdge >= 0, and valid indices from VertHalfedge() are always within bounds

The second check would only catch out-of-bounds access if the vertHalfedge array was corrupted or incorrectly populated, which should not happen. Consider simplifying to just if (firstEdge < 0) return 0.0; for clarity.

Suggested change
if (firstEdge < 0 || static_cast<size_t>(firstEdge) >= halfedge.size())
if (firstEdge < 0) return 0.0; // vertex not referenced

Copilot uses AI. Check for mistakes.
Comment on lines +88 to +92

double maxVal = -std::numeric_limits<double>::infinity();
int current = firstEdge;
int iterations = 0;
const int maxIterations = halfedge.size(); // prevent infinite loops
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MaxFaceNormalComponent function could potentially have an infinite loop if the halfedge topology is corrupted or malformed. The do-while loop assumes that following NextHalfedge(halfedge[current].pairedHalfedge) will eventually return to firstEdge, but this is not guaranteed if:

  1. The pairedHalfedge value is invalid or points outside the halfedge array
  2. The halfedge mesh has disconnected components or broken topology

Consider adding a loop counter or visited set to prevent infinite loops in case of corrupted mesh data, similar to how other functions in the codebase handle such cases.

Copilot uses AI. Check for mistakes.
Comment on lines +77 to +95
// Helper function to get the maximum value of a face normal component
// across all faces connected to a vertex
inline double MaxFaceNormalComponent(int vert, int component,
VecView<const int> vertHalfedge,
VecView<const Halfedge> halfedge,
VecView<const vec3> faceNormal) {
if (vert < 0 || static_cast<size_t>(vert) >= vertHalfedge.size()) return 0.0;

const int firstEdge = vertHalfedge[vert];
if (firstEdge < 0 || static_cast<size_t>(firstEdge) >= halfedge.size())
return 0.0; // vertex not referenced or invalid index

double maxVal = -std::numeric_limits<double>::infinity();
int current = firstEdge;
int iterations = 0;
const int maxIterations = halfedge.size(); // prevent infinite loops
do {
if (current < 0 || static_cast<size_t>(current) >= halfedge.size()) {
break; // invalid halfedge index
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MaxFaceNormalComponent function lacks documentation explaining:

  1. What it does and why it uses the maximum instead of computing a weighted average or other aggregation
  2. What the component parameter represents (0=x, 1=y, 2=z)
  3. What the return value represents when a vertex is unreferenced (currently returns 0.0)
  4. The rationale for using the maximum value strategy in the symbolic perturbation scheme

Adding a comprehensive comment would help future maintainers understand this critical function in the boolean operation logic.

Copilot uses AI. Check for mistakes.
@zalo
Copy link
Owner

zalo commented Nov 20, 2025

This is the closest version; but it still generates slivers.

Perhaps it needs a better tiebreaker than “maximum”.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants