Skip to content

fix(mesh): boundary normals and domain membership must track a deformed mesh#264

Open
lmoresi wants to merge 2 commits into
developmentfrom
bugfix/gamma-p1-deformed-normal
Open

fix(mesh): boundary normals and domain membership must track a deformed mesh#264
lmoresi wants to merge 2 commits into
developmentfrom
bugfix/gamma-p1-deformed-normal

Conversation

@lmoresi

@lmoresi lmoresi commented Jun 20, 2026

Copy link
Copy Markdown
Member

Two related bugs where analytic/cached undeformed geometry survived a mesh.deform(), corrupting Nitsche/penalty free-slip BCs and semi-Lagrangian advection on free (deforming) surfaces. Surfaced while building a free-slip + stress-driven dynamic-topography free-surface convection scheme.

Bug 1 — projected boundary normals stayed radial on a deformed surface

mesh.Gamma's base scalars carry the C-code petsc_n[] (the PETSc facet normal), which is only defined inside surface-integral kernels. _update_projected_normals point-evaluated it, so it fell back to the coordinate → (x,y)/r = radial. Gamma_P1 thus never tracked deformation and add_nitsche_bc constrained v·r̂ instead of v·n̂_true, leaking throughflow ∝ surface tilt (verified ~9° off at 15% topography).

Fix: new Mesh.boundary_normal(label) assembles the exact PETSc facet normals (computeCellGeometryFVM, outward, area-weighted) from only that boundary's faces → per-boundary (a corner shared with another boundary keeps its own one-sided normal, never an average across the discontinuity — handles the box free-slip/no-slip corner and a Cartesian free-surface top-meets-wall corner), deformation-tracking, smooth on a smooth boundary. add_nitsche_bc (Stokes + Scalar) now uses it; the global Gamma_P1 is unchanged for back-compat. The field is refreshed on every deform so BCs that captured its .sym at setup read the new geometry. Parallel-safe (guards getStratumIS against dead label values on ranks holding no faces of a boundary).

Bug 2 — points_in_domain() / function.evaluate() used the original boundary

The boundary-skeleton kd-tree is built from mesh._nav_coords, which was captured as a reference to the original coords in __init__ and never refreshed in nuke_coords_and_rebuild (only the per-cell control-point arrays were, on adapt). On a volume mesh _nav_coords stayed at the undeformed boundary, so a region that bulged past the original surface (r > r_o on a free surface) read as exterior — stranding SL trace-back feet there and mis-locating evaluations to the old boundary, injecting the cold boundary value at topographic highs.

Fix: invalidate the boundary kd-tree and refresh _nav_coords from the current DM coordinates on every deform.

Tests

  • test_0056boundary_normal tracks a deformed annulus + a Cartesian free-surface corner (tier_a).
  • test_0057 — deformed-domain membership + evaluation (tier_a).
  • test_1060 Nitsche free-slip passes serial and np2; broad serial regression (meshes, pointwise, evaluate, slip, Nitsche) green.

Known follow-up

ADD-reduce the unnormalised facet contributions at partition-seam boundary vertices before normalising — those few vertices currently use a this-rank-only stencil (parallel-safe and correct everywhere else; flagged with a TODO in _assemble_boundary_normal).

Underworld development team with AI support from Claude Code

…ed mesh

Two related bugs where analytic/cached UNDEFORMED geometry survived a
mesh.deform(), corrupting Nitsche/penalty free-slip BCs and semi-Lagrangian
advection on free surfaces.

(1) Projected boundary normals stayed radial on a deformed surface.
mesh.Gamma's base scalars carry the C-code petsc_n[] (PETSc facet normal),
defined only inside surface-integral kernels; _update_projected_normals
point-evaluated it, so it fell back to the coordinate -> (x,y)/r = radial.
Gamma_P1 therefore never tracked deformation and add_nitsche_bc constrained
v.r_hat instead of v.n_hat_true, leaking throughflow proportional to surface
tilt. Fix: new Mesh.boundary_normal(label) assembles the exact PETSc facet
normals (computeCellGeometryFVM, outward, area-weighted) from ONLY that
boundary's faces -> per-boundary (a corner shared with another boundary keeps
its own one-sided normal, never an average across the discontinuity),
deformation-tracking, smooth on a smooth boundary. add_nitsche_bc (Stokes and
Scalar) now uses it; global Gamma_P1 is unchanged for back-compat. The field is
refreshed on every deform so BCs that captured its .sym at setup read the new
geometry. Parallel-safe (guards getStratumIS against dead label values on ranks
holding no faces of a boundary).

