Last active
April 21, 2025 01:23
-
-
Save yamahigashi/56cd540ee9075f2c32e535fd07103fbd to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"""Module for edge ring/loop walking and selection and reconstruction of subdivision surfaces in Maya.""" | |
import re | |
import struct | |
import zlib | |
from typing import Callable, Optional, Union | |
import maya.api.OpenMaya as om | |
import maya.cmds as cmds | |
import maya.mel as mel | |
try: | |
from gml_util.common.logging import get_logger | |
except ImportError: | |
# gracefully fallback to the default logger | |
from logging import getLogger as get_logger | |
try: | |
# Maya2024: python 3.10 | |
from typing import TypeAlias # typing>=3.10 # type: ignore | |
except ImportError: | |
# Maya2023: python 3.9 | |
# Maya2022: python 3.7 | |
from typing_extensions import TypeAlias | |
logger = get_logger(__name__) | |
COMP_PATTERN = re.compile(r"^(.+)\.e\[(\d+)\]$") | |
_topo_cache: dict[tuple[str, int], "MeshTopology"] = {} | |
_last_cached_scene: str = "" | |
MeshLike: TypeAlias = Union[str, om.MObject, om.MDagPath] | |
# --------------------------------------------------------------------------- | |
# MeshTopology: | |
# --------------------------------------------------------------------------- | |
class MeshTopology: | |
"""This class contains the topology information of a mesh.""" | |
__slots__ = ( | |
"boundary_edges", # list[int] (boundary edge ids) | |
"dag", | |
"edge_faces", # list[list[faceId, ...]] | |
"edge_vertices", # list[(v0, v1)] | |
"num_vertices", # int | |
"polygon_edges", # list[list[edgeId, ...]] (faceIdx 順) | |
"positions", # list[om.MPoint] (vertex positions) | |
"uv_border_edges", # list[int] (uv border edge ids) | |
"vertex_edges", # dict[vertId, list[edgeId, ...]] | |
"vertex_faces", # dict[vertId, list[faceId, ...]] | |
) | |
def __init__(self, dag: om.MDagPath) -> None: | |
"""Initialize MeshTopology containing MFnMesh information. | |
Args: | |
dag: Maya DAG path of the mesh. | |
""" | |
self.dag = dag | |
# --- edge -> vertices / faces -------------------------------------- | |
e2v = [] | |
e2f = [] | |
boundary_edges = [] | |
it_e = om.MItMeshEdge(dag) | |
while not it_e.isDone(): | |
e2v.append((it_e.vertexId(0), it_e.vertexId(1))) | |
e2f.append(it_e.getConnectedFaces()) | |
if it_e.onBoundary(): | |
boundary_edges.append(it_e.index()) | |
it_e.next() | |
self.edge_vertices = e2v | |
self.edge_faces = e2f | |
self.boundary_edges = boundary_edges | |
# --- face -> edges ------------------------------------------------- | |
f2e = [] | |
it_f = om.MItMeshPolygon(dag) | |
while not it_f.isDone(): | |
f2e.append(it_f.getEdges()) | |
it_f.next() | |
self.polygon_edges = f2e | |
# --- vertex -> faces (逆引きテーブル) ------------------------------ | |
mesh = om.MFnMesh(dag) | |
vf = {vid: [] for vid in range(mesh.numVertices)} | |
for f_idx, edge_ids in enumerate(self.polygon_edges): | |
for e_id in edge_ids: | |
v0, v1 = self.edge_vertices[e_id] | |
vf[v0].append(f_idx) | |
vf[v1].append(f_idx) | |
self.vertex_faces = vf | |
self.num_vertices = mesh.numVertices | |
self.positions = mesh.getPoints(om.MSpace.kObject) | |
# --- vertex -> edges (逆引きテーブル) ------------------------------ | |
ve = {vid: [] for vid in range(mesh.numVertices)} | |
for e_idx, (v0, v1) in enumerate(self.edge_vertices): | |
ve[v0].append(e_idx) | |
ve[v1].append(e_idx) | |
self.vertex_edges = ve | |
# --- UV border edges ---------------------------------------------- | |
uv_border_edges = set() | |
# maya 2023.0 以降のバージョンでは、UV border edges を取得する | |
if int(cmds.about(apiVersion=True)) >= 20230000: | |
uv_set_names = mesh.getUVSetNames() | |
current_uv_set_name = mesh.currentUVSetName() | |
current_uv_set_id = uv_set_names.index(current_uv_set_name) | |
border_ids = mesh.getUVBorderEdges(current_uv_set_id) # type: ignore | |
for border_id in border_ids: | |
uv_border_edges.add(border_id) | |
self.uv_border_edges = list(uv_border_edges) | |
# --------------------------------------------------------------------------- | |
# Utility: | |
# --------------------------------------------------------------------------- | |
def get_vertex_checksum(poly_object: MeshLike, position: bool = False) -> int: | |
"""Calculate a checksum for the vertex topology and position of a mesh using CRC-32. | |
Args: | |
poly_object: The mesh object (string, MObject, or MDagPath). | |
position: Whether to include vertex position in the checksum calculation. | |
The default is False. The operation of reconstruct_subdiv does not require the position. | |
Returns: | |
int: The checksum value. | |
""" | |
poly_object = get_meshobject(poly_object) | |
# ---------------------------------------------------------- | |
crc = 0 | |
it_vtx = om.MItMeshVertex(poly_object) | |
while not it_vtx.isDone(): | |
# 頂点インデックス(uint32) | |
crc = zlib.crc32(struct.pack('<I', it_vtx.index()), crc) | |
# 隣接頂点(uint32 x N) | |
for v in it_vtx.getConnectedVertices(): | |
crc = zlib.crc32(struct.pack('<I', v), crc) | |
# 位置情報(double x4) | |
if position: | |
pt = it_vtx.position(om.MSpace.kObject) | |
crc = zlib.crc32(struct.pack('<dddd', | |
pt.x, pt.y, pt.z, pt.w), crc) | |
it_vtx.next() | |
# Python の zlib.crc32 は符号あり 32bit を返すので、符号なしに | |
return crc & 0xFFFFFFFF | |
def get_topology(shape: str) -> MeshTopology: | |
"""Get the topology of a mesh shape. | |
This function caches the topology for faster access. | |
Args: | |
shape: The name of the mesh shape. | |
Returns: | |
MeshTopology: The topology of the mesh shape. | |
""" | |
global _topo_cache, _last_cached_scene | |
checksum = get_vertex_checksum(shape) | |
current_scene = cmds.file(query=True, sceneName=True) | |
if current_scene != _last_cached_scene: | |
_topo_cache.clear() | |
_last_cached_scene = current_scene | |
sl = om.MSelectionList() | |
sl.add(shape) | |
dag = sl.getDagPath(0) | |
path = dag.fullPathName() | |
if (path, checksum) not in _topo_cache: | |
if dag.apiType() != om.MFn.kMesh: | |
dag.extendToShape() | |
if dag.apiType() != om.MFn.kMesh: | |
raise RuntimeError(f"{shape} is not a mesh.") | |
_topo_cache[(path, checksum)] = MeshTopology(dag) | |
return _topo_cache[(path, checksum)] | |
def get_meshobject(mesh_like: MeshLike) -> om.MObject: | |
"""Convert a mesh-like object to an MObject. | |
Args: | |
mesh_like: The mesh-like object (string, MObject, or MDagPath). | |
Returns: | |
om.MObject: The MObject representation of the mesh. | |
""" | |
if isinstance(mesh_like, str): | |
sl = om.MSelectionList() | |
sl.add(mesh_like) | |
return sl.getDependNode(0) | |
elif isinstance(mesh_like, om.MDagPath): | |
return mesh_like.node() | |
elif isinstance(mesh_like, om.MObject): | |
return mesh_like | |
raise TypeError(f"Invalid type: {type(mesh_like)}") | |
def get_mesh_dagpath(mesh_like: MeshLike) -> om.MDagPath: | |
"""Convert a mesh-like object to an MDagPath. | |
Args: | |
mesh_like: The mesh-like object (string, MObject, or MDagPath). | |
Returns: | |
om.MDagPath: The MDagPath representation of the mesh. | |
""" | |
if isinstance(mesh_like, str): | |
sl = om.MSelectionList() | |
sl.add(mesh_like) | |
return sl.getDagPath(0) | |
elif isinstance(mesh_like, om.MDagPath): | |
return mesh_like | |
elif isinstance(mesh_like, om.MObject): | |
sl = om.MSelectionList() | |
sl.add(mesh_like) | |
return sl.getDagPath(0) | |
raise TypeError(f"Invalid type: {type(mesh_like)}") | |
# --------------------------------------------------------------------------- | |
# Edge walking: ring | |
# --------------------------------------------------------------------------- | |
def _opposite_edge_in_face(edge_id: int, face_id: int, topo: MeshTopology) -> Optional[int]: | |
"""Returns an edge in the same face that does not share vertices with edge_id. | |
This is used for edge ring walking. | |
Args: | |
edge_id: The edge ID. | |
face_id: The face ID. | |
topo: The mesh topology. | |
Returns: | |
Optional[int]: The edge ID that does not share vertices with edge_id. | |
""" | |
v0, v1 = topo.edge_vertices[edge_id] | |
for eid in topo.polygon_edges[face_id]: | |
if eid == edge_id: | |
continue | |
ev0, ev1 = topo.edge_vertices[eid] | |
if v0 not in (ev0, ev1) and v1 not in (ev0, ev1): | |
return eid | |
return None | |
def walk_ring(edge_id: int, topo: MeshTopology) -> tuple[list[int], list[int]]: | |
"""Returns a list of edges in a ring starting from edge_id. | |
This function traverses the edge ring using the face sharing method, | |
similar to Maya's "Edge Ring" command. | |
It uses logic to move towards the side that excludes the face | |
to which the current edge belongs. | |
Args: | |
edge_id: The starting edge ID. | |
topo: The mesh topology. | |
Returns: | |
tuple[list[int], list[int]]: A tuple containing two lists of edge IDs, | |
representing the left and right rings respectively. | |
""" | |
faces = topo.edge_faces[edge_id] | |
if not faces: | |
return [edge_id], [] | |
l_ordered: list[int] = [] | |
r_ordered: list[int] = [] | |
def _walk(ordered: list[int], face0: int) -> list[int]: | |
current_e = edge_id | |
current_f = face0 | |
def _append(e: int) -> None: | |
ordered.append(e) | |
while current_e not in ordered: | |
_append(current_e) | |
nxt = _opposite_edge_in_face(current_e, current_f, topo) | |
if nxt is None: | |
break # 境界で終了 | |
faces_nxt = topo.edge_faces[nxt] | |
if len(faces_nxt) < 2: | |
_append(nxt) | |
break # 境界 | |
# 反対側フェースへ | |
if faces_nxt[1] == current_f: | |
current_f = faces_nxt[0] | |
else: | |
current_f = faces_nxt[1] | |
current_e = nxt | |
return ordered | |
_walk(l_ordered, faces[0]) | |
if len(faces) == 2: | |
_walk(r_ordered, faces[1]) | |
return l_ordered, r_ordered | |
# --------------------------------------------------------------------------- | |
# Edge walking: loop | |
# --------------------------------------------------------------------------- | |
def _collect_edges_current_faces(current_e: int, topo: MeshTopology) -> set[int]: | |
"""Helper function to collect edges that belong to the same faces as current_e. | |
Args: | |
current_e: The current edge ID. | |
topo: The mesh topology. | |
Returns: | |
set[int]: A set of edge IDs belonging to the same faces as current_e. | |
""" | |
faces = topo.edge_faces[current_e] | |
return {e for f in faces for e in topo.polygon_edges[f]} | |
def _get_candidate_faces_for_loop(current_v: int, current_faces: set[int], topo: MeshTopology) -> set[int]: | |
"""Helper function to get candidate faces for the loop. | |
Args: | |
current_v: The current vertex ID. | |
current_faces: The set of faces already traversed. | |
topo: The mesh topology. | |
Returns: | |
set[int]: A set of candidate face IDs for the loop. | |
""" | |
return {f for f in topo.vertex_faces[current_v] if f not in current_faces} | |
def _find_next_loop_edge( | |
current_e: int, | |
current_v: int, | |
current_faces: set[int], | |
topo: MeshTopology, | |
visited: set[int], | |
) -> Optional[int]: | |
"""Helper function to find the next edge in the loop. | |
Find the next edge in the loop: shares the current vertex, is not visited, | |
and not part of the same face-subgraph as current_e. | |
Args: | |
current_e: The current edge ID. | |
current_v: The current vertex ID. | |
current_faces: The set of faces already traversed. | |
topo: The mesh topology. | |
visited: A set of visited edges. | |
Returns: | |
Optional[int]: The next edge ID in the loop, or None if not found. | |
""" | |
# Find candidate edges connected to current_v that are valid next steps | |
# Candidate faces are faces connected to current_v, *excluding* faces adjacent to current_e | |
candidate_edges: set[int] = set() | |
candidate_faces = _get_candidate_faces_for_loop(current_v, current_faces, topo) | |
# First pass: Gather potential candidates rigorously | |
potential_candidates: set[int] = set() | |
if current_v in topo.vertex_edges: | |
for edge_id in topo.vertex_edges[current_v]: | |
# Basic validity checks | |
if not (0 <= edge_id < len(topo.edge_vertices)): | |
continue | |
if edge_id == current_e or edge_id in visited: | |
continue | |
# Check if this edge belongs to any of the candidate faces (faces at current_v not adjacent to current_e) | |
edge_faces = set(topo.edge_faces[edge_id]) | |
if not edge_faces.intersection(candidate_faces): | |
# This edge doesn't belong to a 'forward' face from current_v relative to current_e | |
continue | |
potential_candidates.add(edge_id) | |
else: | |
logger.warning(f"Vertex {current_v} not found in vertex_edges map.") | |
return None | |
# Filter candidates further if needed (e.g., using the 'edges_adj_current_faces' check, though it can be strict) | |
# For now, let's rely on the straightness check for multiple candidates. | |
candidate_edges = potential_candidates | |
# --- Decide the next edge --- | |
if not candidate_edges: | |
# logger.debug(f"No valid loop candidate edges found at vertex {current_v}.") | |
return None | |
elif len(candidate_edges) == 1: | |
# Only one valid path forward | |
chosen_edge = candidate_edges.pop() | |
# logger.debug(f"Single loop candidate edge {chosen_edge} found at vertex {current_v}.") | |
return chosen_edge | |
else: | |
# Multiple candidates, choose the most straight one | |
# logger.debug(f"Multiple ({len(candidate_edges)}) loop candidates found at vertex {current_v}: {candidate_edges}. Choosing straightest.") | |
# Pass current_e (edge arrived from), candidates, current_v (shared vertex), and topo | |
chosen_edge = choose_most_straight_edge(current_e, candidate_edges, current_v, topo) | |
return chosen_edge | |
return None | |
def choose_most_straight_edge( | |
current_e: int, | |
candidates: set[int], | |
shared_v: int, # The vertex where current_e and candidates meet | |
topo: MeshTopology, | |
) -> Optional[int]: | |
"""Choose the 'most straight' edge from candidates relative to current_e at shared_v. | |
'Most straight' is determined by finding the candidate edge whose direction | |
vector (originating from shared_v) has the most negative dot product | |
(closest to -1) with the direction vector of current_e (originating from shared_v). | |
Args: | |
current_e: The reference edge ID (the one just traversed). | |
candidates: A set of candidate next edge IDs connected at shared_v. | |
shared_v: The vertex ID where current_e and candidates connect. | |
topo: The mesh topology containing vertex positions. | |
Returns: | |
Optional[int]: The chosen edge ID, or None if no valid candidates or error. | |
""" | |
if not candidates: | |
return None | |
try: | |
# --- Get vector for the current edge --- | |
v_curr_a, v_curr_b = topo.edge_vertices[current_e] | |
p_shared = topo.positions[shared_v] # Position of the shared vertex | |
# Find the *other* vertex of the current edge (the one we came from) | |
other_v_curr = v_curr_b if v_curr_a == shared_v else v_curr_a | |
# Ensure the other vertex index is valid | |
if not (0 <= other_v_curr < topo.num_vertices): | |
logger.error(f"Invalid 'other' vertex index {other_v_curr} for current edge {current_e}. Cannot calculate direction.") | |
# Fallback: return the first candidate | |
return next(iter(candidates)) if candidates else None | |
p_other_curr = topo.positions[other_v_curr] | |
# Vector pointing away from shared_v along the current edge's path *backwards* | |
# To get the forward direction relative to shared_v, we actually want the vector *towards* p_other_curr | |
# vec_current = p_other_curr - p_shared | |
# However, the loop logic passes current_v as the vertex *reached*. | |
# So, current_e leads *to* shared_v (current_v). We need the vector pointing *away* from shared_v | |
# along the direction *opposite* to how we arrived. | |
# Let's redefine: vec_ref is the vector pointing FROM shared_v TO the vertex we came FROM. | |
vec_ref = (p_other_curr - p_shared) | |
# We want the candidate vector that is most aligned with -vec_ref (most opposite to vec_ref) | |
# Or equivalently, most aligned with (p_shared - p_other_curr) | |
# Let's use the forward vector for simplicity: vector along current_e towards shared_v | |
vec_incoming = (p_shared - p_other_curr).normal() # Normalized vector arriving at shared_v | |
except IndexError as e: | |
logger.error(f"Index error accessing topology for current edge {current_e} or shared vertex {shared_v}: {e}") | |
return next(iter(candidates)) if candidates else None | |
except Exception as e: | |
logger.error(f"Unexpected error getting reference vector: {e}") | |
return next(iter(candidates)) if candidates else None | |
best_edge: Optional[int] = None | |
# Initialize min_dot_product. Since we compare vec_incoming with vec_outgoing, | |
# the ideal dot product (straightest path) is 1.0 (most aligned). | |
# So we initialize with a value lower than the minimum possible (-1.0). | |
max_dot_product = -1.1 | |
for edge_id in candidates: | |
try: | |
# --- Get vector for the candidate edge --- | |
v_cand_a, v_cand_b = topo.edge_vertices[edge_id] | |
# Find the *other* vertex of the candidate edge (the one we are going towards) | |
if shared_v == v_cand_a: | |
other_v_cand = v_cand_b | |
elif shared_v == v_cand_b: | |
other_v_cand = v_cand_a | |
else: | |
# This should not happen if candidate selection is correct | |
logger.warning(f"Candidate edge {edge_id} {v_cand_a, v_cand_b} doesn't share vertex {shared_v}? Skipping.") | |
continue | |
# Ensure the other vertex index is valid | |
if not (0 <= other_v_cand < topo.num_vertices): | |
logger.warning(f"Invalid 'other' vertex index {other_v_cand} for candidate edge {edge_id}. Skipping.") | |
continue | |
p_other_cand = topo.positions[other_v_cand] | |
# Vector pointing away from shared_v along the candidate edge | |
vec_outgoing = (p_other_cand - p_shared).normal() # Normalize | |
# --- Calculate dot product --- | |
# Compare the incoming vector direction with the outgoing candidate direction | |
dot_product = vec_incoming * vec_outgoing | |
# We want the candidate most aligned with the incoming direction (dot product closest to 1.0) | |
if dot_product > max_dot_product: | |
# Add a check to avoid picking the edge we just came from if somehow it's a candidate | |
# (shouldn't happen with `visited` check, but as a safeguard) | |
# This check is complex if topology allows U-turns. Let's rely on `visited`. | |
# Check if the angle is very close to 0 (dot product near 1) | |
# This helps avoid issues with nearly collinear edges causing noise. | |
# Using math.isclose for robust comparison near 1.0 | |
# if math.isclose(dot_product, 1.0, abs_tol=1e-6): | |
# Very straight, likely the correct path | |
# logger.debug(f"Candidate {edge_id} is almost perfectly straight (dot={dot_product:.4f})") | |
max_dot_product = dot_product | |
best_edge = edge_id | |
except IndexError as e: | |
logger.warning(f"Index error accessing topology for candidate edge {edge_id}: {e}. Skipping.") | |
continue | |
except Exception as e: | |
logger.error(f"Unexpected error processing candidate edge {edge_id}: {e}. Skipping.") | |
continue | |
if max_dot_product < 0.8: | |
# No valid candidates found, return None | |
logger.warning(f"No valid candidates found for edge {current_e} at vertex {shared_v}.") | |
return None | |
if best_edge is None and candidates: | |
# Fallback if all candidates caused errors | |
logger.warning("Could not determine the best straight edge due to errors. Returning the first available candidate.") | |
best_edge = next(iter(candidates)) | |
# logger.debug(f"Chose edge {best_edge} from candidates {candidates} at vertex {shared_v} (max dot: {max_dot_product:.3f})") | |
return best_edge | |
def walk_loop(edge_id: int, topo: MeshTopology) -> tuple[list[int], list[int]]: | |
"""Returns a list of edges in a loop starting from edge_id. | |
This function traverses the edge loop using the vertex sharing method, | |
similar to Maya's "Edge Loop" command. | |
It uses logic to move towards the side that excludes the face | |
to which the current edge belongs. | |
Args: | |
edge_id: The starting edge ID. | |
topo: The mesh topology. | |
Returns: | |
tuple[list[int], list[int]]: A tuple containing two lists of edge IDs, | |
representing the left and right loops respectively. | |
""" | |
v0, v1 = topo.edge_vertices[edge_id] | |
def _walk_direction(start_edge: int, start_vertex: int) -> list[int]: | |
current_e = start_edge | |
current_v = start_vertex | |
visited_local = {current_e} | |
ordered: list[int] = [current_e] | |
while True: | |
current_faces = set(topo.edge_faces[current_e]) | |
next_e = _find_next_loop_edge( | |
current_e, | |
current_v, | |
current_faces, | |
topo, | |
visited_local, | |
) | |
if next_e is None: | |
break | |
ordered.append(next_e) | |
visited_local.add(next_e) | |
# Update current_v and current_e | |
ev0, ev1 = topo.edge_vertices[next_e] | |
current_v = ev1 if ev0 == current_v else ev0 | |
current_e = next_e | |
return ordered | |
# Walk in both directions | |
left_loop = _walk_direction(edge_id, v1) | |
right_loop = _walk_direction(edge_id, v0) | |
return left_loop, right_loop | |
# --------------------------------------------------------------------------- | |
# Edge processing: | |
# --------------------------------------------------------------------------- | |
def process_edges( | |
edge_comps: Union[list[str], set[str]], | |
walk_fn: Callable[[int, MeshTopology], tuple[list[int], list[int]]], | |
every_n: int = 1, | |
offset: int = 0, | |
) -> set[str]: | |
"""Process selected edge components and apply a function to every N edges. | |
Returns a list of selected edge components. | |
Args: | |
edge_comps: List of edge components (e.g., ["pCube1.e[0]", "pCube1.e[1]"]). | |
walk_fn: Function to walk edges (e.g., walk_ring or walk_loop). | |
every_n: Interval for edge selection. | |
offset: Offset for the selection. | |
""" | |
seeds: dict[str, set[int]] = {} | |
for comp in edge_comps: | |
m = COMP_PATTERN.match(comp) | |
if not m: | |
continue | |
shape, eid = m.groups() | |
seeds.setdefault(shape, set()).add(int(eid)) | |
if not seeds: | |
logger.warning("No valid edge components found.") | |
return set() | |
targets: set[str] = set() | |
for shape, seed_edges in seeds.items(): | |
topo = get_topology(shape) | |
visited: set[int] = set() | |
for seed in seed_edges: | |
if seed in visited: | |
continue | |
l_seq, r_seq = walk_fn(seed, topo) | |
r_trimmed = [eid for idx, eid in enumerate(r_seq) if (idx - offset) % every_n == 0] | |
l_trimmed = [eid for idx, eid in enumerate(l_seq) if (idx - offset) % every_n == 0] | |
trimmed = r_trimmed + l_trimmed | |
new_edges = [eid for eid in trimmed if eid not in visited] | |
comps = [f"{shape}.e[{eid}]" for eid in new_edges] | |
targets.update(comps) | |
seq = l_seq + r_seq | |
visited.update(seq) | |
return targets | |
def process_selection( | |
walk_fn: Callable[[int, MeshTopology], tuple[list[int], list[int]]], | |
action_fn: Callable[[list[str]], None], | |
every_n: int = 1, | |
offset: int = 0, | |
) -> None: | |
"""Process selected edge components and apply a function to every N edges. | |
Args: | |
walk_fn: Function to walk edges (e.g., walk_ring or walk_loop). | |
action_fn: Function to apply to every N edges. | |
every_n: Interval for edge selection. | |
offset: Offset for the selection. | |
""" | |
comps = cmds.ls(selection=True, flatten=True) | |
if not comps: | |
logger.warning("No edge components selected.") | |
select_boundary_edges() | |
comps = cmds.ls(selection=True, flatten=True) | |
new_comps = process_edges(comps, walk_fn, every_n, offset) | |
if new_comps: | |
action_fn(list(new_comps)) | |
return | |
new_comps = process_edges(comps, walk_fn, every_n, offset) | |
if new_comps: | |
action_fn(list(new_comps)) | |
else: | |
select_boundary_edges() | |
comps = cmds.ls(selection=True, flatten=True) | |
new_comps = process_edges(comps, walk_fn, every_n, offset) | |
if new_comps: | |
action_fn(list(new_comps)) | |
def action_select(comps: list[str]) -> None: | |
"""Select edge components. | |
Args: | |
comps: List of edge components to select. | |
""" | |
cmds.select(clear=True) | |
cmds.select(comps, add=True) | |
def action_delete(comps: list[str]) -> None: | |
"""Delete edge components | |
Args: | |
comps: List of edge components to delete. | |
""" | |
cmds.select(clear=True) | |
cmds.polyDelEdge(comps, cv=True) | |
##################################################################### | |
# API Functions | |
##################################################################### | |
def select_ring(every_n: int = 1, offset: int = 0) -> None: | |
"""Select edge ring components. | |
Args: | |
every_n: Interval for edge selection. | |
offset: Offset for the selection. | |
""" | |
process_selection(walk_ring, action_select, every_n, offset) | |
def select_loop(every_n: int = 1, offset: int = 0) -> None: | |
"""Select edge loop components. | |
Args: | |
every_n: Interval for edge selection. | |
offset: Offset for the selection. | |
""" | |
process_selection(walk_loop, action_select, every_n, offset) | |
def decimate_ring(every_n: int = 1, offset: int = 0) -> None: | |
"""Decimate edge ring components. | |
Args: | |
every_n: Interval for edge selection. | |
offset: Offset for the selection. | |
""" | |
process_selection(walk_ring, action_delete, every_n, offset) | |
def decimate_loop(every_n: int = 1, offset: int = 0) -> None: | |
"""Decimate edge loop components. | |
Args: | |
every_n: Interval for edge selection. | |
offset: Offset for the selection. | |
""" | |
process_selection(walk_loop, action_delete, every_n, offset) | |
def select_boundary_edges() -> None: | |
"""Select boundary edges of the selected mesh.""" | |
# Change selection mode to object mode | |
mel.eval('maintainActiveChangeSelectMode "*" 1;') | |
sel = cmds.ls(selection=True, flatten=True) | |
if not sel: | |
cmds.warning("No mesh selected.") | |
return | |
comps = [] | |
for shape in sel: | |
topo = get_topology(shape) | |
initial_edges = set() | |
initial_edges.update(topo.uv_border_edges) | |
initial_edges.update(topo.boundary_edges) | |
comp = [f"{shape}.e[{eid}]" for eid in initial_edges] | |
comps.extend(comp) | |
print(len(comp)) | |
# Change selection mode to edge mode | |
mel.eval(f'doMenuComponentSelectionExt("{sel[0]}", "edge", 1);') | |
cmds.select(cl=True) | |
cmds.select(comps, tgl=True) | |
def reconstruct_subdiv(level: int = 1) -> None: | |
"""Reconstruct subdivision surface of the selected mesh. | |
The boundary edges are selected first, and then a ring selection with an offset of 1 is applied. | |
This means that the boundary edges are preserved, and the edges inside them are selected for deletion. | |
If there are no boundary edges, the last edge loop is selected and a ring selection with an offset of 1 | |
is applied. This means that the last edge loop, which is created when applying subdivision, | |
is selected for deletion. | |
This function is useful for reconstructing the mesh topology after applying subdivision surfaces. | |
Args: | |
level: Subdivision level (default is 1). | |
""" | |
# change to object mode | |
mel.eval('maintainActiveChangeSelectMode "*" 1;') | |
sel = cmds.ls(selection=True, flatten=True) | |
if not sel: | |
cmds.warning("No mesh selected.") | |
return | |
for shape in sel: | |
for _ in range(level): | |
topo = get_topology(shape) | |
initial_edges = set() | |
if topo.uv_border_edges or topo.boundary_edges: | |
# UV border edges | |
initial_edges.update(topo.uv_border_edges) | |
initial_edges.update(topo.boundary_edges) | |
initial_offset = 1 | |
exclude_initial = True | |
else: | |
# last edge loo | |
last_edges = topo.vertex_edges[topo.num_vertices - 1] | |
initial_edges.update(last_edges) | |
initial_offset = 0 | |
exclude_initial = False | |
initial_comps = {f"{shape}.e[{eid}]" for eid in initial_edges} | |
targets = set() | |
targets.update(process_edges(initial_comps, walk_ring, every_n=2, offset=initial_offset)) | |
if exclude_initial : | |
target_edge_ids = set() | |
for comp_name in targets: | |
m = COMP_PATTERN.match(comp_name) | |
if m: | |
target_edge_ids.add(int(m.group(2))) | |
# if initial edges apear in the target edges applied the ring selection, | |
# the mesh is not a valid subdiv mesh. Because the initial edges should | |
# not be selected. | |
if initial_edges.intersection(target_edge_ids): | |
message = f"The selected mesh seems to be not a valid subdiv mesh({shape}).\n" \ | |
"Fallback to exclude the mis-selected edges.\n" \ | |
"Please check the topology after the operation." | |
logger.warning(message) | |
initial_to_be_excluded = set() | |
for eid in initial_edges: | |
if eid in target_edge_ids: | |
initial_to_be_excluded.add(f"{shape}.e[{eid}]") | |
to_be_excluded = process_edges(initial_to_be_excluded, walk_loop) | |
targets = initial_comps - to_be_excluded | |
targets = process_edges(targets, walk_ring, every_n=2, offset=initial_offset) | |
targets.update(process_edges(targets, walk_loop)) | |
targets.update(process_edges(targets, walk_ring, every_n=2, offset=0)) | |
cmds.polyDelEdge(list(targets), cleanVertices=True) | |
# --------------------------------------------------------------------------- | |
# UI | |
# --------------------------------------------------------------------------- | |
WINDOW_NAME = "edgeWalkingUI" | |
WINDOW_TITLE = "Edge Walking Tools" | |
RING_EVERY_N_FIELD = "ew_ring_everyNField" # Control name for the 'Every N' field group | |
RING_OFFSET_FIELD = "ew_ring_offsetField" # Control name for the 'Offset' field group | |
LOOP_EVERY_N_FIELD = "ew_loop_everyNField" # Control name for the 'Every N' field group | |
LOOP_OFFSET_FIELD = "ew_loop_offsetField" # Control name for the 'Offset' field group | |
def _get_ui_values() -> tuple[int, int, int, int]: | |
"""Helper function to get every_n and offset from the UI's intFieldGrp. | |
Returns: | |
tuple[int, int]: A tuple containing every_n and offset values. | |
""" | |
ring_every_n = 1 | |
ring_offset = 0 | |
loop_every_n = 1 | |
loop_offset = 0 | |
# Check if controls exist before querying | |
if cmds.control(RING_EVERY_N_FIELD, exists=True): | |
# intFieldGrp returns a list of values (one value in this case) | |
values_n = cmds.intFieldGrp(RING_EVERY_N_FIELD, query=True, value=True) | |
if values_n and len(values_n) > 0: | |
ring_every_n = values_n[0] | |
if cmds.control(RING_OFFSET_FIELD, exists=True): | |
values_o = cmds.intFieldGrp(RING_OFFSET_FIELD, query=True, value=True) | |
if values_o and len(values_o) > 0: | |
ring_offset = values_o[0] | |
# Check if controls exist before querying | |
if cmds.control(RING_EVERY_N_FIELD, exists=True): | |
# intFieldGrp returns a list of values (one value in this case) | |
values_n = cmds.intFieldGrp(RING_EVERY_N_FIELD, query=True, value=True) | |
if values_n and len(values_n) > 0: | |
ring_every_n = values_n[0] | |
if cmds.control(RING_OFFSET_FIELD, exists=True): | |
values_o = cmds.intFieldGrp(RING_OFFSET_FIELD, query=True, value=True) | |
if values_o and len(values_o) > 0: | |
ring_offset = values_o[0] | |
# Ensure ring_every_n is at least 1, as 0 or negative would break modulo logic | |
ring_every_n = max(1, ring_every_n) | |
# Check if controls exist before querying | |
if cmds.control(LOOP_EVERY_N_FIELD, exists=True): | |
# intFieldGrp returns a list of values (one value in this case) | |
values_n = cmds.intFieldGrp(LOOP_EVERY_N_FIELD, query=True, value=True) | |
if values_n and len(values_n) > 0: | |
loop_every_n = values_n[0] | |
if cmds.control(LOOP_OFFSET_FIELD, exists=True): | |
values_o = cmds.intFieldGrp(LOOP_OFFSET_FIELD, query=True, value=True) | |
if values_o and len(values_o) > 0: | |
loop_offset = values_o[0] | |
# Check if controls exist before querying | |
if cmds.control(LOOP_EVERY_N_FIELD, exists=True): | |
# intFieldGrp returns a list of values (one value in this case) | |
values_n = cmds.intFieldGrp(LOOP_EVERY_N_FIELD, query=True, value=True) | |
if values_n and len(values_n) > 0: | |
loop_every_n = values_n[0] | |
if cmds.control(LOOP_OFFSET_FIELD, exists=True): | |
values_o = cmds.intFieldGrp(LOOP_OFFSET_FIELD, query=True, value=True) | |
if values_o and len(values_o) > 0: | |
loop_offset = values_o[0] | |
loop_every_n = max(1, loop_every_n) | |
logger.debug(f"UI values read: ring_every_n={ring_every_n}, offset={ring_offset}") | |
return ring_every_n, ring_offset, loop_every_n, loop_offset | |
def _reset_ring_ui_values(*_args) -> None: | |
"""Resets the every_n and offset fields to default values (1 and 0).""" | |
logger.debug("Resetting UI parameters.") | |
if cmds.control(RING_EVERY_N_FIELD, exists=True): | |
cmds.intFieldGrp(RING_EVERY_N_FIELD, edit=True, value1=1) # value1 corresponds to the first field | |
if cmds.control(RING_OFFSET_FIELD, exists=True): | |
cmds.intFieldGrp(RING_OFFSET_FIELD, edit=True, value1=0) # value1 corresponds to the first field | |
def _reset_loop_ui_values(*_args) -> None: | |
"""Resets the every_n and offset fields to default values (1 and 0).""" | |
logger.debug("Resetting UI parameters.") | |
if cmds.control(LOOP_EVERY_N_FIELD, exists=True): | |
cmds.intFieldGrp(LOOP_EVERY_N_FIELD, edit=True, value1=1) # value1 corresponds to the first field | |
if cmds.control(LOOP_OFFSET_FIELD, exists=True): | |
cmds.intFieldGrp(LOOP_OFFSET_FIELD, edit=True, value1=0) # value1 corresponds to the first field | |
def _select_ring_ui(*_args) -> None: | |
"""Wrapper for select_ring using UI values for every_n and offset.""" | |
every_n, offset, _, _ = _get_ui_values() | |
select_ring(every_n=every_n, offset=offset) | |
def _select_loop_ui(*_args) -> None: | |
"""Wrapper for select_loop using UI values for every_n and offset.""" | |
_, _, every_n, offset = _get_ui_values() | |
select_loop(every_n=every_n, offset=offset) | |
def _reconstruct_subdiv_ui(*_args) -> None: | |
"""Wrapper for reconstruct_subdiv. Uses a fixed level (e.g., 1).""" | |
# Note: This function currently IGNORES the UI's every_n and offset. | |
# The reconstruct_subdiv function uses hardcoded values internally. | |
# To use UI values, you would need to modify reconstruct_subdiv | |
# to accept these as parameters and adjust its internal logic accordingly. | |
level = 1 # Or add an intField for level in the UI | |
logger.info(f"Calling reconstruct_subdiv with level={level}") | |
reconstruct_subdiv(level=level) | |
def show_ui() -> None: | |
"""Create and show the UI for edge walking and selection tools.""" | |
# If the window already exists, delete it to avoid duplicates | |
if cmds.window(WINDOW_NAME, exists=True): | |
logger.debug(f"Deleting existing UI: {WINDOW_NAME}") | |
cmds.deleteUI(WINDOW_NAME, window=True) | |
# Create the main window | |
cmds.window(WINDOW_NAME, title=WINDOW_TITLE, widthHeight=(260, 320), sizeable=True) | |
# Use a main column layout with spacing | |
main_layout = cmds.columnLayout( | |
adjustableColumn=True, | |
rowSpacing=5, | |
) | |
# --- Selection Tools Section --- (No changes below this line compared to previous UI code) | |
cmds.text(label="Edge Selection Tools", font="boldLabelFont", align="left", parent=main_layout) | |
cmds.separator(style="in", height=5, parent=main_layout) | |
# --- Boundary Selection ------------------------------------------------- | |
_param_controls_layout = cmds.rowLayout( | |
numberOfColumns=6, # One column for each main control group (N, Offset, Reset) | |
columnWidth6=(20, 65, 55, 30, 15, 140), | |
parent=main_layout, | |
) | |
cmds.separator(style="none", height=2) | |
cmds.separator(style="none", height=2) | |
cmds.separator(style="none", height=2) | |
cmds.separator(style="none", height=2) | |
cmds.separator(style="none", height=2) | |
cmds.button( | |
label="Select Boundary / UV Border", | |
command=lambda _: select_boundary_edges(), # type: ignore | |
annotation="Select geometric boundary and UV border edges (Maya 2023+ for UV borders).", | |
) | |
# --- Ring Selection ------------------------------------------------- | |
_param_controls_layout = cmds.rowLayout( | |
numberOfColumns=6, # One column for each main control group (N, Offset, Reset) | |
columnWidth6=(20, 65, 55, 30, 15, 140), | |
parent=main_layout, | |
) | |
cmds.separator(style="none", height=5) | |
# intFieldGrp for Every Nth value input (child of the rowLayout) | |
cmds.intFieldGrp( | |
RING_EVERY_N_FIELD, | |
numberOfFields=1, | |
label='Every N:', | |
value1=2, | |
columnWidth2=[42, 23], # type: ignore | |
annotation="Select every Nth", | |
) | |
# intFieldGrp for Offset value input (child of the rowLayout) | |
cmds.intFieldGrp( | |
RING_OFFSET_FIELD, | |
numberOfFields=1, | |
label='Offset:', | |
value1=1, | |
columnWidth2=[32, 23], # type: ignore | |
annotation="Offset", | |
) | |
# Reset button for parameters (child of the rowLayout) | |
cmds.button( | |
label="Reset", # Shorter label fits better | |
command=_reset_ring_ui_values, # type: ignore | |
annotation="Reset", | |
) | |
cmds.separator(style="none", height=5) | |
cmds.button( | |
label="Select Ring (using N, Offset)", | |
command=_select_ring_ui, # type: ignore | |
annotation="Select edge ring based on current selection and parameters.", | |
) | |
# --- Loop Selection ------------------------------------------------- | |
_param_controls_layout = cmds.rowLayout( | |
numberOfColumns=6, # One column for each main control group (N, Offset, Reset) | |
columnWidth6=(20, 65, 55, 30, 15, 140), | |
parent=main_layout, | |
) | |
cmds.separator(style="none", height=5) | |
# intFieldGrp for Every Nth value input (child of the rowLayout) | |
cmds.intFieldGrp( | |
LOOP_EVERY_N_FIELD, | |
numberOfFields=1, | |
label='Every N:', | |
value1=1, | |
columnWidth2=[42, 23], # type: ignore | |
annotation="Select every Nth", | |
) | |
# intFieldGrp for Offset value input (child of the rowLayout) | |
cmds.intFieldGrp( | |
LOOP_OFFSET_FIELD, | |
numberOfFields=1, | |
label='Offset:', | |
value1=0, | |
columnWidth2=[32, 23], # type: ignore | |
annotation="Offset", | |
) | |
# Reset button for parameters (child of the rowLayout) | |
cmds.button( | |
label="Reset", # Shorter label fits better | |
command=_reset_loop_ui_values, # type: ignore | |
annotation="Reset", | |
) | |
cmds.separator(style="none", height=5) | |
cmds.button( | |
label="Select Loop (using N, Offset)", | |
command=_select_loop_ui, # type: ignore | |
annotation="Select edge loop based on current selection and parameters.", | |
) | |
# End of the rowLayout section. Subsequent controls go back to main_layout. | |
# Add the separator *after* the rowLayout, back in the main column layout | |
cmds.separator(style="in", height=10, parent=main_layout) | |
# --- Modification Tools Section --- | |
cmds.text(label="Modification Tools", font="boldLabelFont", align="left", parent=main_layout) | |
cmds.separator(style="in", height=5, parent=main_layout) | |
cmds.button( | |
label="Reconstruct Subdiv (Level 1)", | |
command=_reconstruct_subdiv_ui, # Uses fixed level, ignores N/Offset # type: ignore | |
annotation="Attempt to reverse one level of subdivision. Ignores N/Offset parameters.", | |
parent=main_layout, | |
) | |
cmds.separator(style="none", height=5, parent=main_layout) | |
# Show the window | |
cmds.showWindow(WINDOW_NAME) | |
logger.info(f"{WINDOW_TITLE} UI displayed.") | |
__all__ = [ | |
"decimate_loop", | |
"decimate_ring", | |
"reconstruct_subdiv", | |
"select_loop", | |
"select_ring", | |
"show_ui", | |
] | |
show_ui() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment