Skip to content

Commit 3a8648a

Browse files
committed
tests: add test for mesh clipping
1 parent 51ee9a5 commit 3a8648a

1 file changed

Lines changed: 167 additions & 0 deletions

File tree

tests/test_mesh_operations.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
63230
def 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

Comments
 (0)