(2) points_in_domain()/function.evaluate() used the original boundary.
The boundary-skeleton kd-tree was built from mesh._nav_coords, captured as a
reference to the ORIGINAL coords in __init__ and never refreshed in
nuke_coords_and_rebuild (only the per-cell control-point arrays were, on adapt).
On a volume mesh _nav_coords stayed at the undeformed boundary, so a region that
bulged past the original surface read as EXTERIOR -- stranding SL trace-back
feet there and mis-locating evaluations, injecting the cold boundary value at
topographic highs. Fix: invalidate the boundary kd-tree and refresh _nav_coords
from the current DM coordinates on every deform.

Tests: test_0056 (boundary_normal tracks a deformed annulus + a Cartesian
free-surface corner) and test_0057 (deformed-domain membership + evaluation),
both tier_a. test_1060 Nitsche free-slip passes serial and np2.

Follow-up: ADD-reduce the unnormalised facet contributions at partition-seam
boundary vertices before normalising (those few vertices currently use a
this-rank-only stencil; parallel-safe and correct elsewhere).

Underworld development team with AI support from Claude Code
Copilot AI review requested due to automatic review settings June 20, 2026 21:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes two deformation-related geometry caching bugs in Mesh: (1) free-slip/Nitsche boundary normals now track mesh deformation per-boundary (and remain corner-correct), and (2) domain membership / point-location now uses the deformed boundary geometry by invalidating and rebuilding navigation caches on deform().

Changes:

  • Add Mesh.boundary_normal(boundary) that assembles deformation-tracking PETSc facet normals per boundary and update Nitsche BCs to use it by default.
  • Fix deformed-domain membership / evaluation by invalidating the boundary kd-tree and refreshing _nav_coords after coordinate rebuilds.
  • Add regression tests for deformed normals and deformed domain membership/evaluation.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.

File Description
src/underworld3/discretisation/discretisation_mesh.py Adds per-boundary normal assembly, refreshes navigation coords and invalidates boundary control-point kd-tree on deform/rebuild.
src/underworld3/cython/petsc_generic_snes_solvers.pyx Switches default Nitsche BC normal from Gamma_P1 to deformation-tracking boundary_normal.
tests/test_0056_projected_normals_deform.py Regression test ensuring per-boundary normals track deformation and are corner-correct.
tests/test_0057_deformed_domain_membership.py Regression test ensuring points_in_domain() and function.evaluate() track deformed geometry.

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

Comment on lines +2509 to +2511
import underworld3 as uw
from scipy.spatial import cKDTree

Comment thread src/underworld3/discretisation/discretisation_mesh.py Outdated
Comment on lines +2560 to +2580
tree = cKDTree(coords)
nverts = cdim # P1 vertices per facet (2D edge=2, 3D tri=3)
for f in face_pts:
if dm.getSupportSize(f) != 1:
continue
area, cent, nrm = dm.computeCellGeometryFVM(f)
nrm = numpy.asarray(nrm)[:cdim]
cell = dm.getSupport(f)[0]
_, ccent, _ = dm.computeCellGeometryFVM(cell)
if numpy.dot(nrm, numpy.asarray(cent)[:cdim]
- numpy.asarray(ccent)[:cdim]) < 0:
nrm = -nrm
# Accumulate to the facet's P1 DOFs (its vertices) — found as the
# nearest DOFs to the facet centroid. This avoids indexing the local
# coordinate array by (vertex_point - vStart), which is only valid
# in serial (the parallel coordinate layout differs → out-of-range).
# Normalisation at the end makes the per-DOF weight (full vs share)
# irrelevant to the resulting direction.
_, idxs = tree.query(numpy.asarray(cent)[:cdim], k=nverts)
for idx in numpy.atleast_1d(idxs):
accum[idx] += area * nrm
Comment thread tests/test_0057_deformed_domain_membership.py
Comment on lines +150 to +154
# normal, NOT a 45° average with the vertical walls. ∂h/∂x at x=0,1 is
# ±0.1π, so the true top normal there is tilted ~17° from vertical — and
# crucially has a SMALL... no: it must equal the one-sided top facet normal
# (the reference), i.e. err already bounds it. Assert the corner normal is
# not the 45° wall-average:
Comment on lines +4775 to +4789
# Surface normal components. By default use this boundary's OWN
# deformation-tracking facet normal (assembled from only this
# boundary's faces, so a corner shared with another boundary is not
# averaged across the discontinuity, and a deformed surface is
# followed correctly). The legacy global mesh.Gamma_P1 point-evaluates
# petsc_n and stays radial on a deformed surface — see
# Mesh.boundary_normal.
if normal is not None:
if isinstance(normal, sympy.MatrixBase):
n = [normal[i] for i in range(dim)]
else:
n = list(normal)
else:
Gamma_P1 = mesh.Gamma_P1
n = [Gamma_P1[i] for i in range(dim)]
bnorm = mesh.boundary_normal(boundary)
n = [bnorm[i] for i in range(dim)]
@lmoresi

