import numpy as np def _empty_edges_like(edges: np.ndarray) -> np.ndarray: return np.zeros((0, 2), dtype=edges.dtype) def _compact_vertices_for_edges(verts: np.ndarray, edges: np.ndarray) -> tuple[np.ndarray, np.ndarray]: if len(edges) == 0: return verts[:0], _empty_edges_like(edges) used_vertex_indices, inverse = np.unique(edges.reshape(-1), return_inverse=True) compact_verts = verts[used_vertex_indices] compact_edges = inverse.reshape(-1, 2).astype(edges.dtype, copy=False) return compact_verts, compact_edges def _compact_vertices_and_features_for_edges( verts: np.ndarray, edges: np.ndarray, vertex_feats: np.ndarray ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: if len(edges) == 0: return verts[:0], _empty_edges_like(edges), vertex_feats[:0] used_vertex_indices, inverse = np.unique(edges.reshape(-1), return_inverse=True) compact_verts = verts[used_vertex_indices] compact_edges = inverse.reshape(-1, 2).astype(edges.dtype, copy=False) compact_feats = vertex_feats[used_vertex_indices] return compact_verts, compact_edges, compact_feats def _merged_vertex_from_incident_lines( verts: np.ndarray, edges: np.ndarray, component_members: np.ndarray, incident_edge_indices_by_vertex: list[list[int]], average_position: np.ndarray, intersection_angle_threshold_deg: float | None, ) -> np.ndarray: if len(component_members) < 2: return average_position if intersection_angle_threshold_deg is None or intersection_angle_threshold_deg <= 0: return average_position component_member_set = set(int(vertex) for vertex in component_members) component_edge_indices: set[int] = set() for vertex in component_members: component_edge_indices.update(incident_edge_indices_by_vertex[int(vertex)]) line_points: list[np.ndarray] = [] line_dirs: list[np.ndarray] = [] for edge_idx in component_edge_indices: a, b = edges[edge_idx] a = int(a) b = int(b) if (a in component_member_set) == (b in component_member_set): continue line_vec = verts[b] - verts[a] line_length = np.linalg.norm(line_vec) if line_length == 0: continue line_points.append(verts[a].astype(np.float64, copy=False)) line_dirs.append(line_vec.astype(np.float64, copy=False) / line_length) if len(line_dirs) < 2: return average_position directions = np.array(line_dirs, dtype=np.float64) dot_products = np.abs(directions @ directions.T) upper_triangle = np.triu_indices(len(directions), k=1) angles_deg = np.degrees(np.arccos(np.clip(dot_products[upper_triangle], -1.0, 1.0))) if not np.any(angles_deg >= intersection_angle_threshold_deg): return average_position system = np.zeros((3, 3), dtype=np.float64) rhs = np.zeros(3, dtype=np.float64) identity = np.eye(3, dtype=np.float64) for point, direction in zip(line_points, line_dirs): projection = identity - np.outer(direction, direction) system += projection rhs += projection @ point try: intersection = np.linalg.lstsq(system, rhs, rcond=None)[0] except np.linalg.LinAlgError: return average_position if not np.all(np.isfinite(intersection)): return average_position return intersection.astype(average_position.dtype, copy=False) def merge_close_vertices( verts: np.ndarray, edges: np.ndarray, distance_threshold: float, intersection_angle_threshold_deg: float | None = 30.0, ) -> tuple[np.ndarray, np.ndarray]: if len(verts) == 0 or distance_threshold <= 0: return verts, edges cell_size = float(distance_threshold) cell_coords = np.floor(verts / cell_size).astype(np.int64) buckets: dict[tuple[int, int, int], list[int]] = {} parent = np.arange(len(verts), dtype=np.int64) rank = np.zeros(len(verts), dtype=np.int8) members = [set([i]) for i in range(len(verts))] edge_neighbors = [set() for _ in range(len(verts))] incident_edge_indices_by_vertex: list[list[int]] = [[] for _ in range(len(verts))] for edge_idx, (a, b) in enumerate(edges): a = int(a) b = int(b) incident_edge_indices_by_vertex[a].append(edge_idx) if a != b: incident_edge_indices_by_vertex[b].append(edge_idx) if a == b: continue edge_neighbors[a].add(b) edge_neighbors[b].add(a) def find(i: int) -> int: while parent[i] != i: parent[i] = parent[parent[i]] i = parent[i] return i def components_share_edge(a_members: set[int], b_members: set[int]) -> bool: if len(a_members) > len(b_members): a_members, b_members = b_members, a_members return any(not edge_neighbors[vertex].isdisjoint(b_members) for vertex in a_members) def union(a: int, b: int) -> None: ra, rb = find(a), find(b) if ra == rb or components_share_edge(members[ra], members[rb]): return if rank[ra] < rank[rb]: ra, rb = rb, ra parent[rb] = ra if rank[ra] == rank[rb]: rank[ra] += 1 members[ra].update(members[rb]) members[rb].clear() for i, cell in enumerate(cell_coords): key = tuple(cell.tolist()) for dx in (-1, 0, 1): for dy in (-1, 0, 1): for dz in (-1, 0, 1): neighbor_key = (key[0] + dx, key[1] + dy, key[2] + dz) for j in buckets.get(neighbor_key, ()): if np.linalg.norm(verts[i] - verts[j]) <= distance_threshold: union(i, j) buckets.setdefault(key, []).append(i) roots = np.array([find(i) for i in range(len(verts))], dtype=np.int64) unique_roots, inverse = np.unique(roots, return_inverse=True) merged_verts = np.zeros((len(unique_roots), 3), dtype=verts.dtype) for new_vertex_idx in range(len(unique_roots)): component_members = np.flatnonzero(inverse == new_vertex_idx) average_position = np.mean(verts[component_members], axis=0) merged_verts[new_vertex_idx] = _merged_vertex_from_incident_lines( verts, edges, component_members, incident_edge_indices_by_vertex, average_position, intersection_angle_threshold_deg, ) old_to_new = inverse merged_edges = old_to_new[edges] keep = merged_edges[:, 0] != merged_edges[:, 1] merged_edges = merged_edges[keep] if len(merged_edges) == 0: return merged_verts, np.zeros((0, 2), dtype=np.int64) merged_edges = np.sort(merged_edges, axis=1) merged_edges = np.unique(merged_edges, axis=0) return merged_verts, merged_edges def merge_vertices_with_features( verts: np.ndarray, edges: np.ndarray, vertex_feats: np.ndarray, alpha: float = 0.95, distance_threshold: float | None = None, vertex_scores: np.ndarray | None = None, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: if len(verts) == 0 or len(vertex_feats) == 0: return verts, edges, vertex_feats if len(verts) != len(vertex_feats): raise ValueError("verts and vertex_feats must have the same length") feats = vertex_feats.astype(np.float32, copy=False) sim = feats @ feats.T parent = np.arange(len(verts), dtype=np.int64) rank = np.zeros(len(verts), dtype=np.int8) members = [set([i]) for i in range(len(verts))] edge_neighbors = [set() for _ in range(len(verts))] for a, b in edges: if a == b: continue edge_neighbors[int(a)].add(int(b)) edge_neighbors[int(b)].add(int(a)) def find(i: int) -> int: while parent[i] != i: parent[i] = parent[parent[i]] i = parent[i] return i def components_share_edge(a_members: set[int], b_members: set[int]) -> bool: if len(a_members) > len(b_members): a_members, b_members = b_members, a_members return any(not edge_neighbors[vertex].isdisjoint(b_members) for vertex in a_members) def union(a: int, b: int) -> None: ra, rb = find(a), find(b) if ra == rb or components_share_edge(members[ra], members[rb]): return if rank[ra] < rank[rb]: ra, rb = rb, ra parent[rb] = ra if rank[ra] == rank[rb]: rank[ra] += 1 members[ra].update(members[rb]) members[rb].clear() rows, cols = np.where(np.triu(sim >= alpha, k=1)) if distance_threshold is not None: if distance_threshold <= 0: rows, cols = np.array([], dtype=np.int64), np.array([], dtype=np.int64) else: distances = np.linalg.norm(verts[rows] - verts[cols], axis=1) keep = distances <= distance_threshold rows, cols = rows[keep], cols[keep] for a, b in zip(rows.tolist(), cols.tolist()): union(a, b) roots = np.array([find(i) for i in range(len(verts))], dtype=np.int64) unique_roots, inverse = np.unique(roots, return_inverse=True) merged_verts = np.zeros((len(unique_roots), 3), dtype=verts.dtype) counts = np.bincount(inverse) np.add.at(merged_verts, inverse, verts) merged_verts /= counts[:, None] if vertex_scores is not None: scores = vertex_scores.astype(np.float32, copy=False) best_score = np.full(len(unique_roots), -np.inf, dtype=np.float32) best_index = np.zeros(len(unique_roots), dtype=np.int64) for idx, group_idx in enumerate(inverse): score = scores[idx] if score > best_score[group_idx]: best_score[group_idx] = score best_index[group_idx] = idx merged_feats = vertex_feats[best_index] else: merged_feats = np.zeros((len(unique_roots), vertex_feats.shape[1]), dtype=vertex_feats.dtype) np.add.at(merged_feats, inverse, vertex_feats) merged_feats /= counts[:, None] merged_edges = inverse[edges] keep = merged_edges[:, 0] != merged_edges[:, 1] merged_edges = merged_edges[keep] if len(merged_edges) == 0: return merged_verts, np.zeros((0, 2), dtype=np.int64), merged_feats merged_edges = np.sort(merged_edges, axis=1) merged_edges = np.unique(merged_edges, axis=0) return merged_verts, merged_edges, merged_feats def filter_edges_by_length( verts: np.ndarray, edges: np.ndarray, min_length: float | None = 0.05, max_length: float | None = 100.0, ) -> tuple[np.ndarray, np.ndarray]: if len(edges) == 0: return verts[:0], _empty_edges_like(edges) edge_segments = verts[edges] edge_lengths = np.linalg.norm(edge_segments[:, 1] - edge_segments[:, 0], axis=1) keep = np.ones(len(edges), dtype=bool) if min_length is not None: keep &= edge_lengths >= min_length if max_length is not None: keep &= edge_lengths <= max_length return _compact_vertices_for_edges(verts, edges[keep]) def add_gap_fill_edges_from_candidates( verts: np.ndarray, edges: np.ndarray, candidate_edge_points: np.ndarray, merge_distance_threshold: float, ) -> tuple[np.ndarray, np.ndarray]: if len(verts) == 0 or len(candidate_edge_points) == 0 or merge_distance_threshold < 0: return verts, edges candidate_edge_points = np.asarray(candidate_edge_points).reshape(-1, 2, verts.shape[1]) endpoint_distances = np.linalg.norm( candidate_edge_points.reshape(-1, verts.shape[1])[:, None, :] - verts[None, :, :], axis=2, ) nearest_vertices = np.argmin(endpoint_distances, axis=1).reshape(-1, 2) nearest_distances = np.min(endpoint_distances, axis=1).reshape(-1, 2) known_edges = {tuple(sorted((int(a), int(b)))) for a, b in edges if int(a) != int(b)} added_edges: list[tuple[int, int]] = [] for edge_vertices, edge_distances in zip(nearest_vertices, nearest_distances): if np.any(edge_distances > merge_distance_threshold): continue a, b = int(edge_vertices[0]), int(edge_vertices[1]) if a == b: continue edge_key = tuple(sorted((a, b))) if edge_key in known_edges: continue known_edges.add(edge_key) added_edges.append(edge_key) if len(added_edges) == 0: return verts, edges added_edges_array = np.array(added_edges, dtype=edges.dtype) return verts, np.concatenate([edges, added_edges_array], axis=0) def add_edges_by_translated_edge_symmetry( verts: np.ndarray, edges: np.ndarray, distance_threshold: float = 0.2, ) -> tuple[np.ndarray, np.ndarray]: if len(verts) == 0 or len(edges) == 0 or distance_threshold < 0: return verts, edges template_edges = edges.copy() known_edges = {tuple(sorted((int(a), int(b)))) for a, b in edges if int(a) != int(b)} added_edges: list[tuple[int, int]] = [] for a, b in template_edges: a = int(a) b = int(b) edge_vector = verts[b] - verts[a] if not np.any(edge_vector): continue for template_vector in (edge_vector, -edge_vector): translated_endpoints = verts + template_vector distances = np.linalg.norm(translated_endpoints[:, None, :] - verts[None, :, :], axis=2) nearest_vertices = np.argmin(distances, axis=1) nearest_distances = np.min(distances, axis=1) for start_vertex, (end_vertex, end_distance) in enumerate(zip(nearest_vertices, nearest_distances)): if end_distance > distance_threshold: continue end_vertex = int(end_vertex) if start_vertex == end_vertex: continue edge_key = tuple(sorted((start_vertex, end_vertex))) if edge_key in known_edges: continue known_edges.add(edge_key) added_edges.append(edge_key) if len(added_edges) == 0: return verts, edges added_edges_array = np.array(added_edges, dtype=edges.dtype) return verts, np.concatenate([edges, added_edges_array], axis=0) def _edge_angle_to_xz_plane_deg(edge_vecs: np.ndarray) -> np.ndarray: xz_norms = np.linalg.norm(edge_vecs[:, [0, 2]], axis=1) return np.degrees(np.arctan2(np.abs(edge_vecs[:, 1]), xz_norms)) def _infer_principal_xz_directions( edge_vecs: np.ndarray, edge_lengths: np.ndarray, horizontal_angle_threshold_deg: float, ) -> tuple[np.ndarray, np.ndarray] | None: xz_vecs = edge_vecs[:, [0, 2]] xz_norms = np.linalg.norm(xz_vecs, axis=1) angles = _edge_angle_to_xz_plane_deg(edge_vecs) keep = (edge_lengths > 0) & (xz_norms > 0) & (angles <= horizontal_angle_threshold_deg) if not np.any(keep): return None dirs = xz_vecs[keep] / xz_norms[keep, None] weights = edge_lengths[keep] covariance = np.einsum("i,ij,ik->jk", weights, dirs, dirs) if not np.any(covariance): return None eigvals, eigvecs = np.linalg.eigh(covariance) primary = eigvecs[:, int(np.argmax(eigvals))] primary_norm = np.linalg.norm(primary) if primary_norm == 0: return None primary = primary / primary_norm secondary = np.array([-primary[1], primary[0]], dtype=primary.dtype) return primary, secondary def project_near_horizontal_edges_to_xz_plane( verts: np.ndarray, edges: np.ndarray, horizontal_angle_threshold_deg: float = 5.0, principal_direction_angle_threshold_deg: float = 5.0, ) -> tuple[np.ndarray, np.ndarray]: if len(edges) == 0: return verts, edges edge_vecs = verts[edges[:, 1]] - verts[edges[:, 0]] edge_lengths = np.linalg.norm(edge_vecs, axis=1) principal_dirs = _infer_principal_xz_directions(edge_vecs, edge_lengths, horizontal_angle_threshold_deg) if principal_dirs is None: return verts, edges xz_vecs = edge_vecs[:, [0, 2]] xz_norms = np.linalg.norm(xz_vecs, axis=1) valid = (edge_lengths > 0) & (xz_norms > 0) if not np.any(valid): return verts, edges angles_to_xz = _edge_angle_to_xz_plane_deg(edge_vecs) dirs_xz = np.zeros_like(xz_vecs, dtype=np.result_type(verts.dtype, np.float64)) dirs_xz[valid] = xz_vecs[valid] / xz_norms[valid, None] dots = np.stack([np.abs(dirs_xz @ direction) for direction in principal_dirs], axis=1) angles_to_principal = np.degrees(np.arccos(np.clip(np.max(dots, axis=1), -1.0, 1.0))) eligible = ( valid & (angles_to_xz <= horizontal_angle_threshold_deg) & (angles_to_principal <= principal_direction_angle_threshold_deg) ) eligible_edges = edges[eligible] if len(eligible_edges) == 0: return verts, edges parent = np.arange(len(verts), dtype=np.int64) def find(i: int) -> int: while parent[i] != i: parent[i] = parent[parent[i]] i = parent[i] return i def union(a: int, b: int) -> None: ra, rb = find(a), find(b) if ra != rb: parent[rb] = ra for a, b in eligible_edges: union(int(a), int(b)) new_verts = verts.copy() eligible_vertices = np.unique(eligible_edges.reshape(-1)) roots = np.array([find(int(vertex)) for vertex in eligible_vertices], dtype=np.int64) for root in np.unique(roots): component_vertices = eligible_vertices[roots == root] new_verts[component_vertices, 1] = np.mean(verts[component_vertices, 1]) return new_verts, edges def snap_edges_to_principal_xz_directions( verts: np.ndarray, edges: np.ndarray, horizontal_angle_threshold_deg: float = 5.0, principal_direction_angle_threshold_deg: float = 5.0, ) -> tuple[np.ndarray, np.ndarray]: if len(edges) == 0: return verts, edges edge_vecs = verts[edges[:, 1]] - verts[edges[:, 0]] edge_lengths = np.linalg.norm(edge_vecs, axis=1) principal_dirs = _infer_principal_xz_directions(edge_vecs, edge_lengths, horizontal_angle_threshold_deg) if principal_dirs is None: return verts, edges xz_vecs = edge_vecs[:, [0, 2]] xz_norms = np.linalg.norm(xz_vecs, axis=1) valid = xz_norms > 0 if not np.any(valid): return verts, edges dirs_xz = np.zeros_like(xz_vecs, dtype=np.result_type(verts.dtype, np.float64)) dirs_xz[valid] = xz_vecs[valid] / xz_norms[valid, None] dots = np.stack([np.abs(dirs_xz @ direction) for direction in principal_dirs], axis=1) best_direction_indices = np.argmax(dots, axis=1) angles_to_principal = np.degrees(np.arccos(np.clip(np.max(dots, axis=1), -1.0, 1.0))) eligible = valid & (angles_to_principal <= principal_direction_angle_threshold_deg) eligible_edges = edges[eligible] if len(eligible_edges) == 0: return verts, edges eligible_vertices = np.unique(eligible_edges.reshape(-1)) vertex_to_local = {int(vertex): idx for idx, vertex in enumerate(eligible_vertices)} constraints = np.zeros((len(eligible_edges), 2 * len(eligible_vertices)), dtype=np.float64) eligible_direction_indices = best_direction_indices[eligible] for row, ((a, b), direction_idx) in enumerate(zip(eligible_edges, eligible_direction_indices)): direction = principal_dirs[int(direction_idx)] normal = np.array([-direction[1], direction[0]], dtype=np.float64) a_col = 2 * vertex_to_local[int(a)] b_col = 2 * vertex_to_local[int(b)] constraints[row, a_col : a_col + 2] = -normal constraints[row, b_col : b_col + 2] = normal original_xz = verts[eligible_vertices][:, [0, 2]].astype(np.float64, copy=False).reshape(-1) residual = constraints @ original_xz if not np.any(residual): return verts, edges system = constraints @ constraints.T correction_weights = np.linalg.lstsq(system, residual, rcond=None)[0] snapped_xz = original_xz - constraints.T @ correction_weights new_verts = verts.copy() snapped_xz = snapped_xz.reshape(-1, 2) new_verts[eligible_vertices, 0] = snapped_xz[:, 0] new_verts[eligible_vertices, 2] = snapped_xz[:, 1] return new_verts, edges project_near_horizontal_edges_to_xy_plane = project_near_horizontal_edges_to_xz_plane snap_edges_to_principal_xy_directions = snap_edges_to_principal_xz_directions def _remove_solitary_edges(verts: np.ndarray, edges: np.ndarray) -> tuple[np.ndarray, np.ndarray]: if len(edges) == 0: return verts[:0], _empty_edges_like(edges) degrees = np.bincount(edges.reshape(-1), minlength=len(verts)) edge_degrees = degrees[edges] keep = np.any(edge_degrees > 1, axis=1) return _compact_vertices_for_edges(verts, edges[keep]) def remove_solitary_edges(verts: np.ndarray, edges: np.ndarray) -> tuple[np.ndarray, np.ndarray]: return _remove_solitary_edges(verts, edges) def filter_edges_by_connectivity( verts: np.ndarray, edges: np.ndarray, min_endpoint_degree: int = 2, ) -> tuple[np.ndarray, np.ndarray]: """Keep edges where at least one endpoint connects to >= min_endpoint_degree other edges. Boosts structurally connected edges; penalizes isolated ones. min_endpoint_degree=2 keeps any edge touching a vertex with 2+ incident edges. """ if len(edges) == 0 or min_endpoint_degree <= 1: return verts, edges degrees = np.bincount(edges.reshape(-1), minlength=len(verts)) endpoint_degrees = degrees[edges] # [E, 2] keep = np.any(endpoint_degrees >= min_endpoint_degree, axis=1) return _compact_vertices_for_edges(verts, edges[keep]) def _point_to_segments_distance(points: np.ndarray, seg_starts: np.ndarray, seg_ends: np.ndarray) -> np.ndarray: dtype = np.result_type(points.dtype, seg_starts.dtype, seg_ends.dtype, np.float64) points = points.astype(dtype, copy=False) seg_starts = seg_starts.astype(dtype, copy=False) seg_ends = seg_ends.astype(dtype, copy=False) seg_vecs = seg_ends - seg_starts seg_lens_sq = np.einsum("ij,ij->i", seg_vecs, seg_vecs) offsets = points[:, None, :] - seg_starts[None, :, :] t = np.zeros((len(points), len(seg_starts)), dtype=dtype) valid = seg_lens_sq > 0 if np.any(valid): t[:, valid] = np.einsum("pkj,kj->pk", offsets[:, valid], seg_vecs[valid]) / seg_lens_sq[valid] t = np.clip(t, 0.0, 1.0) closest = seg_starts[None, :, :] + t[:, :, None] * seg_vecs[None, :, :] return np.linalg.norm(points[:, None, :] - closest, axis=2) def remove_edges_close_to_other_edges( verts: np.ndarray, edges: np.ndarray, distance_threshold: float ) -> tuple[np.ndarray, np.ndarray]: if distance_threshold <= 0: return verts, edges if len(edges) == 0: return verts[:0], _empty_edges_like(edges) edge_segments = verts[edges] edge_vecs = edge_segments[:, 1] - edge_segments[:, 0] edge_lengths = np.linalg.norm(edge_vecs, axis=1) edge_order = np.lexsort((np.arange(len(edges)), -edge_lengths)) kept_edge_indices: list[int] = [] for edge_idx in edge_order: if len(kept_edge_indices) == 0: kept_edge_indices.append(edge_idx) continue endpoints = edge_segments[edge_idx] kept_segments = edge_segments[kept_edge_indices] distances = _point_to_segments_distance(endpoints, kept_segments[:, 0], kept_segments[:, 1]) if np.any(distances.mean(axis=0) <= distance_threshold): continue kept_edge_indices.append(edge_idx) kept_edges = edges[sorted(kept_edge_indices)] return _compact_vertices_for_edges(verts, kept_edges) def post_process_wireframe( verts: np.ndarray, edges: np.ndarray, merge_distance_threshold: float, remove_solitary_edges: bool = False, filter_by_length: bool = False, project_near_horizontal_edges: bool = False, snap_to_principal_directions: bool = False, min_edge_length: float | None = 0.05, max_edge_length: float | None = 100.0, horizontal_angle_threshold_deg: float = 5.0, principal_direction_angle_threshold_deg: float = 5.0, intersection_angle_threshold_deg: float | None = 30.0, gap_fill_candidate_edge_points: np.ndarray | None = None, complete_symmetric_edges: bool = False, symmetric_edge_distance_threshold: float = 0.2, connectivity_filter_min_degree: int = 0, ) -> tuple[np.ndarray, np.ndarray]: verts, edges = merge_close_vertices( verts, edges, merge_distance_threshold, intersection_angle_threshold_deg=intersection_angle_threshold_deg, ) if complete_symmetric_edges: verts, edges = add_edges_by_translated_edge_symmetry( verts, edges, distance_threshold=symmetric_edge_distance_threshold, ) if filter_by_length: verts, edges = filter_edges_by_length(verts, edges, min_edge_length, max_edge_length) if project_near_horizontal_edges: verts, edges = project_near_horizontal_edges_to_xz_plane( verts, edges, horizontal_angle_threshold_deg=horizontal_angle_threshold_deg, principal_direction_angle_threshold_deg=principal_direction_angle_threshold_deg, ) if snap_to_principal_directions: verts, edges = snap_edges_to_principal_xz_directions( verts, edges, horizontal_angle_threshold_deg=horizontal_angle_threshold_deg, principal_direction_angle_threshold_deg=principal_direction_angle_threshold_deg, ) if remove_solitary_edges: verts, edges = _remove_solitary_edges(verts, edges) if gap_fill_candidate_edge_points is not None: verts, edges = add_gap_fill_edges_from_candidates( verts, edges, gap_fill_candidate_edge_points, merge_distance_threshold, ) if connectivity_filter_min_degree > 1: verts, edges = filter_edges_by_connectivity(verts, edges, min_endpoint_degree=connectivity_filter_min_degree) return verts, edges