@@ -442,6 +442,11 @@ class replacement_boundaries(Enum):
442442
443443 self .filename = filename
444444 self .boundaries = boundaries
445+ # Bounding-surface objects (tangent-slip + restore), keyed by boundary
446+ # label. SEPARATE from self.boundaries (the persisted gmsh/DMPlex
447+ # labelling, untouched). Populated by analytic-geometry constructors;
448+ # see docs/developer/design/boundary-slip-strategy.md.
449+ self ._bounding_surfaces = {}
445450 self .boundary_normals = boundary_normals
446451 self .regions = regions
447452 self .parent = None # Set by extract_region() for submeshes
@@ -2007,6 +2012,182 @@ def Gamma_P1(self):
20072012 self ._update_projected_normals ()
20082013 return self ._projected_normals .sym
20092014
2015+ # ===================================================================
2016+ # Bounding surfaces — per-surface tangent-slip + restore.
2017+ # See docs/developer/design/boundary-slip-strategy.md. SEPARATE from
2018+ # self.boundaries (the persisted gmsh/DMPlex labelling, untouched).
2019+ # ===================================================================
2020+ @property
2021+ def bounding_surfaces (self ):
2022+ """Mapping ``{label: BoundingSurface}`` of this mesh's registered
2023+ bounding-surface objects (tangent-slip + restore).
2024+
2025+ This is a NEW collection, separate from and additional to
2026+ :attr:`boundaries` (the persisted gmsh/DMPlex label ``Enum``, left
2027+ untouched). Populated by analytic-geometry constructors (Annulus,
2028+ SphericalShell, CubedSphere, box meshes); user-extendable via
2029+ :meth:`register_tangent_slip_provider`.
2030+ """
2031+ if not hasattr (self , "_bounding_surfaces" ) or self ._bounding_surfaces is None :
2032+ self ._bounding_surfaces = {}
2033+ return self ._bounding_surfaces
2034+
2035+ def register_tangent_slip_provider (self , label , surface ):
2036+ """Install a :class:`BoundingSurface` object for a boundary ``label``
2037+ (separate from the persisted ``mesh.boundaries`` labelling).
2038+
2039+ Lets a user declare a custom analytic surface (e.g. an ellipsoid) the
2040+ constructors don't know about, or replace one (free-surface release).
2041+ """
2042+ from underworld3 .meshing .bounding_surface import BoundingSurface
2043+ if not isinstance (surface , BoundingSurface ):
2044+ raise TypeError (
2045+ "surface must be a BoundingSurface; got "
2046+ f"{ type (surface ).__name__ } " )
2047+ self .bounding_surfaces [str (label )] = surface
2048+ return surface
2049+
2050+ def _resolve_slip_spec (self , slip_spec ):
2051+ """Resolve a ``slip_spec`` to ``(slip_labels tuple, free_labels set)``.
2052+
2053+ Back-compatible forms: ``True``/``"all"``/``"ring"``/``"box"`` → all
2054+ geometric boundary labels; ``False``/``None`` → none; a label name; a
2055+ list of labels; a ``dict {label: snap_bool}`` (``False`` = free
2056+ surface, slip but do not restore).
2057+ """
2058+ from underworld3 .meshing .smoothing import _auto_pinned_labels
2059+ geo = _auto_pinned_labels (self )
2060+ if slip_spec is None or slip_spec is False :
2061+ return (), set ()
2062+ if slip_spec is True :
2063+ return tuple (geo ), set ()
2064+ if isinstance (slip_spec , dict ):
2065+ return tuple (slip_spec .keys ()), {k for k , v in slip_spec .items () if not v }
2066+ if isinstance (slip_spec , str ):
2067+ s = slip_spec .strip ().lower ()
2068+ if s in ("true" , "on" , "1" , "all" , "ring" , "box" , "axes" , "axis" ):
2069+ return tuple (geo ), set ()
2070+ if s in ("false" , "off" , "0" , "none" , "" ):
2071+ return (), set ()
2072+ return (slip_spec ,), set ()
2073+ return tuple (slip_spec ), set ()
2074+
2075+ def restore_to_surface (self , coords , label ):
2076+ """Snap ``coords`` onto the named bounding surface (delegates to the
2077+ surface object's ``restore``)."""
2078+ return self .bounding_surfaces [str (label )].restore (
2079+ numpy .asarray (coords , dtype = float ))
2080+
2081+ def tangent_project (self , coords , label , reference ):
2082+ """Tangent-slide ``coords`` (displacement measured from ``reference``)
2083+ on the named bounding surface (delegates to the surface object)."""
2084+ return self .bounding_surfaces [str (label )].tangent_project (
2085+ numpy .asarray (coords , dtype = float ),
2086+ numpy .asarray (reference , dtype = float ))
2087+
2088+ def boundary_slip (self , slip_spec = True , reference_coords = None ,
2089+ boundary_labels = None ):
2090+ """Build ``(is_pinned, project)`` for tangent slip on this mesh's
2091+ bounding surfaces — the orchestrator the metric movers call.
2092+
2093+ See ``docs/developer/design/boundary-slip-strategy.md``. The mesh
2094+ classifies which vertices slip vs pin (the cross-surface concern); each
2095+ surface object owns its tangent-project + restore.
2096+
2097+ A vertex **slips** iff it lies on **exactly one** slip surface that has a
2098+ registered analytic :class:`BoundingSurface`; non-boundary, junction
2099+ (≥2 surfaces), unregistered-surface, or degenerate-normal vertices are
2100+ **pinned** (the step-1 safe default — ``facet`` restore is a follow-up).
2101+
2102+ Parameters
2103+ ----------
2104+ slip_spec :
2105+ See :meth:`_resolve_slip_spec`. Default ``True`` (all surfaces).
2106+ reference_coords : ndarray, optional
2107+ Fixed reference vertex positions (local-chart vertex order) that
2108+ displacements are measured from. Defaults to ``mesh.X.coords``.
2109+ boundary_labels : iterable of str, optional
2110+ Boundary labels defining the boundary (``is_bnd``). Defaults to all
2111+ geometric boundary labels; pass a mover's pinned set for parity.
2112+
2113+ Returns
2114+ -------
2115+ (is_pinned, project) : (ndarray[bool], callable)
2116+ ``is_pinned`` is the per-vertex pinned mask (local-chart order);
2117+ ``project(Y)`` slides+restores the slip vertices of ``Y`` in place
2118+ and returns it.
2119+ """
2120+ from underworld3 .meshing .smoothing import (
2121+ _pinned_mask , _auto_pinned_labels , _owned_vertex_mask )
2122+
2123+ dm = self .dm
2124+ pStart , pEnd = dm .getDepthStratum (0 )
2125+ n_verts = pEnd - pStart
2126+ if reference_coords is None :
2127+ reference_coords = numpy .asarray (self .X .coords , dtype = float )
2128+ ref = numpy .asarray (reference_coords , dtype = float )
2129+
2130+ all_labels = (tuple (boundary_labels ) if boundary_labels is not None
2131+ else _auto_pinned_labels (self ))
2132+ # TODO(follow-up): _pinned_mask expands labels through vertices/edges
2133+ # only, so a 3D boundary label that tags FACES alone (a mesh loaded with
2134+ # markVertices=False) leaves its boundary vertices unmarked. This is a
2135+ # pre-existing limitation of the shared helper used by every mover; the
2136+ # fix (close faces→edges→vertices) belongs with _pinned_mask itself.
2137+ is_bnd = _pinned_mask (dm , all_labels )
2138+
2139+ slip_labels , free_labels = self ._resolve_slip_spec (slip_spec )
2140+ surf = self .bounding_surfaces
2141+ # Only labels with a registered analytic surface can slip (step 1).
2142+ usable = [lab for lab in slip_labels if lab in surf ]
2143+ masks = {lab : _pinned_mask (dm , (lab ,)) for lab in usable }
2144+ count = numpy .zeros (n_verts , dtype = int )
2145+ for m in masks .values ():
2146+ count += m .astype (int )
2147+ slip_mask = is_bnd & (count == 1 )
2148+ is_pinned = is_bnd & ~ slip_mask
2149+ vert_label = numpy .empty (n_verts , dtype = object )
2150+ for lab , m in masks .items ():
2151+ vert_label [m & slip_mask ] = lab
2152+
2153+ # Project only OWNED slip vertices: the movers halo-sync owned→ghost
2154+ # after calling project(), so a leaf/ghost receives its owner's
2155+ # projected value — modifying non-owned coordinates here is both
2156+ # wasteful and a parallel-safety hazard. (Serial: every vertex is
2157+ # owned, so this is a no-op.) is_pinned stays the full geometric
2158+ # classification, which is rank-consistent for shared vertices.
2159+ slip_b = numpy .nonzero (slip_mask & _owned_vertex_mask (dm ))[0 ]
2160+ if slip_b .size == 0 :
2161+ return is_pinned , (lambda Y : Y )
2162+ old_slip = ref [slip_b ]
2163+ labels_b = vert_label [slip_b ]
2164+
2165+ def project (Y ):
2166+ Y = numpy .asarray (Y , dtype = float )
2167+ for lab in usable :
2168+ sel = labels_b == lab
2169+ if not sel .any ():
2170+ continue
2171+ bs = surf [lab ]
2172+ idx = slip_b [sel ]
2173+ slid = bs .tangent_project (Y [idx ], old_slip [sel ])
2174+ # FREE surfaces (dict spec False) slide but do not restore.
2175+ Y [idx ] = slid if lab in free_labels else bs .restore (slid )
2176+ return Y
2177+
2178+ return is_pinned , project
2179+
2180+ def project_to_slip_surface (self , coords , slip_spec = True ,
2181+ reference_coords = None , boundary_labels = None ):
2182+ """In-place convenience over :meth:`boundary_slip`: slide+restore the
2183+ slip vertices of ``coords`` (a full local-chart vertex array) and return
2184+ it. For callers that just want coordinates snapped back (a checkpoint
2185+ reload, a diagnostic, the free-surface module)."""
2186+ _is_pinned , project = self .boundary_slip (
2187+ slip_spec , reference_coords = reference_coords ,
2188+ boundary_labels = boundary_labels )
2189+ return project (numpy .asarray (coords , dtype = float ))
2190+
20102191 @timing .routine_timer_decorator
20112192 def update_lvec (self ):
20122193 """
0 commit comments