Files
Nexus/2023/scripts/animation_tools/dwpicker/designer/patheditor.py
2025-11-23 23:31:18 +08:00

704 lines
25 KiB
Python

import os
import json
from copy import deepcopy
from functools import partial
from ..pyside import QtWidgets, QtCore, QtGui
from maya import cmds
from ..geometry import (
distance, get_global_rect, grow_rect, path_symmetry)
from ..qtutils import icon
from ..interactionmanager import InteractionManager
from ..interactive import SelectionSquare, Manipulator
from ..optionvar import (
LAST_OPEN_DIRECTORY, SHAPE_PATH_ROTATION_STEP_ANGLE, save_optionvar)
from ..painting import (
draw_selection_square, draw_manipulator, draw_tangents,
draw_world_coordinates)
from ..path import get_open_directory
from ..qtutils import get_cursor
from ..selection import get_selection_mode
from ..shapepath import (
offset_tangent, offset_path, auto_tangent, create_polygon_path,
rotate_path)
from ..transform import (
Transform, resize_path_with_reference, resize_rect_with_direction)
from ..viewport import ViewportMapper
class PathEditor(QtWidgets.QWidget):
pathEdited = QtCore.Signal()
def __init__(self, parent=None):
super(PathEditor, self).__init__(parent)
self.setWindowTitle('Shape path editor')
self.buffer_path = None
self.rotate_center = None
self.canvas = PathEditorCanvas()
self.canvas.pathEdited.connect(self.pathEdited.emit)
export_path = QtWidgets.QAction(icon('save.png'), 'Export path', self)
export_path.triggered.connect(self.export_path)
import_path = QtWidgets.QAction(icon('open.png'), 'Import path', self)
import_path.triggered.connect(self.import_path)
delete = QtWidgets.QAction(icon('delete.png'), 'Delete vertex', self)
delete.triggered.connect(self.canvas.delete)
smooth_tangent = QtWidgets.QAction(
icon('tangent.png'), 'Smooth tangents', self)
smooth_tangent.triggered.connect(self.canvas.smooth_tangents)
break_tangent = QtWidgets.QAction(
icon('tangentbreak.png'), 'Break tangents', self)
break_tangent.triggered.connect(self.canvas.break_tangents)
hsymmetry = QtWidgets.QAction(
icon('h_symmetry.png'), 'Mirror horizontally', self)
hsymmetry.triggered.connect(partial(self.canvas.symmetry, True))
vsymmetry = QtWidgets.QAction(
icon('v_symmetry.png'), 'Mirror vertically', self)
vsymmetry.triggered.connect(partial(self.canvas.symmetry, False))
center_path = QtWidgets.QAction(
icon('frame.png'), 'Center path', self)
center_path.triggered.connect(partial(self.canvas.center_path))
center_path_button = QtWidgets.QToolButton()
center_path_button.setDefaultAction(center_path)
self.angle = QtWidgets.QSlider(QtCore.Qt.Horizontal)
self.angle.setMinimum(0)
self.angle.setMaximum(360)
self.angle.sliderPressed.connect(self.start_rotate)
self.angle.sliderReleased.connect(self.end_rotate)
self.angle.valueChanged.connect(self.rotate)
self.angle_step = QtWidgets.QSpinBox()
self.angle_step.setToolTip('Step')
self.angle_step.setMinimum(0)
self.angle_step.setMaximum(90)
value = cmds.optionVar(query=SHAPE_PATH_ROTATION_STEP_ANGLE)
self.angle_step.setValue(value)
function = partial(save_optionvar, SHAPE_PATH_ROTATION_STEP_ANGLE)
self.angle_step.valueChanged.connect(function)
polygon = QtWidgets.QAction(
icon('polygon.png'), 'Create Polygon', self)
polygon.triggered.connect(self.create_polygon)
toggle = QtWidgets.QAction(icon('dock.png'), 'Dock/Undock', self)
toggle.triggered.connect(self.toggle_flag)
self.toolbar = QtWidgets.QToolBar()
self.toolbar.setIconSize(QtCore.QSize(18, 18))
self.toolbar.addAction(import_path)
self.toolbar.addAction(export_path)
self.toolbar.addSeparator()
self.toolbar.addAction(polygon)
self.toolbar.addAction(delete)
self.toolbar.addSeparator()
self.toolbar.addAction(smooth_tangent)
self.toolbar.addAction(break_tangent)
self.toolbar.addSeparator()
self.toolbar.addAction(hsymmetry)
self.toolbar.addAction(vsymmetry)
toolbar3 = QtWidgets.QHBoxLayout()
toolbar3.setContentsMargins(0, 0, 0, 0)
toolbar3.addWidget(center_path_button)
toolbar3.addStretch()
toolbar3.addWidget(QtWidgets.QLabel('Rotate: '))
toolbar3.addWidget(self.angle)
toolbar3.addWidget(self.angle_step)
self.toolbar2 = QtWidgets.QToolBar()
self.toolbar2.setIconSize(QtCore.QSize(18, 18))
self.toolbar2.addAction(toggle)
toolbars = QtWidgets.QHBoxLayout()
toolbars.setContentsMargins(0, 0, 0, 0)
toolbars.addWidget(self.toolbar)
toolbars.addStretch()
toolbars.addWidget(self.toolbar2)
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addLayout(toolbars)
layout.addWidget(self.canvas)
layout.addLayout(toolbar3)
def export_path(self):
directory = get_open_directory()
filename = QtWidgets.QFileDialog.getSaveFileName(
self, 'Export shape', directory, '*.dws')
if not filename[0]:
return
save_optionvar(LAST_OPEN_DIRECTORY, os.path.dirname(filename[0]))
with open(filename[0], 'w') as f:
json.dump(self.canvas.path, f, indent=2)
def import_path(self):
directory = get_open_directory()
filename = QtWidgets.QFileDialog.getOpenFileName(
self, 'Import shape', directory, '*.dws')
if not filename[0]:
return
save_optionvar(LAST_OPEN_DIRECTORY, os.path.dirname(filename[0]))
with open(filename[0], 'r') as f:
path = json.load(f)
self.canvas.set_path(path)
self.pathEdited.emit()
def start_rotate(self):
self.buffer_path = deepcopy(self.canvas.path)
if not self.canvas.selection:
self.rotate_center = (0, 0)
elif len(self.canvas.selection) == 1:
index = self.canvas.selection[0]
self.rotate_center = self.buffer_path[index]['point']
else:
point = self.canvas.manipulator.rect.center()
self.rotate_center = point.toTuple()
def end_rotate(self):
self.buffer_path = None
self.rotate_center = None
self.pathEdited.emit()
self.angle.blockSignals(True)
self.angle.setValue(0)
self.angle.blockSignals(False)
def rotate(self, value):
if self.buffer_path is None:
self.start_rotate()
step_size = self.angle_step.value()
value = round(value / step_size) * step_size
if 1 < len(self.canvas.selection):
path = deepcopy(self.buffer_path)
points = [self.buffer_path[i] for i in self.canvas.selection]
rotated_path = rotate_path(points, value, self.rotate_center)
for i, point in zip(self.canvas.selection, rotated_path):
path[i] = point
else:
path = rotate_path(self.buffer_path, value, self.rotate_center)
if path is None:
return
self.canvas.path = path
if self.canvas.selection:
points = [
QtCore.QPointF(*path[i]['point'])
for i in self.canvas.selection]
self.canvas.update_manipulator_rect(points)
self.canvas.update()
def create_polygon(self):
edges, result = QtWidgets.QInputDialog.getInt(
self, 'Polygon', 'Number of edges', value=3, minValue=3,
maxValue=25)
if not result:
return
path = create_polygon_path(radius=45, n=edges)
self.canvas.set_path(path)
self.pathEdited.emit()
def toggle_flag(self):
point = self.mapToGlobal(self.rect().topLeft())
state = not self.windowFlags() & QtCore.Qt.Tool
self.setWindowFlag(QtCore.Qt.Tool, state)
self.show()
if state:
self.resize(400, 400)
self.move(point)
self.canvas.focus()
def set_options(self, options):
if options is None:
self.canvas.set_path(None)
return
self.canvas.set_path(options['shape.path'] or [])
def path(self):
return self.canvas.path
def path_rect(self):
return get_global_rect(
[QtCore.QPointF(*p['point']) for p in self.canvas.path])
class PathEditorCanvas(QtWidgets.QWidget):
pathEdited = QtCore.Signal()
def __init__(self, parent=None):
super(PathEditorCanvas, self).__init__(parent)
self.viewportmapper = ViewportMapper()
self.viewportmapper.viewsize = self.size()
self.selection_square = SelectionSquare()
self.manipulator = Manipulator(self.viewportmapper)
self.transform = Transform()
self.selection = PointSelection()
self.interaction_manager = InteractionManager()
self.setMouseTracking(True)
self.path = []
def sizeHint(self):
return QtCore.QSize(300, 200)
def mousePressEvent(self, event):
if not self.path:
return
cursor = self.viewportmapper.to_units_coords(get_cursor(self))
self.transform.direction = self.manipulator.get_direction(event.pos())
self.current_action = self.get_action()
if self.current_action and self.current_action[0] == 'move point':
self.selection.set([self.current_action[1]])
self.update_manipulator_rect()
if self.manipulator.rect is not None:
self.transform.set_rect(self.manipulator.rect)
self.transform.reference_rect = QtCore.QRectF(self.manipulator.rect)
self.transform.set_reference_point(cursor)
has_shape_hovered = bool(self.current_action)
self.interaction_manager.update(
event, pressed=True,
has_shape_hovered=has_shape_hovered,
dragging=has_shape_hovered)
self.selection_square.clicked(cursor)
self.update()
def get_action(self):
if not self.path:
return
cursor = self.viewportmapper.to_units_coords(get_cursor(self))
if self.manipulator.rect and self.manipulator.rect.contains(cursor):
return 'move points', None
direction = self.manipulator.get_direction(get_cursor(self))
if direction:
return 'resize points', direction
tolerance = self.viewportmapper.to_units(5)
for i, data in enumerate(self.path):
point = QtCore.QPointF(*data['point'])
if distance(point, cursor) < tolerance:
return 'move point', i
if data['tangent_in']:
point = QtCore.QPointF(*data['tangent_in'])
if distance(point, cursor) < tolerance:
return 'move in', i
if data['tangent_out']:
point = QtCore.QPointF(*data['tangent_out'])
if distance(point, cursor) < tolerance:
return 'move out', i
index = is_point_on_path_edge(self.path, cursor, tolerance)
if index is not None:
return 'create point', index
def mouseMoveEvent(self, event):
if not self.path:
return
cursor = self.viewportmapper.to_units_coords(get_cursor(self)).toPoint()
if self.interaction_manager.mode == InteractionManager.NAVIGATION:
offset = self.interaction_manager.mouse_offset(event.pos())
if offset is not None:
origin = self.viewportmapper.origin - offset
self.viewportmapper.origin = origin
elif self.interaction_manager.mode == InteractionManager.SELECTION:
self.selection_square.handle(cursor)
elif self.interaction_manager.mode == InteractionManager.DRAGGING:
if not self.current_action:
return self.update()
offset = self.interaction_manager.mouse_offset(event.pos())
if not offset:
return self.update()
offset = QtCore.QPointF(
self.viewportmapper.to_units(offset.x()),
self.viewportmapper.to_units(offset.y()))
if self.current_action[0] == 'move points':
offset_path(self.path, offset, self.selection)
self.update_manipulator_rect()
elif self.current_action[0] == 'resize points':
resize_rect_with_direction(
self.transform.rect, cursor,
self.transform.direction)
path = (
[self.path[i] for i in self.selection] if
self.selection else self.path)
resize_path_with_reference(
path,
self.transform.reference_rect,
self.transform.rect)
rect = self.transform.rect
self.transform.reference_rect.setTopLeft(rect.topLeft())
self.transform.reference_rect.setSize(rect.size())
self.manipulator.set_rect(self.transform.rect)
elif self.current_action[0] == 'move point':
offset_path(self.path, offset, [self.current_action[1]])
elif self.current_action and self.current_action[0] == 'move in':
move_tangent(
point=self.path[self.current_action[1]],
tangent_in_moved=True,
offset=offset,
lock=not self.interaction_manager.ctrl_pressed)
elif self.current_action[0] == 'move out':
move_tangent(
point=self.path[self.current_action[1]],
tangent_in_moved=False,
offset=offset,
lock=not self.interaction_manager.ctrl_pressed)
elif self.current_action[0] == 'create point':
self.interaction_manager.mouse_offset(event.pos())
point = {
'point': [cursor.x(), cursor.y()],
'tangent_in': None,
'tangent_out': None}
index = self.current_action[1] + 1
self.path.insert(index, point)
self.autotangent(index)
self.current_action = 'move point', index
self.selection.set([index])
self.update_manipulator_rect()
self.update()
def move_point(self, i, offset):
self.path[i]['point'][0] += offset.x()
self.path[i]['point'][1] += offset.y()
point = self.path[i]['tangent_in']
if point:
point[0] += offset.x()
point[1] += offset.y()
point = self.path[i]['tangent_out']
if point:
point[0] += offset.x()
point[1] += offset.y()
def mouseReleaseEvent(self, event):
if not self.path:
return
if self.current_action:
self.pathEdited.emit()
if self.interaction_manager.mode == InteractionManager.SELECTION:
self.select()
self.selection_square.release()
self.interaction_manager.update(
event, pressed=False, has_shape_hovered=False, dragging=False)
self.update()
def select(self):
shift = self.interaction_manager.shift_pressed
ctrl = self.interaction_manager.ctrl_pressed
self.selection.mode = get_selection_mode(shift=shift, ctrl=ctrl)
rect = self.selection_square.rect
points = []
indexes = []
for i, p in enumerate(self.path):
point = QtCore.QPointF(*p['point'])
if rect.contains(point):
indexes.append(i)
points.append(point)
self.selection.set(indexes)
self.update_manipulator_rect()
def update_manipulator_rect(self, points=None):
if points is None:
points = [
QtCore.QPointF(*self.path[i]['point'])
for i in self.selection]
if len(points) < 2:
self.manipulator.set_rect(None)
return
rect = get_global_rect(points)
rect.setHeight(max(rect.height(), .5))
rect.setWidth(max(rect.width(), .5))
self.manipulator.set_rect(rect)
def wheelEvent(self, event):
# To center the zoom on the mouse, we save a reference mouse position
# and compare the offset after zoom computation.
factor = .25 if event.angleDelta().y() > 0 else -.25
self.zoom(factor, event.pos())
self.update()
def resizeEvent(self, event):
self.viewportmapper.viewsize = self.size()
size = (event.size() - event.oldSize()) / 2
offset = QtCore.QPointF(size.width(), size.height())
self.viewportmapper.origin -= offset
self.update()
def zoom(self, factor, reference):
abspoint = self.viewportmapper.to_units_coords(reference)
if factor > 0:
self.viewportmapper.zoomin(abs(factor))
else:
self.viewportmapper.zoomout(abs(factor))
relcursor = self.viewportmapper.to_viewport_coords(abspoint)
vector = relcursor - reference
self.viewportmapper.origin = self.viewportmapper.origin + vector
def paintEvent(self, event):
if not self.path:
return
try:
painter = QtGui.QPainter(self)
painter.setPen(QtGui.QPen())
color = QtGui.QColor('black')
color.setAlpha(50)
painter.setBrush(color)
rect = QtCore.QRect(
0, 0, self.rect().width() - 1, self.rect().height() - 1)
painter.drawRect(rect)
draw_world_coordinates(
painter, self.rect(), QtGui.QColor('#282828'),
self.viewportmapper)
painter.setBrush(QtGui.QBrush())
draw_shape_path(
painter, self.path, self.selection, self.viewportmapper)
draw_tangents(painter, self.path, self.viewportmapper)
if self.selection_square.rect:
draw_selection_square(
painter, self.selection_square.rect, self.viewportmapper)
conditions = (
self.manipulator.rect is not None and
all(self.manipulator.viewport_handlers()))
if conditions:
draw_manipulator(
painter, self.manipulator,
get_cursor(self), self.viewportmapper)
finally:
painter.end()
def center_path(self):
qpath = path_to_qpath(self.path, ViewportMapper())
center = qpath.boundingRect().center()
offset_path(self.path, -center)
self.pathEdited.emit()
self.update_manipulator_rect()
self.update()
def delete(self):
if len(self.path) - len(self.selection) < 3:
return QtWidgets.QMessageBox.critical(
self, 'Error', 'Shape must at least contains 3 control points')
for i in sorted(self.selection, reverse=True):
del self.path[i]
self.selection.clear()
self.update_manipulator_rect()
self.pathEdited.emit()
self.update()
def break_tangents(self):
for i in self.selection:
self.path[i]['tangent_in'] = None
self.path[i]['tangent_out'] = None
self.pathEdited.emit()
self.update()
def smooth_tangents(self):
if not self.selection:
return
for i in self.selection:
self.autotangent(i)
self.update()
self.pathEdited.emit()
def autotangent(self, i):
point = self.path[i]['point']
next_index = i + 1 if i < (len(self.path) - 1) else 0
next_point = self.path[next_index]['point']
previous_point = self.path[i - 1]['point']
tan_in, tan_out = auto_tangent(point, previous_point, next_point)
self.path[i]['tangent_in'] = tan_in
self.path[i]['tangent_out'] = tan_out
def set_path(self, path):
self.path = path
self.selection.clear()
self.manipulator.set_rect(None)
self.focus()
def focus(self):
if not self.path:
self.update()
return
points = [QtCore.QPointF(*p['point']) for p in self.path]
rect = get_global_rect(points)
self.viewportmapper.focus(grow_rect(rect, 15))
self.update()
def symmetry(self, horizontal=True):
path = (
[self.path[i] for i in self.selection] if
self.selection else self.path)
if self.manipulator.rect:
center = self.manipulator.rect.center()
else:
points = [QtCore.QPointF(*p['point']) for p in self.path]
rect = get_global_rect(points)
center = rect.center()
path_symmetry(path, center, horizontal=horizontal)
self.pathEdited.emit()
self.update()
class PointSelection():
def __init__(self):
self.elements = []
self.mode = 'replace'
def set(self, elements):
if self.mode == 'add':
if elements is None:
return
return self.add(elements)
elif self.mode == 'replace':
if elements is None:
return self.clear()
return self.replace(elements)
elif self.mode == 'invert':
if elements is None:
return
return self.invert(elements)
elif self.mode == 'remove':
if elements is None:
return
for element in elements:
if element in self.elements:
self.remove(element)
def replace(self, elements):
self.elements = elements
def add(self, elements):
self.elements.extend([e for e in elements if e not in self])
def remove(self, element):
self.elements.remove(element)
def invert(self, elements):
for element in elements:
if element not in self.elements:
self.add([element])
else:
self.remove(element)
def clear(self):
self.elements = []
def __len__(self):
return len(self.elements)
def __bool__(self):
return bool(self.elements)
__nonzero__ = __bool__
def __getitem__(self, i):
return self.elements[i]
def __iter__(self):
return self.elements.__iter__()
def path_to_qpath(path, viewportmapper):
painter_path = QtGui.QPainterPath()
start = QtCore.QPointF(*path[0]['point'])
painter_path.moveTo(viewportmapper.to_viewport_coords(start))
for i in range(len(path)):
point = path[i]
point2 = path[i + 1 if i + 1 < len(path) else 0]
c1 = QtCore.QPointF(*(point['tangent_out'] or point['point']))
c2 = QtCore.QPointF(*(point2['tangent_in'] or point2['point']))
end = QtCore.QPointF(*point2['point'])
painter_path.cubicTo(
viewportmapper.to_viewport_coords(c1),
viewportmapper.to_viewport_coords(c2),
viewportmapper.to_viewport_coords(end))
return painter_path
def draw_shape_path(painter, path, selection, viewportmapper):
painter.setPen(QtCore.Qt.gray)
painter.drawPath(path_to_qpath(path, viewportmapper))
rect = QtCore.QRectF(0, 0, 5, 5)
for i, point in enumerate(path):
center = QtCore.QPointF(*point['point'])
rect.moveCenter(viewportmapper.to_viewport_coords(center))
painter.setBrush(QtCore.Qt.white if i in selection else QtCore.Qt.NoBrush)
painter.drawRect(rect)
def is_point_on_path_edge(path, cursor, tolerance=3):
stroker = QtGui.QPainterPathStroker()
stroker.setWidth(tolerance * 2)
for i in range(len(path)):
point = path[i]
painter_path = QtGui.QPainterPath()
painter_path.moveTo(QtCore.QPointF(*point['point']))
point2 = path[i + 1 if i + 1 < len(path) else 0]
c1 = QtCore.QPointF(*(point['tangent_out'] or point['point']))
c2 = QtCore.QPointF(*(point2['tangent_in'] or point2['point']))
end = QtCore.QPointF(*point2['point'])
painter_path.cubicTo(c1, c2, end)
stroke = stroker.createStroke(painter_path)
if stroke.contains(cursor):
return i
return None
def move_tangent(point, tangent_in_moved, offset, lock):
center_point = point['point']
tangent_in = point['tangent_in' if tangent_in_moved else 'tangent_out']
tangent_out = point['tangent_out' if tangent_in_moved else 'tangent_in']
offset = offset.x(), offset.y()
tangent_in, tangent_out = offset_tangent(
tangent_in, tangent_out, center_point, offset, lock)
point['tangent_in'if tangent_in_moved else 'tangent_out'] = tangent_in
point['tangent_out'if tangent_in_moved else 'tangent_in'] = tangent_out
if __name__ == '__main__':
try:
se.close()
except:
pass
from ..qtutils import maya_main_window
se = PathEditor(maya_main_window())
se.setWindowFlags(QtCore.Qt.Window)
se.show()