lmoresi commented Jun 21, 2026

Copy link
Copy Markdown
Member Author

This fix is a critical component of the adaptive-meshing / free-surface work, not just the free-slip BC path.

While validating mmpde adaptive convection we traced the long-standing "holes / giant empty cells in the adapted mesh + wrecked adaptive convection" failure to Bug 2 here (deformed-mesh point location). The chain:

  • metric_density_from_gradient builds a P1 density with strictly-positive nodal values, but on a deformed mesh uw.function.evaluate mis-located points (stale _nav_coords) and returned negative values even at the field's own DOF coords (measured min ≈ −3.0).
  • Negative density → non-SPD metric → the mover either NaN-bailed (a protective stall that hid the problem) or, after the SPD-floor in fix(meshing): SPD-floor the mmpde metric to stop silent NaN-bail on deformed meshes #259, acted on garbage-magnitude metric → intermittent giant cells / holes / divergence.

With this PR, the metric evaluates correctly/positive on the deformed mesh, so the mover is fed a valid metric and adaptation is stable.

Validation (with this fix): forced adaptation every step (39/39) at R=5 under vigorous stagnant-lid convection (Ra1e6, Δη1e3) → mesh clean every frame (folded=0, cell-area-ratio ~14 flat), vrms→18, Nu→1.86. The same configuration without the fix produced area-ratio spikes 189/96/875 (holes). So this unblocks adaptive convection (and, as noted, the free-surface dynamic-topography work).

A small complementary hardening (mover bakes the metric from positive nodal values via monotone RBF/Shepard, so it can never go negative regardless of eval precision) will follow as a separate bugfix PR.

- boundary_normal(): drop unused cKDTree import (tree is built in
  _assemble_boundary_normal).
- _assemble_boundary_normal(): drop unused `import underworld3 as uw`;
  count P1 vertices PER FACET from its closure (vStart..vEnd) instead of
  assuming nverts == cdim, so non-simplex facets (3D quad/hex boundaries)
  are handled correctly.
- add_nitsche_bc docstring: `normal` default is now the per-boundary,
  deformation-tracking mesh.boundary_normal(boundary), not mesh.Gamma_N.
- tests: drop unused surf_now; tidy an editorial comment fragment.

test_0056 + test_0057 pass (3 passed).

Underworld development team with AI support from Claude Code
@lmoresi

lmoresi commented Jun 21, 2026

Copy link
Copy Markdown
Member Author

Addressed the Copilot review in e086e72:

  • nverts == cdim (the substantive one): now counts P1 vertices per facet from its closure (vStart..vEnd), so 3D non-simplex boundary faces (quads on a hex mesh = 4 verts) are handled correctly, not just simplex edges/triangles.
  • Dropped the unused cKDTree import in boundary_normal() and the unused import underworld3 as uw in _assemble_boundary_normal().
  • Fixed the add_nitsche_bc normal docstring (default is now mesh.boundary_normal(boundary), not mesh.Gamma_N).
  • Test tidy-ups: removed the unused surf_now; cleaned the editorial comment fragment.

test_0056 + test_0057 pass (3 passed). CI will re-run on the new commit.

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