Skip to content

Instantly share code, notes, and snippets.

@yamahigashi
Last active April 21, 2025 01:23
Show Gist options
  • Save yamahigashi/56cd540ee9075f2c32e535fd07103fbd to your computer and use it in GitHub Desktop.
Save yamahigashi/56cd540ee9075f2c32e535fd07103fbd to your computer and use it in GitHub Desktop.
"""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