diff --git a/rtxpy/tests/test_lod.py b/rtxpy/tests/test_lod.py index d72ed72..5e475c4 100644 --- a/rtxpy/tests/test_lod.py +++ b/rtxpy/tests/test_lod.py @@ -316,6 +316,25 @@ def test_boundary_shared_at_higher_subsample(self): f"tile(0,1) min_x={min_x_01}" ) + def test_interior_tile_has_no_skirt(self): + """Interior tiles (not at terrain edge) should have no skirt. #79""" + terrain = self._make_terrain(256, 256) + rtx = _FakeRTX() + mgr = TerrainLODManager(terrain, tile_size=64, + pixel_spacing_x=1.0, pixel_spacing_y=1.0) + mgr.update(np.array([128, 128, 0]), rtx, force=True) + # Tile (1,1) is fully interior — no edges touch terrain boundary. + # Its mesh should have no skirt (vertex count = grid verts only). + gid = _tile_gid(1, 1) + verts = rtx.geometries[gid][0] + # With +1 overlap and subsample=1, tile covers 65x65 grid + # (64 tile_size + 1 overlap). No skirt → exactly 65*65 verts. + n_verts = len(verts) // 3 + assert n_verts == 65 * 65, ( + f"Interior tile should have no skirt, got {n_verts} verts " + f"(expected {65 * 65})" + ) + def test_get_stats(self): terrain = self._make_terrain(128, 128) mgr = TerrainLODManager(terrain, tile_size=64) @@ -378,3 +397,33 @@ def test_skirt_z_below_min(self): new_v, _ = _add_tile_skirt(verts, indices, H, W) skirt_z = new_v[9 * 3 + 2::3] # z of skirt vertices assert np.all(skirt_z < 10.0) + + def test_no_edges_returns_unchanged(self): + """edges=all False should return original mesh unchanged. #79""" + H, W = 3, 3 + verts = np.zeros(9 * 3, dtype=np.float32) + indices = np.zeros(8 * 3, dtype=np.int32) + new_v, new_i = _add_tile_skirt( + verts, indices, H, W, edges=(False, False, False, False)) + np.testing.assert_array_equal(new_v, verts) + np.testing.assert_array_equal(new_i, indices) + + def test_partial_edges_fewer_wall_tris(self): + """Activating only some edges should produce fewer wall tris. #79""" + H, W = 4, 4 + n_verts = H * W + n_tris = (H - 1) * (W - 1) * 2 + verts = np.zeros(n_verts * 3, dtype=np.float32) + indices = np.zeros(n_tris * 3, dtype=np.int32) + for h in range(H): + for w in range(W): + idx = (h * W + w) * 3 + verts[idx] = float(w) + verts[idx + 1] = float(h) + verts[idx + 2] = float(h + w) + + _, all_i = _add_tile_skirt(verts, indices, H, W) + _, partial_i = _add_tile_skirt( + verts, indices, H, W, edges=(True, False, False, False)) + # Only top edge active → fewer wall triangles + assert len(partial_i) < len(all_i) diff --git a/rtxpy/viewer/terrain_lod.py b/rtxpy/viewer/terrain_lod.py index 88d4059..739199b 100644 --- a/rtxpy/viewer/terrain_lod.py +++ b/rtxpy/viewer/terrain_lod.py @@ -252,8 +252,16 @@ def _build_tile_mesh(self, tr, tc, lod): verts[0::3] = verts[0::3] * subsample * self._psx + c0 * self._psx verts[1::3] = verts[1::3] * subsample * self._psy + r0 * self._psy - # Add edge skirt to hide T-junction cracks between LOD levels - verts, indices = _add_tile_skirt(verts, indices, th, tw) + # Only add skirt on exterior edges (terrain boundary). + # Interior edges shared with adjacent tiles via the +1 overlap + # don't need skirt — overlapping skirt walls cause artifacts. + edges = ( + tr == 0, # top + tc == self._n_tile_cols - 1, # right + tr == self._n_tile_rows - 1, # bottom + tc == 0, # left + ) + verts, indices = _add_tile_skirt(verts, indices, th, tw, edges=edges) return verts, indices @@ -272,13 +280,20 @@ def is_terrain_lod_gid(gid): return gid.startswith('terrain_lod_r') -def _add_tile_skirt(vertices, indices, H, W, skirt_depth=None): - """Add a thin skirt around tile edges. +def _add_tile_skirt(vertices, indices, H, W, skirt_depth=None, + edges=(True, True, True, True)): + """Add a thin skirt around specified tile edges. - The skirt is deliberately small — just enough to cover gaps at - LOD boundaries. It uses the same algorithm as - ``mesh.add_terrain_skirt`` but with a shallower default depth. + Parameters + ---------- + edges : tuple of bool + ``(top, right, bottom, left)`` — which edges get skirt + geometry. Interior tile edges shared with adjacent tiles + should be False to avoid overlapping wall triangles. """ + if not any(edges): + return vertices, indices + z_vals = vertices[2::3] z_min = float(np.nanmin(z_vals)) z_max = float(np.nanmax(z_vals)) @@ -303,14 +318,29 @@ def _add_tile_skirt(vertices, indices, H, W, skirt_depth=None): skirt_verts[1::3] = vertices[perim * 3 + 1] skirt_verts[2::3] = skirt_z - idx = np.arange(n_perim, dtype=np.int32) - idx_next = np.roll(idx, -1) - top_a = perim + # Mask: only create wall triangles for active edges. + # Perimeter segments per edge: top W-1, right H-1, bottom W-1, left H-1. + edge_top, edge_right, edge_bottom, edge_left = edges + seg_mask = np.zeros(n_perim, dtype=bool) + off = 0 + for active, count in [(edge_top, W - 1), (edge_right, H - 1), + (edge_bottom, W - 1), (edge_left, H - 1)]: + if active: + seg_mask[off:off + count] = True + off += count + + active_segs = np.where(seg_mask)[0].astype(np.int32) + if len(active_segs) == 0: + return vertices, indices + + idx_next = (active_segs + 1) % n_perim + top_a = perim[active_segs] top_b = perim[idx_next] - bot_a = (n_orig + idx).astype(np.int32) + bot_a = (n_orig + active_segs).astype(np.int32) bot_b = (n_orig + idx_next).astype(np.int32) - wall_tris = np.empty(n_perim * 6, dtype=np.int32) + n_active = len(active_segs) + wall_tris = np.empty(n_active * 6, dtype=np.int32) wall_tris[0::6] = top_a wall_tris[1::6] = bot_b wall_tris[2::6] = top_b