@@ -60,6 +60,173 @@ def test_remesh_changes_vertices(square_surface, kwargs):
6060 # assert after_v >= 0.5 * before_v
6161
6262
63+ def _make_curved_clipper (x_offset : float , y_half : float , z_top : float , z_bot : float , n : int = 6 ) -> pv .PolyData :
64+ """Build a curved (listric) clipper surface that bows in the X direction.
65+
66+ The surface lies roughly in the YZ plane at x=x_offset but curves so that
67+ deeper vertices are offset further in X. This simulates a listric fault
68+ being used as a clipper.
69+ """
70+ ys = np .linspace (- y_half , y_half , n )
71+ zs = np .linspace (z_top , z_bot , n )
72+ verts = []
73+ for z in zs :
74+ # curvature: bow increases with depth (small enough that cut stays within 50 m of X=0)
75+ # The deepest target vertex is at z=-7000; max x-deviation = c * 7000 < tol=50 m → c < 0.007
76+ curve = x_offset + 0.005 * (z - z_top )
77+ for y in ys :
78+ verts .append ([curve , y , z ])
79+ verts = np .array (verts , dtype = float )
80+ faces = []
81+ for iz in range (n - 1 ):
82+ for iy in range (n - 1 ):
83+ i0 = iz * n + iy
84+ i1 = i0 + 1
85+ i2 = i0 + n
86+ i3 = i2 + 1
87+ faces .extend ([[3 , i0 , i1 , i3 ], [3 , i0 , i3 , i2 ]])
88+ faces = np .array (faces ).flatten ()
89+ return pv .PolyData (verts , faces )
90+
91+
92+ @pytest .mark .parametrize ("clipper_factory,label" , [
93+ (
94+ # Planar clipper: YZ plane at X=0, far smaller than the 4 km deep target
95+ lambda : pv .Plane (
96+ center = (0.0 , 0.0 , - 250.0 ),
97+ direction = (1.0 , 0.0 , 0.0 ),
98+ i_size = 500.0 ,
99+ j_size = 500.0 ,
100+ i_resolution = 4 ,
101+ j_resolution = 4 ,
102+ ).triangulate (),
103+ "planar" ,
104+ ),
105+ (
106+ # Curved (listric) clipper: also smaller than the target, bows in X with depth
107+ lambda : _make_curved_clipper (x_offset = 0.0 , y_half = 250.0 , z_top = 0.0 , z_bot = - 500.0 ),
108+ "curved" ,
109+ ),
110+ ])
111+ def test_no_bridge_when_clipper_shorter_than_target (clipper_factory , label ):
112+ """Clipper that doesn't extend fully through the target must not leave a bridge.
113+
114+ Setup
115+ -----
116+ Target : vertical fault surface in the XZ plane (Y=0), 10 km wide × 4 km deep.
117+ Clipper : either a planar or curved (listric) surface at X≈0, only 500 m deep
118+ — far smaller than the 4 km depth of the target.
119+
120+ Without the fix, the lower portion (Z < -500 m) is left as a bridge. With
121+ boundary extension, the clipper rim is pushed out to cover the full target so
122+ the result must lie entirely on one side of X=0. The interior vertices of the
123+ clipper are unchanged, so the cut location is preserved for both planar and
124+ curved cases.
125+ """
126+ # Large vertical fault in XZ plane (Y=0): 10 km wide, 4 km deep.
127+ target_pv = pv .Plane (
128+ center = (0.0 , 0.0 , - 2000.0 ),
129+ direction = (0.0 , 1.0 , 0.0 ),
130+ i_size = 10000.0 ,
131+ j_size = 4000.0 ,
132+ i_resolution = 20 ,
133+ j_resolution = 8 ,
134+ ).triangulate ()
135+
136+ target = loop_cgal .TriMesh (target_pv )
137+ clipper = loop_cgal .TriMesh (clipper_factory ())
138+
139+ faces_removed = target .cut_with_surface (clipper )
140+ result = target .to_pyvista ()
141+
142+ assert result .n_points > 0 , f"[{ label } ] Clip removed the entire mesh"
143+ assert faces_removed > 0 , f"[{ label } ] Clip was a no-op — meshes may not intersect"
144+
145+ # After a clean cut at X≈0, all remaining vertices must be on ONE side.
146+ # A bridge would leave vertices significantly on both sides.
147+ xs = result .points [:, 0 ]
148+ tol = 50.0 # 50 m tolerance for vertices exactly on the cut boundary
149+ on_positive = np .any (xs > tol )
150+ on_negative = np .any (xs < - tol )
151+ assert not (on_positive and on_negative ), (
152+ f"[{ label } ] Bridge detected: vertices span both sides of the cut plane "
153+ f"(X range [{ xs .min ():.1f} , { xs .max ():.1f} ] m)."
154+ )
155+
156+
157+ @pytest .mark .parametrize ("collocate_target,collocate_clipper,label" , [
158+ (True , False , "collocated_target" ),
159+ (False , True , "collocated_clipper" ),
160+ (True , True , "collocated_both" ),
161+ ])
162+ def test_no_artefact_with_collocated_vertices (collocate_target , collocate_clipper , label ):
163+ """Collocated (duplicate) vertices must not cause bridges or a no-op clip.
164+
165+ Two failure modes are possible:
166+ - Collocated vertices in the *target* create zero-area faces that can make
167+ PMP::do_intersect return false, silently skipping the entire clip.
168+ - Collocated vertices in the *clipper* create degenerate border faces whose
169+ normals are near-zero, causing skirt edges to be skipped and leaving a
170+ bridge gap at those positions.
171+
172+ Both are fixed by calling PMP::stitch_borders before clipping.
173+ """
174+ def _add_collocated_seam (pv_mesh : pv .PolyData ) -> pv .PolyData :
175+ """Duplicate a row of interior vertices along the mesh midline (Z=-2000).
176+
177+ The duplicate vertices are collocated with the originals but topologically
178+ distinct, creating zero-area triangles along the seam.
179+ """
180+ pts = pv_mesh .points .copy ()
181+ # Find vertices near the midline (Z ≈ -2000)
182+ seam = np .where (np .abs (pts [:, 2 ] + 2000.0 ) < 50.0 )[0 ]
183+ if len (seam ) == 0 :
184+ return pv_mesh # nothing to duplicate
185+ extra = pts [seam ].copy () # exact same positions
186+ new_pts = np .vstack ([pts , extra ])
187+ return pv .PolyData (new_pts , pv_mesh .faces )
188+
189+ target_pv = pv .Plane (
190+ center = (0.0 , 0.0 , - 2000.0 ),
191+ direction = (0.0 , 1.0 , 0.0 ),
192+ i_size = 10000.0 ,
193+ j_size = 4000.0 ,
194+ i_resolution = 20 ,
195+ j_resolution = 8 ,
196+ ).triangulate ()
197+
198+ clipper_pv = pv .Plane (
199+ center = (0.0 , 0.0 , - 250.0 ),
200+ direction = (1.0 , 0.0 , 0.0 ),
201+ i_size = 500.0 ,
202+ j_size = 500.0 ,
203+ i_resolution = 4 ,
204+ j_resolution = 4 ,
205+ ).triangulate ()
206+
207+ if collocate_target :
208+ target_pv = _add_collocated_seam (target_pv )
209+ if collocate_clipper :
210+ clipper_pv = _add_collocated_seam (clipper_pv )
211+
212+ target = loop_cgal .TriMesh (target_pv )
213+ clipper = loop_cgal .TriMesh (clipper_pv )
214+
215+ faces_removed = target .cut_with_surface (clipper )
216+ result = target .to_pyvista ()
217+
218+ assert result .n_points > 0 , f"[{ label } ] Clip removed the entire mesh"
219+ assert faces_removed > 0 , f"[{ label } ] Clip was a no-op (do_intersect likely wrong due to degenerate faces)"
220+
221+ xs = result .points [:, 0 ]
222+ tol = 50.0
223+ on_positive = np .any (xs > tol )
224+ on_negative = np .any (xs < - tol )
225+ assert not (on_positive and on_negative ), (
226+ f"[{ label } ] Bridge detected: X range [{ xs .min ():.1f} , { xs .max ():.1f} ] m"
227+ )
228+
229+
63230def test_cut_with_implicit_function (square_surface ):
64231 tm = loop_cgal .TriMesh (square_surface )
65232 # create a scalar property that varies across vertices
0 commit comments