1516 lines
63 KiB
Python
1516 lines
63 KiB
Python
"""
|
|
Module contains classes needed to simulate garments from patterns in Maya.
|
|
"""
|
|
# Basic
|
|
from functools import partial
|
|
import copy
|
|
import errno
|
|
import numpy as np
|
|
import os
|
|
import time
|
|
from importlib import reload
|
|
|
|
# Maya
|
|
from maya import cmds
|
|
from maya import OpenMaya
|
|
|
|
# Arnold
|
|
import mtoa.utils as mutils
|
|
from mtoa.cmds.arnoldRender import arnoldRender
|
|
import mtoa.core
|
|
|
|
# My modules
|
|
import pygarment.pattern.core as core
|
|
import pygarment.pattern.wrappers as wrappers
|
|
from pygarment.pattern.utils import vector_angle, rel_to_abs_2d
|
|
from pygarment.mayaqltools import qualothwrapper as qw
|
|
from pygarment.mayaqltools import utils
|
|
reload(core)
|
|
reload(wrappers)
|
|
reload(qw)
|
|
reload(utils)
|
|
|
|
class PatternLoadingError(BaseException):
|
|
"""To be rised when a pattern cannot be loaded correctly to 3D"""
|
|
pass
|
|
|
|
class MayaGarment(wrappers.VisPattern):
|
|
"""
|
|
Extends a pattern specification in custom JSON format to work with Maya
|
|
Input:
|
|
* Pattern template in custom JSON format
|
|
* import panel to Maya scene TODO
|
|
* cleaning imported stuff TODO
|
|
* Basic operations on panels in Maya TODO
|
|
"""
|
|
def __init__(self, pattern_file, clean_on_die=False):
|
|
super(MayaGarment, self).__init__(pattern_file)
|
|
self.self_clean = clean_on_die
|
|
|
|
self.last_verts = None
|
|
self.current_verts = None
|
|
self.loaded_to_maya = False
|
|
self.obstacles = []
|
|
self.shader_group = None
|
|
self.MayaObjects = {}
|
|
self.config = {
|
|
'material': {},
|
|
'body_friction': 0.5,
|
|
'resolution_scale': 5
|
|
}
|
|
|
|
def __del__(self):
|
|
"""Remove Maya objects when dying"""
|
|
if self.self_clean:
|
|
self.clean(True)
|
|
|
|
# ------ Basic operations ------
|
|
def load(self, obstacles=[], shader_group=None, config={}, parent_group=None):
|
|
"""
|
|
Loads current pattern to Maya as simulatable garment.
|
|
If already loaded, cleans previous geometry & reloads
|
|
config should contain info on fabric matereials & body_friction (collider friction) if provided
|
|
"""
|
|
if self.is_self_intersecting():
|
|
# supplied pattern with self-intersecting panels -- it's likely to crash Maya
|
|
raise PatternLoadingError('{}::{}::Provided pattern has self-intersecting panels. Nothing is loaded'.format(
|
|
self.__class__.__name__, self.name))
|
|
|
|
if self.loaded_to_maya:
|
|
# save the latest sim info
|
|
self.fetchSimProps()
|
|
self.clean(True)
|
|
|
|
# Normal flow produces garbage warnings of parenting from Maya. Solution suggestion didn't work, so I just live with them
|
|
self.load_panels(parent_group)
|
|
self.stitch_panels()
|
|
self.loaded_to_maya = True
|
|
|
|
self.setShaderGroup(shader_group)
|
|
|
|
self.add_colliders(obstacles)
|
|
self._setSimProps(config)
|
|
|
|
# should be done on the mesh after stitching, res adjustment, but before sim & clean-up
|
|
self._eval_vertex_segmentation()
|
|
|
|
# remove the junk after garment was stitched and labeled
|
|
self._clean_mesh()
|
|
|
|
print('Garment ' + self.name + ' is loaded to Maya')
|
|
|
|
def load_panels(self, parent_group=None):
|
|
"""Load panels to Maya as curve collection & geometry objects.
|
|
Groups them by panel and by pattern"""
|
|
# top group
|
|
group_name = cmds.group(em=True, n=self.name) # emrty at first
|
|
if parent_group is not None:
|
|
group_name = cmds.parent(group_name, parent_group)
|
|
self.MayaObjects['pattern'] = group_name
|
|
|
|
# Load panels as curves
|
|
self.MayaObjects['panels'] = {}
|
|
for panel_name in self.pattern['panels']:
|
|
panel_maya = self._load_panel(panel_name, group_name)
|
|
|
|
def stitch_panels(self):
|
|
"""
|
|
Create seams between qualoth panels.
|
|
Assumes that panels are already loadeded (as curves).
|
|
Assumes that after stitching every pattern becomes a single piece of geometry
|
|
Returns
|
|
Qulaoth cloth object name
|
|
"""
|
|
self.MayaObjects['stitches'] = []
|
|
for stitch in self.pattern['stitches']:
|
|
stitch_id = qw.qlCreateSeam(
|
|
self._maya_curve_name(stitch[0]),
|
|
self._maya_curve_name(stitch[1]))
|
|
stitch_id = cmds.parent(stitch_id, self.MayaObjects['pattern']) # organization
|
|
self.MayaObjects['stitches'].append(stitch_id[0])
|
|
|
|
# after stitching, only one cloth\cloth shape object per pattern is left -- move up the hierarechy
|
|
children = cmds.listRelatives(self.MayaObjects['pattern'], ad=True)
|
|
cloths = [obj for obj in children if 'qlCloth' in obj]
|
|
cmds.parent(cloths, self.MayaObjects['pattern'])
|
|
|
|
def setShaderGroup(self, shader_group=None):
|
|
"""
|
|
Sets material properties for the cloth object created from current panel
|
|
"""
|
|
if not self.loaded_to_maya:
|
|
raise RuntimeError(
|
|
'MayaGarmentError::Pattern is not yet loaded. Cannot set shader')
|
|
|
|
if shader_group is not None: # use previous othervise
|
|
self.shader_group = shader_group
|
|
|
|
if self.shader_group is not None:
|
|
cmds.sets(self.get_qlcloth_geomentry(), forceElement=self.shader_group)
|
|
|
|
def save_mesh(self, folder='', tag='sim'):
|
|
"""
|
|
Saves cloth as obj file and its per vertex segmentation to a given folder or
|
|
to the folder with the pattern if not given.
|
|
"""
|
|
if not self.loaded_to_maya:
|
|
print('MayaGarmentWARNING::Pattern is not yet loaded. Nothing saved')
|
|
return
|
|
|
|
if folder:
|
|
filepath = folder
|
|
else:
|
|
filepath = self.path
|
|
self._save_to_path(filepath, self.name + '_' + tag)
|
|
|
|
def sim_caching(self, caching=True):
|
|
"""Toggles the caching of simulation steps to garment folder"""
|
|
if caching:
|
|
# create folder
|
|
self.cache_path = os.path.join(self.path, self.name + '_simcache')
|
|
try:
|
|
os.makedirs(self.cache_path)
|
|
except OSError as exc:
|
|
if exc.errno != errno.EEXIST: # ok if directory exists
|
|
raise
|
|
pass
|
|
else:
|
|
# disable caching
|
|
self.cache_path = ''
|
|
|
|
def clean(self, delete=False):
|
|
""" Hides/removes the garment from Maya scene
|
|
NOTE all of the maya ids assosiated with the garment become invalidated,
|
|
if delete flag is True
|
|
"""
|
|
if self.loaded_to_maya:
|
|
# Remove from simulation
|
|
cmds.setAttr(self.get_qlcloth_props_obj() + '.active', 0)
|
|
|
|
if delete:
|
|
print('MayaGarment::Deleting {}'.format(self.MayaObjects['pattern']))
|
|
|
|
# Clean solver cache properly
|
|
solver = qw.findSolver()
|
|
if solver:
|
|
qw.qlReinitSolver(self.get_qlcloth_props_obj(), solver)
|
|
|
|
cmds.delete(self.MayaObjects['pattern'])
|
|
qw.deleteSolver()
|
|
|
|
if 'segm_node' in self.MayaObjects and self.MayaObjects['segm_node']:
|
|
cmds.delete(self.MayaObjects['segm_node'])
|
|
|
|
self.loaded_to_maya = False
|
|
self.MayaObjects = {} # clean
|
|
else:
|
|
cmds.hide(self.MayaObjects['pattern'])
|
|
|
|
# do nothing if not loaded -- already clean =)
|
|
|
|
def display_vertex_segmentation(self, shader):
|
|
"""
|
|
Color every vertes of the garment according to the panel is belongs to
|
|
(as indicated in self.vertex_labels)
|
|
"""
|
|
# group vertices by label (it's faster then coloring one-by-one)
|
|
vertex_select_lists = dict.fromkeys(self.panel_order() + ['stitch', 'Other'])
|
|
for key in vertex_select_lists:
|
|
vertex_select_lists[key] = []
|
|
|
|
for vert_idx in range(len(self.current_verts)):
|
|
str_label = self.vertex_labels[vert_idx]
|
|
if str_label not in self.panel_order() and str_label != 'stitch':
|
|
str_label = 'Other'
|
|
|
|
vert_addr = '{}.vtx[{}]'.format(self.get_qlcloth_geomentry(), vert_idx)
|
|
vertex_select_lists[str_label].append(vert_addr)
|
|
|
|
# Contrasting Panel Coloring for visualization
|
|
# https://www.schemecolor.com/bright-rainbow-colors.php
|
|
# color_hex = ['FF0900', 'FF7F00', 'FFEF00', '00F11D', '0079FF', 'A800FF']
|
|
# color_hex = ['FCE6DB', 'FADBD4', 'F5C4C7', 'EDB2C9', 'B7A9CC', 'E6CCE8'] # teenage fever + 0.65
|
|
#color_hex = ['FAD2FC', 'CCCCFC', 'E0ECFF', 'FFFEED', 'FFEBC9', 'FFC4CD'] # love you mom
|
|
# color_hex = ['F6D9E2', 'CACADD', 'FEEDE4', 'F8D6CB', 'D2C7C2'] # Winter season
|
|
#color_hex = ['95DBD9', '8CB1D8', 'A186BD', 'E698C2', 'FBE7A1'] # Love and hope
|
|
# color_hex = ['FCEFE3', 'C0C8D5', '9DA9C4', '72638F', 'BA869F', 'D1ABB6'] # Pastel Lust + 0.85
|
|
color_hex = ['8594AB', '9FBBBA', 'FACDA7', 'EDA19D', 'CF8299', '8B6B96', 'A4DE87'] # CRAZY SITUATION + 0.85 https://www.schemecolor.com/crazy-situation.php
|
|
color_list = np.empty((len(color_hex), 3))
|
|
for idx in range(len(color_hex)):
|
|
color_list[idx] = np.array([int(color_hex[idx][i:i + 2], 16) for i in (0, 2, 4)]) / 255.0
|
|
|
|
start_time = time.time()
|
|
for label, str_label in enumerate(vertex_select_lists.keys()):
|
|
if len(vertex_select_lists[str_label]) > 0: # 'Other' may not be present at all
|
|
if str_label == 'Other': # non-segmented becomes white
|
|
color = np.ones(3)
|
|
elif str_label == 'stitch': # stitches are black
|
|
color = np.zeros(3)
|
|
else:
|
|
# color selection with expansion if the list is too small
|
|
factor, color_id = (label // len(color_list)) + 1, label % len(color_list)
|
|
color = color_list[color_id] * 0.9 # / factor # gets darker the more labels there are
|
|
|
|
# color corresponding vertices
|
|
cmds.select(clear=True)
|
|
cmds.select(vertex_select_lists[str_label])
|
|
cmds.polyColorPerVertex(rgb=color.tolist())
|
|
|
|
cmds.select(clear=True)
|
|
|
|
cmds.setAttr(self.get_qlcloth_geomentry() + '.displayColors', 1)
|
|
cmds.setAttr(self.get_qlcloth_geomentry() + '.aiExportColors', 1)
|
|
|
|
# Add a color node to allow rendering in Arnold
|
|
cmds.select(self.get_qlcloth_geomentry())
|
|
color_set = cmds.polyColorSet( query=True, currentColorSet=True )
|
|
|
|
data_node = cmds.createNode('aiUserDataColor')
|
|
|
|
self.MayaObjects['segm_node'] = data_node
|
|
|
|
# Connect with shader
|
|
color = cmds.getAttr(shader + '.color')[0]
|
|
|
|
cmds.setAttr((data_node + '.default'), color[0], color[1], color[2], type='double3')
|
|
|
|
print('INFO::Segmentation Color Set!')
|
|
|
|
cmds.connectAttr((data_node + '.outColor'), (shader + '.color')) # Assume Lambert shader
|
|
cmds.setAttr((data_node + '.attribute'), color_set[0], type="string")
|
|
|
|
cmds.refresh()
|
|
|
|
# ------ Simulation ------
|
|
def add_colliders(self, obstacles=[]):
|
|
"""
|
|
Adds given Maya objects as colliders of the garment
|
|
"""
|
|
if not self.loaded_to_maya:
|
|
raise RuntimeError(
|
|
'MayaGarmentError::Pattern is not yet loaded. Cannot load colliders')
|
|
if obstacles: # if not given, use previous ones
|
|
self.obstacles = obstacles
|
|
|
|
if 'colliders' not in self.MayaObjects:
|
|
self.MayaObjects['colliders'] = []
|
|
for obj in self.obstacles:
|
|
collider = qw.qlCreateCollider(
|
|
self.get_qlcloth_geomentry(),
|
|
obj
|
|
)
|
|
# apply current friction settings
|
|
qw.setColliderFriction(collider, self.config['body_friction'])
|
|
# organize object tree
|
|
collider = cmds.parent(collider, self.MayaObjects['pattern'])
|
|
self.MayaObjects['colliders'].append(collider)
|
|
|
|
def fetchSimProps(self):
|
|
"""Fetch garment material & body friction from Maya settings"""
|
|
if not self.loaded_to_maya:
|
|
raise RuntimeError('MayaGarmentError::Pattern is not yet loaded.')
|
|
|
|
self.config['material'] = qw.fetchFabricProps(self.get_qlcloth_props_obj())
|
|
if 'colliders' in self.MayaObjects and self.MayaObjects['colliders']:
|
|
# assuming all colliders have the same value
|
|
friction = qw.fetchColliderFriction(self.MayaObjects['colliders'][0])
|
|
if friction:
|
|
self.config['body_friction'] = friction
|
|
|
|
self.config['collision_thickness'] = cmds.getAttr(self.get_qlcloth_props_obj() + '.thickness')
|
|
|
|
# take resolution scale from any of the panels assuming all the same
|
|
self.config['resolution_scale'] = qw.fetchPanelResolution()
|
|
|
|
return self.config
|
|
|
|
def update_verts_info(self):
|
|
"""
|
|
Retrieves current vertex positions from Maya & updates the last state.
|
|
For best performance, should be called on each iteration of simulation
|
|
Assumes the object is already loaded & stitched
|
|
"""
|
|
if not self.loaded_to_maya:
|
|
raise RuntimeError(
|
|
'MayaGarmentError::Pattern is not yet loaded. Cannot update verts info')
|
|
|
|
# working with meshes http://www.fevrierdorian.com/blog/post/2011/09/27/Quickly-retrieve-vertex-positions-of-a-Maya-mesh-%28English-Translation%29
|
|
cloth_dag = self.get_qlcloth_geom_dag()
|
|
|
|
mesh = OpenMaya.MFnMesh(cloth_dag)
|
|
|
|
vertices = utils.get_vertices_np(mesh)
|
|
|
|
self.last_verts = self.current_verts
|
|
self.current_verts = vertices
|
|
|
|
def cache_if_enabled(self, frame):
|
|
"""If caching is enabled -> saves current geometry to cache folder
|
|
Does nothing otherwise """
|
|
if not self.loaded_to_maya:
|
|
print('MayaGarmentWARNING::Pattern is not yet loaded. Nothing cached')
|
|
return
|
|
|
|
if hasattr(self, 'cache_path') and self.cache_path:
|
|
self._save_to_path(self.cache_path, self.name + '_{:04d}'.format(frame))
|
|
|
|
# ------ Qualoth objects ------
|
|
def get_qlcloth_geomentry(self):
|
|
"""
|
|
Find the first Qualoth cloth geometry object belonging to current pattern
|
|
"""
|
|
if not self.loaded_to_maya:
|
|
raise RuntimeError('MayaGarmentError::Pattern is not yet loaded.')
|
|
|
|
if 'qlClothOut' not in self.MayaObjects:
|
|
children = cmds.listRelatives(self.MayaObjects['pattern'], ad=True)
|
|
cloths = [obj for obj in children
|
|
if 'qlCloth' in obj and 'Out' in obj and 'Shape' not in obj]
|
|
self.MayaObjects['qlClothOut'] = cloths[0]
|
|
|
|
return self.MayaObjects['qlClothOut']
|
|
|
|
def get_qlcloth_props_obj(self):
|
|
"""
|
|
Find the first qlCloth object belonging to current pattern
|
|
"""
|
|
if not self.loaded_to_maya:
|
|
raise RuntimeError('MayaGarmentError::Pattern is not yet loaded.')
|
|
|
|
if 'qlCloth' not in self.MayaObjects:
|
|
children = cmds.listRelatives(self.MayaObjects['pattern'], ad=True)
|
|
cloths = [obj for obj in children
|
|
if 'qlCloth' in obj and 'Out' not in obj and 'Shape' in obj]
|
|
self.MayaObjects['qlCloth'] = cloths[0]
|
|
|
|
return self.MayaObjects['qlCloth']
|
|
|
|
def get_qlcloth_geom_dag(self):
|
|
"""
|
|
returns DAG reference to cloth shape object
|
|
"""
|
|
if not self.loaded_to_maya:
|
|
raise RuntimeError('MayaGarmentError::Pattern is not yet loaded.')
|
|
|
|
if 'shapeDAG' not in self.MayaObjects:
|
|
self.MayaObjects['shapeDAG'] = utils.get_dag(self.get_qlcloth_geomentry())
|
|
|
|
return self.MayaObjects['shapeDAG']
|
|
|
|
# ------ Geometry Checks ------
|
|
def is_static(self, threshold, allowed_non_static_percent=0):
|
|
"""
|
|
Checks wether garment is in the static equilibrium
|
|
Compares current state with the last recorded state
|
|
"""
|
|
if not self.loaded_to_maya:
|
|
raise RuntimeError(
|
|
'MayaGarmentError::Pattern is not yet loaded. Cannot check static')
|
|
|
|
if self.last_verts is None: # first iteration
|
|
return False
|
|
|
|
# Compare L1 norm per vertex
|
|
# Checking vertices change is the same as checking if velocity is zero
|
|
diff = np.abs(self.current_verts - self.last_verts)
|
|
diff_L1 = np.sum(diff, axis=1)
|
|
|
|
non_static_len = len(diff_L1[diff_L1 > threshold]) # compare vertex-wize to allow accurate control over outliers
|
|
|
|
if non_static_len == 0 or non_static_len < len(self.current_verts) * 0.01 * allowed_non_static_percent:
|
|
print('\nStatic with {} non-static vertices out of {}'.format(non_static_len, len(self.current_verts)))
|
|
return True, non_static_len
|
|
else:
|
|
return False, non_static_len
|
|
|
|
def intersect_colliders_3D(self, obstacles=[]):
|
|
"""Checks wheter garment intersects given obstacles or its colliders if obstacles are not given
|
|
Returns True if intersections found
|
|
|
|
Having intersections may disrupt simulation result although it seems to recover from some of those
|
|
"""
|
|
if not self.loaded_to_maya:
|
|
raise RuntimeError('Garment is not yet loaded: cannot check for intersections')
|
|
|
|
if not obstacles:
|
|
obstacles = self.obstacles
|
|
|
|
print('Garment::3D Penetration checks')
|
|
|
|
# check intersection with colliders
|
|
for obj in obstacles:
|
|
intersecting = self._intersect_object(obj)
|
|
|
|
if intersecting:
|
|
return True
|
|
|
|
return False
|
|
|
|
def self_intersect_3D(self, verbose=False):
|
|
"""Checks wheter currently loaded garment geometry intersects itself
|
|
Unline boolOp, check is non-invasive and do not require garment reload or copy.
|
|
|
|
Having intersections may disrupt simulation result although it seems to recover from some of those
|
|
"""
|
|
if not self.loaded_to_maya:
|
|
raise RuntimeError(
|
|
'MayaGarmentError::Pattern is not yet loaded. Cannot check geometry self-intersection')
|
|
|
|
# It turns out that OpenMaya python reference has nothing to do with reality of passing argument:
|
|
# most of the functions I use below are to be treated as wrappers of c++ API
|
|
# https://help.autodesk.com/view/MAYAUL/2018//ENU/?guid=__cpp_ref_class_m_fn_mesh_html
|
|
|
|
mesh, cloth_dag = utils.get_mesh_dag(self.get_qlcloth_geomentry())
|
|
|
|
vertices = OpenMaya.MPointArray()
|
|
mesh.getPoints(vertices, OpenMaya.MSpace.kWorld)
|
|
|
|
# use ray intersect with all edges of current mesh & the mesh itself
|
|
num_edges = mesh.numEdges()
|
|
accelerator = mesh.autoUniformGridParams()
|
|
num_hits = 0
|
|
for edge_id in range(num_edges):
|
|
# Vertices that comprise an edge
|
|
vtx1, vtx2 = utils.edge_vert_ids(mesh, edge_id)
|
|
|
|
# test intersection
|
|
raySource = OpenMaya.MFloatPoint(vertices[vtx1])
|
|
rayDir = OpenMaya.MFloatVector(vertices[vtx2] - vertices[vtx1])
|
|
hit, hitFaces, hitPoints, _ = utils.test_ray_intersect(mesh, raySource, rayDir, accelerator, return_info=True)
|
|
|
|
if not hit:
|
|
continue
|
|
|
|
# Since edge is on the mesh, we have tons of false hits
|
|
# => check if current edge is adjusent to hit faces: if shares a vertex
|
|
for face_id in range(hitFaces.length()):
|
|
face_verts = OpenMaya.MIntArray()
|
|
mesh.getPolygonVertices(hitFaces[face_id], face_verts)
|
|
face_verts = [face_verts[j] for j in range(face_verts.length())]
|
|
|
|
if vtx1 not in face_verts and vtx2 not in face_verts:
|
|
# hit face is not adjacent to the edge => real hit
|
|
if verbose:
|
|
print('Hit point: {}, {}, {}'.format(hitPoints[face_id][0], hitPoints[face_id][1], hitPoints[face_id][2]))
|
|
num_hits += 1
|
|
|
|
if num_hits == 0: # no intersections -- no need for threshold check
|
|
print('{} is not self-intersecting'.format(self.name))
|
|
return False
|
|
|
|
if ('self_intersect_hit_threshold' in self.config
|
|
and num_hits > self.config['self_intersect_hit_threshold']
|
|
or num_hits > 0 and 'self_intersect_hit_threshold' not in self.config): # non-zero hit if no threshold provided
|
|
print('{} is self-intersecting with {} intersect edges -- above threshold {}'.format(
|
|
self.name, num_hits,
|
|
self.config['self_intersect_hit_threshold'] if 'self_intersect_hit_threshold' in self.config else 0))
|
|
return True
|
|
else:
|
|
print('{} is self-intersecting with {} intersect edges -- ignored by threshold {}'.format(
|
|
self.name, num_hits,
|
|
self.config['self_intersect_hit_threshold'] if 'self_intersect_hit_threshold' in self.config else 0))
|
|
# no need to reload -- non-invasive checks
|
|
return False
|
|
|
|
# ------ ~Private -------
|
|
def _load_panel(self, panel_name, pattern_group=None):
|
|
"""
|
|
Loads curves constituting given panel to Maya.
|
|
Goups them per panel
|
|
"""
|
|
panel = self.pattern['panels'][panel_name]
|
|
vertices = np.asarray(panel['vertices'])
|
|
self.MayaObjects['panels'][panel_name] = {}
|
|
self.MayaObjects['panels'][panel_name]['edges'] = []
|
|
|
|
# top panel group
|
|
panel_group = cmds.group(n=panel_name, em=True)
|
|
if pattern_group is not None:
|
|
panel_group = cmds.parent(panel_group, pattern_group)[0]
|
|
self.MayaObjects['panels'][panel_name]['group'] = panel_group
|
|
|
|
# draw edges
|
|
curve_names = []
|
|
for edge in panel['edges']:
|
|
|
|
if ('curvature' not in edge
|
|
or isinstance(edge['curvature'], list)
|
|
or edge['curvature']['type'] != 'circle'):
|
|
# NOTE Legacy curvature representation
|
|
curve_points = self._edge_as_3d_tuple_list(edge, vertices)
|
|
curve = cmds.curve(p=curve_points, d=min(len(curve_points) - 1, 3))
|
|
else:
|
|
curve = self._draw_circle_arc(edge, vertices)
|
|
curve_names.append(curve)
|
|
self.MayaObjects['panels'][panel_name]['edges'].append(curve)
|
|
# Group
|
|
curve_group = cmds.group(curve_names, n=panel_name + '_curves')
|
|
curve_group = cmds.parent(curve_group, panel_group)[0]
|
|
self.MayaObjects['panels'][panel_name]['curve_group'] = curve_group
|
|
# 3D placemement
|
|
self._apply_panel_3d_placement(panel_name)
|
|
|
|
# Create geometry
|
|
panel_geom = qw.qlCreatePattern(curve_group)
|
|
# take out the solver node -- created only once per scene, no need to store
|
|
solvers = [obj for obj in panel_geom if 'Solver' in obj]
|
|
panel_geom = list(set(panel_geom) - set(solvers))
|
|
panel_geom = cmds.parent(panel_geom, panel_group) # organize
|
|
|
|
# TODO Compare the norm of loaded pattern with our custom
|
|
# evaluated norm (see GarmentCode) and flip if different
|
|
|
|
pattern_object = [node for node in panel_geom if 'Pattern' in node]
|
|
|
|
self.MayaObjects['panels'][panel_name]['qlPattern'] = (
|
|
pattern_object[0] if panel_group in pattern_object[0] else panel_group + '|' + pattern_object[0]
|
|
)
|
|
|
|
return panel_group
|
|
|
|
def _setSimProps(self, config={}):
|
|
"""Pass material properties for cloth & colliders to Qualoth"""
|
|
if not self.loaded_to_maya:
|
|
raise RuntimeError('MayaGarmentError::Pattern is not yet loaded.')
|
|
|
|
if config:
|
|
self.config = config
|
|
|
|
qw.setFabricProps(
|
|
self.get_qlcloth_props_obj(),
|
|
self.config['material']
|
|
)
|
|
|
|
if 'colliders' in self.MayaObjects:
|
|
for collider in self.MayaObjects['colliders']:
|
|
qw.setColliderFriction(collider, self.config['body_friction'])
|
|
|
|
if 'collision_thickness' in self.config:
|
|
# if not provided, use default auto-calculated value
|
|
cmds.setAttr(self.get_qlcloth_props_obj() + '.overrideThickness', 1)
|
|
cmds.setAttr(self.get_qlcloth_props_obj() + '.thickness', self.config['collision_thickness'])
|
|
|
|
# update resolution properties
|
|
qw.setPanelsResolution(self.config['resolution_scale'])
|
|
|
|
def _eval_vertex_segmentation(self):
|
|
"""
|
|
Evalute which vertex belongs to which panel
|
|
NOTE: only applicable to the mesh that was JUST loaded and stitched
|
|
-- Before the mesh was cleaned up (because the info from Qualoth is dependent on original topology)
|
|
-- before the sim started (need planarity checks)
|
|
Hence fuction is only called once on garment load
|
|
NOTE: if garment resolution was changed from Maya tools,
|
|
the segmentation is not guranteed to be consistent with the change,
|
|
(reload garment geometry to get correct segmentation)
|
|
"""
|
|
if not self.loaded_to_maya:
|
|
raise RuntimeError('Garment should be loaded when evaluating vertex segmentation')
|
|
|
|
self.update_verts_info()
|
|
self.vertex_labels = [None] * len(self.current_verts)
|
|
|
|
# -- Stitches (provided in qualoth objects directly) ---
|
|
on_stitches = self._verts_on_stitches() # TODOLOW I can even distinguish stitches from each other!
|
|
for idx in on_stitches:
|
|
self.vertex_labels[idx] = 'stitch'
|
|
|
|
# --- vertices ---
|
|
vertices = self.current_verts
|
|
# BBoxes give fast results for most vertices
|
|
bboxes = self._all_panel_bboxes()
|
|
vertices_multi_match = []
|
|
for i in range(len(vertices)):
|
|
if i in on_stitches: # already labeled
|
|
continue
|
|
vertex = vertices[i]
|
|
# check which panel is the closest one
|
|
in_bboxes = []
|
|
for panel in bboxes:
|
|
if self._point_in_bbox(vertex, bboxes[panel]):
|
|
in_bboxes.append(panel)
|
|
|
|
if len(in_bboxes) == 1:
|
|
self.vertex_labels[i] = in_bboxes[0]
|
|
else: # multiple or zero matches -- handle later
|
|
vertices_multi_match.append((i, in_bboxes))
|
|
|
|
# eval for confusing cases
|
|
neighbour_checks = 0
|
|
while len(vertices_multi_match) > 0:
|
|
unlabeled_vert_id, matched_panels = vertices_multi_match.pop(0)
|
|
|
|
# check if vert in on the plane of any of the panels
|
|
on_panel_planes = []
|
|
for panel in matched_panels:
|
|
if self._point_on_plane(vertices[unlabeled_vert_id], panel):
|
|
on_panel_planes.append(panel)
|
|
|
|
# plane might not be the only option
|
|
if len(on_panel_planes) == 1: # found!
|
|
self.vertex_labels[unlabeled_vert_id] = on_panel_planes[0]
|
|
else:
|
|
# by this time, many vertices already have labels, so let's just borrow from neigbours
|
|
neighbors = self._get_vert_neighbours(unlabeled_vert_id)
|
|
|
|
neighbour_checks += 1
|
|
|
|
if len(neighbors) == 0:
|
|
# print('Skipped Vertex {} with zero neigbors'.format(unlabeled_vert_id))
|
|
continue
|
|
|
|
unlabelled = [unl[0] for unl in vertices_multi_match]
|
|
# check only labeled neigbors that are not on stitches
|
|
neighbors = [vert_id for vert_id in neighbors if vert_id not in unlabelled and vert_id not in on_stitches]
|
|
|
|
if len(neighbors) > 0:
|
|
neighbour_labels = [self.vertex_labels[vert_id] for vert_id in neighbors]
|
|
|
|
# https://www.geeksforgeeks.org/python-find-most-frequent-element-in-a-list
|
|
frequent_label = max(set(neighbour_labels), key=neighbour_labels.count)
|
|
self.vertex_labels[unlabeled_vert_id] = frequent_label
|
|
else:
|
|
# put back
|
|
# NOTE! There is a ponetial for infinite loop here, but it shoulf not occur
|
|
# if the garment is freshly loaded before sim
|
|
print('Garment::Labelling::vertex {} needs revisit'.format(unlabeled_vert_id))
|
|
vertices_multi_match.append((unlabeled_vert_id, on_panel_planes))
|
|
|
|
def _clean_mesh(self):
|
|
"""
|
|
Clean mesh from incosistencies introduces by stitching,
|
|
and update vertex-dependednt info accordingly
|
|
"""
|
|
# remove the junk after garment was stitched and labeled
|
|
cmds.polyClean(self.get_qlcloth_geomentry())
|
|
|
|
# fix labeling
|
|
self.update_verts_info()
|
|
match_verts = utils.match_vert_lists(self.current_verts, self.last_verts)
|
|
|
|
self.vertex_labels = [self.vertex_labels[i] for i in match_verts]
|
|
|
|
def _edge_as_3d_tuple_list(self, edge, vertices):
|
|
"""
|
|
Represents given edge object as list of control points
|
|
suitable for drawing in Maya
|
|
"""
|
|
points = vertices[edge['endpoints'], :]
|
|
# NOTE: Legacy curvature representation
|
|
if 'curvature' in edge:
|
|
if isinstance(edge['curvature'], list):
|
|
abs_points = rel_to_abs_2d(
|
|
points[0], points[1], edge['curvature']
|
|
)
|
|
abs_points = [abs_points]
|
|
points = np.r_[
|
|
[points[0]], abs_points, [points[1]]
|
|
]
|
|
|
|
elif edge['curvature']['type'] == 'cubic' or edge['curvature']['type'] == 'quadratic':
|
|
abs_points = []
|
|
for p in edge['curvature']['params']:
|
|
abs_points.append(rel_to_abs_2d(
|
|
points[0], points[1], p))
|
|
points = np.r_[
|
|
[points[0]], abs_points, [points[1]]
|
|
]
|
|
else:
|
|
pass # Ignore for circle arcs
|
|
|
|
# to 3D
|
|
points = np.c_[points, np.zeros(len(points))]
|
|
|
|
return list(map(tuple, points))
|
|
|
|
def _draw_circle_arc(self, edge, vertices, resolution=20):
|
|
"""Draw a circle arc as specified by an edge"""
|
|
|
|
radius, large_arc, right = edge['curvature']['params']
|
|
edge_3d = np.array(self._edge_as_3d_tuple_list(edge, vertices))
|
|
|
|
angle = 2 * np.arcsin(np.linalg.norm(edge_3d[1] - edge_3d[0]) / radius / 2)
|
|
if large_arc:
|
|
angle = 2 * np.pi - angle
|
|
circle_name = cmds.circle(
|
|
radius=radius, sections=resolution, sweep=np.rad2deg(angle))
|
|
|
|
# Move the circle arc to a new position
|
|
circle_center = np.array([0, 0, 0]) # default arc placement in Maya
|
|
top_point = np.array([0, radius, 0])
|
|
sec_point = np.array([- radius * np.sin(angle), radius * np.cos(angle), 0])
|
|
|
|
if not right:
|
|
top_point, sec_point = sec_point, top_point
|
|
|
|
# translation
|
|
new_arc_position = edge_3d[0]
|
|
shift = (new_arc_position - top_point).tolist()
|
|
|
|
# Rotation
|
|
curr_dir = sec_point - top_point
|
|
target_dir = edge_3d[1] - edge_3d[0]
|
|
rot_angle = np.rad2deg(vector_angle(curr_dir[:2], target_dir[:2]))
|
|
if not right:
|
|
rot_angle = rot_angle - 360
|
|
|
|
cmds.move(shift[0], shift[1], shift[2], circle_name[0], relative=True)
|
|
cmds.rotate(
|
|
0, 0, rot_angle,
|
|
circle_name[0], relative=True,
|
|
pivot=new_arc_position.tolist())
|
|
|
|
return circle_name[0]
|
|
|
|
def _applyEuler(self, vector, eulerRot):
|
|
"""Applies Euler angles (in degrees) to provided 3D vector"""
|
|
# https://www.cs.utexas.edu/~theshark/courses/cs354/lectures/cs354-14.pdf
|
|
eulerRot_rad = np.deg2rad(eulerRot)
|
|
# X
|
|
vector_x = np.copy(vector)
|
|
vector_x[1] = vector[1] * np.cos(eulerRot_rad[0]) - vector[2] * np.sin(eulerRot_rad[0])
|
|
vector_x[2] = vector[1] * np.sin(eulerRot_rad[0]) + vector[2] * np.cos(eulerRot_rad[0])
|
|
|
|
# Y
|
|
vector_y = np.copy(vector_x)
|
|
vector_y[0] = vector_x[0] * np.cos(eulerRot_rad[1]) + vector_x[2] * np.sin(eulerRot_rad[1])
|
|
vector_y[2] = -vector_x[0] * np.sin(eulerRot_rad[1]) + vector_x[2] * np.cos(eulerRot_rad[1])
|
|
|
|
# Z
|
|
vector_z = np.copy(vector_y)
|
|
vector_z[0] = vector_y[0] * np.cos(eulerRot_rad[2]) - vector_y[1] * np.sin(eulerRot_rad[2])
|
|
vector_z[1] = vector_y[0] * np.sin(eulerRot_rad[2]) + vector_y[1] * np.cos(eulerRot_rad[2])
|
|
|
|
return vector_z
|
|
|
|
def _set_panel_3D_attr(self, panel_dict, panel_group, attribute, maya_attr):
|
|
"""Set recuested attribute to value from the spec"""
|
|
if attribute in panel_dict:
|
|
values = panel_dict[attribute]
|
|
else:
|
|
values = [0, 0, 0]
|
|
cmds.setAttr(
|
|
panel_group + '.' + maya_attr,
|
|
values[0], values[1], values[2],
|
|
type='double3')
|
|
|
|
def _apply_panel_3d_placement(self, panel_name):
|
|
"""Apply transform from spec to given panel"""
|
|
panel = self.pattern['panels'][panel_name]
|
|
panel_group = self.MayaObjects['panels'][panel_name]['curve_group']
|
|
|
|
# set pivot to origin relative to currently loaded curves
|
|
cmds.xform(panel_group, pivots=[0, 0, 0], worldSpace=True)
|
|
|
|
# now place correctly
|
|
self._set_panel_3D_attr(panel, panel_group, 'translation', 'translate')
|
|
self._set_panel_3D_attr(panel, panel_group, 'rotation', 'rotate')
|
|
|
|
def _maya_curve_name(self, address):
|
|
""" Shortcut to retrieve the name of curve corresponding to the edge"""
|
|
panel_name = address['panel']
|
|
edge_id = address['edge']
|
|
return self.MayaObjects['panels'][panel_name]['edges'][edge_id]
|
|
|
|
def _save_to_path(self, path, filename):
|
|
"""Save current state of cloth object to given path with given filename"""
|
|
|
|
# geometry
|
|
filepath = os.path.join(path, filename + '.obj')
|
|
utils.save_mesh(self.get_qlcloth_geomentry(), filepath)
|
|
|
|
# segmentation
|
|
filepath = os.path.join(path, filename + '_segmentation.txt')
|
|
with open(filepath, 'w') as f:
|
|
for panel_name in self.vertex_labels:
|
|
f.write("%s\n" % panel_name)
|
|
|
|
# eval
|
|
num_verts = cmds.polyEvaluate(self.get_qlcloth_geomentry(), v=True)
|
|
if num_verts != len(self.vertex_labels):
|
|
print('MayaGarment::WARNING::Segmentation list does not match mesh topology in save {}'.format(self.name))
|
|
|
|
def _intersect_object(self, geometry):
|
|
"""Check if given object intersects current cloth geometry
|
|
Function does not have side-effects on input geometry"""
|
|
|
|
# ray-based intersection test
|
|
|
|
cloth_mesh, cloth_dag = utils.get_mesh_dag(self.get_qlcloth_geomentry())
|
|
obstacle_mesh, obstacle_dag = utils.get_mesh_dag(geometry)
|
|
|
|
# use obstacle verts as a base for testing
|
|
# Assuming that the obstacle geometry has a lower resolution then the garment
|
|
|
|
obs_vertices = OpenMaya.MPointArray()
|
|
obstacle_mesh.getPoints(obs_vertices, OpenMaya.MSpace.kWorld)
|
|
|
|
# use ray intersect of all edges of obstacle mesh with the garment mesh
|
|
num_edges = obstacle_mesh.numEdges()
|
|
accelerator = cloth_mesh.autoUniformGridParams()
|
|
hit_border_length = 0 # those are edges along the border of intersecting area on the geometry
|
|
for edge_id in range(num_edges):
|
|
# Vertices that comprise an edge
|
|
vtx1, vtx2 = utils.edge_vert_ids(obstacle_mesh, edge_id)
|
|
|
|
# test intersection
|
|
raySource = OpenMaya.MFloatPoint(obs_vertices[vtx1])
|
|
rayDir = OpenMaya.MFloatVector(obs_vertices[vtx2] - obs_vertices[vtx1])
|
|
hit = utils.test_ray_intersect(cloth_mesh, raySource, rayDir, accelerator)
|
|
if hit:
|
|
# A very naive approximation of total border length of areas of intersection
|
|
hit_border_length += rayDir.length()
|
|
|
|
if hit_border_length < 1e-5: # no intersections -- no need for threshold check
|
|
print('{} with {} do not intersect'.format(geometry, self.name))
|
|
return False
|
|
|
|
if ('object_intersect_border_threshold' in self.config
|
|
and hit_border_length > self.config['object_intersect_border_threshold']
|
|
or (hit_border_length > 1e-5 and 'object_intersect_border_threshold' not in self.config)): # non-zero hit if no threshold provided
|
|
print('{} with {} intersect::Approximate intersection border length {:.2f} cm is above threshold {:.2f} cm'.format(
|
|
geometry, self.name, hit_border_length,
|
|
self.config['object_intersect_border_threshold'] if 'object_intersect_border_threshold' in self.config else 0))
|
|
return True
|
|
|
|
print('{} with {} intersect::Approximate intersection border length {:.2f} cm is ignored by threshold {:.2f} cm'.format(
|
|
geometry, self.name, hit_border_length,
|
|
self.config['object_intersect_border_threshold'] if 'object_intersect_border_threshold' in self.config else 0))
|
|
return False
|
|
|
|
def _verts_on_stitches(self):
|
|
"""
|
|
List all the vertices in garment mesh located on stitches
|
|
NOTE: it does not output vertices correctly on is the mesh topology was changed
|
|
(e.g. after cmds.polyClean())!!
|
|
"""
|
|
on_stitches = []
|
|
for stitch in self.pattern['stitches']:
|
|
# querying one side is enough since they share the vertices
|
|
for side in [0, 1]:
|
|
stitch_curve = self._maya_curve_name(stitch[side])
|
|
panel_name = stitch[side]['panel']
|
|
panel_node = self.MayaObjects['panels'][panel_name]['qlPattern']
|
|
|
|
verts_on_curve = qw.getVertsOnCurve(panel_node, stitch_curve)
|
|
|
|
on_stitches += verts_on_curve
|
|
return on_stitches
|
|
|
|
def _verts_on_curves(self):
|
|
"""
|
|
List all the vertices of garment mesh that are located on panel curves
|
|
"""
|
|
all_edge_verts = []
|
|
for panel in self.panel_order():
|
|
panel_info = self.MayaObjects['panels'][panel]
|
|
panel_node = panel_info['qlPattern']
|
|
|
|
# curves
|
|
for curve in panel_info['edges']:
|
|
verts_on_curve = qw.getVertsOnCurve(panel_node, curve)
|
|
all_edge_verts += verts_on_curve
|
|
|
|
print(min(verts_on_curve), max(verts_on_curve))
|
|
|
|
# might contain duplicates
|
|
return all_edge_verts
|
|
|
|
def _all_panel_bboxes(self):
|
|
"""
|
|
Evaluate 3D bounding boxes for all panels (as original curve loops)
|
|
"""
|
|
panel_curves = self.MayaObjects['panels']
|
|
bboxes = {}
|
|
for panel in panel_curves:
|
|
box = cmds.exactWorldBoundingBox(panel_curves[panel]['curve_group'])
|
|
bboxes[panel] = box
|
|
return bboxes
|
|
|
|
@staticmethod
|
|
def _point_in_bbox(point, bbox, tol=0.01):
|
|
"""
|
|
Check if point is within bbox
|
|
bbbox given in maya format (float[] xmin, ymin, zmin, xmax, ymax, zmax.)
|
|
NOTE: tol value is needed for cases when BBox collapses to 2D
|
|
"""
|
|
if (point[0] < (bbox[0] - tol) or point[0] > (bbox[3] + tol)
|
|
or point[1] < (bbox[1] - tol) or point[1] > (bbox[4] + tol)
|
|
or point[2] < (bbox[2] - tol) or point[2] > (bbox[5] + tol)):
|
|
return False
|
|
return True
|
|
|
|
def _point_on_plane(self, point, panel, tol=0.001):
|
|
"""
|
|
Check if a point belongs to the same plane as given in the curve group
|
|
"""
|
|
# I could check by panel rotation and translation!!
|
|
rot = self.pattern['panels'][panel]['rotation']
|
|
transl = np.array(self.pattern['panels'][panel]['translation'])
|
|
|
|
# default panel normal upon load, sign doesn't matter here
|
|
normal = np.array([0., 0., 1.])
|
|
rotated_normal = self._applyEuler(normal, rot)
|
|
|
|
dot_prod = np.dot(np.array(point) - transl, rotated_normal)
|
|
|
|
return np.isclose(dot_prod, 0., atol=tol)
|
|
|
|
def _point_in_curve(self, point, curve_group, tol=0.01):
|
|
"""
|
|
Check if a point is inside a given closed curve region
|
|
Assuming that the point is roughly in the same plane as the curve
|
|
"""
|
|
# closed curve mid-point
|
|
# shoot a ray (new linear curve) and check if it intersects with any of
|
|
pass
|
|
|
|
def _get_vert_neighbours(self, vert_id):
|
|
"""
|
|
List the neigbours of given vertex in current cloth mesh
|
|
"""
|
|
mesh_name = self.get_qlcloth_geomentry()
|
|
|
|
edges = cmds.polyListComponentConversion(
|
|
mesh_name + '.vtx[%d]' % vert_id,
|
|
fromVertex=True, toEdge=True)
|
|
|
|
neighbors = []
|
|
for edge in edges:
|
|
neighbor_verts_str = cmds.polyListComponentConversion(edge, toVertex=True)
|
|
for neighbor_str in neighbor_verts_str:
|
|
values = neighbor_str.split(']')[0].split('[')[-1]
|
|
if ':' in values:
|
|
neighbors += [int(x) for x in values.split(':')]
|
|
else:
|
|
neighbors.append(int(values))
|
|
|
|
return list(set(neighbors)) # leave only unique
|
|
|
|
def _panel_to_id(self, panel):
|
|
"""
|
|
Panel label as integer given the name of the panel
|
|
"""
|
|
return int(self.panel_order().index(panel) + 1)
|
|
|
|
|
|
class MayaGarmentWithUI(MayaGarment):
|
|
"""Extension of MayaGarment that can generate GUI for controlling the pattern"""
|
|
def __init__(self, pattern_file, clean_on_die=False):
|
|
super(MayaGarmentWithUI, self).__init__(pattern_file, clean_on_die)
|
|
self.ui_top_layout = None
|
|
self.ui_controls = {}
|
|
|
|
def __del__(self):
|
|
super(MayaGarmentWithUI, self).__del__()
|
|
# looks like UI now contains links to garment instance (callbacks, most probably)
|
|
# If destructor is called, the UI is already clean
|
|
|
|
# if self.ui_top_layout is not None:
|
|
# self._clean_layout(self.ui_top_layout)
|
|
|
|
# ------- UI Drawing routines --------
|
|
def drawUI(self, top_layout=None):
|
|
""" Draw pattern controls in the given layout
|
|
For correct connection with Maya attributes, it's recommended to call for drawing AFTER garment.load()
|
|
"""
|
|
if top_layout is not None:
|
|
self.ui_top_layout = top_layout
|
|
if self.ui_top_layout is None:
|
|
raise ValueError('GarmentDrawUI::top level layout not found')
|
|
|
|
self._clean_layout(self.ui_top_layout)
|
|
|
|
cmds.setParent(self.ui_top_layout)
|
|
|
|
# Pattern name
|
|
cmds.textFieldGrp(label='Pattern:', text=self.name, editable=False,
|
|
cal=[1, 'left'], cw=[1, 50])
|
|
|
|
# load panels info
|
|
cmds.frameLayout(
|
|
label='Panel Placement',
|
|
collapsable=True, borderVisible=True, collapse=True,
|
|
mh=10, mw=10
|
|
)
|
|
if not self.loaded_to_maya:
|
|
cmds.text(label='<To be displayed after geometry load>')
|
|
else:
|
|
for panel in self.pattern['panels']:
|
|
panel_layout = cmds.frameLayout(
|
|
label=panel, collapsable=True, collapse=True, borderVisible=True, mh=10, mw=10,
|
|
expandCommand=partial(cmds.select, self.MayaObjects['panels'][panel]['curve_group']),
|
|
collapseCommand=partial(cmds.select, self.MayaObjects['panels'][panel]['curve_group'])
|
|
)
|
|
self._ui_3d_placement(panel)
|
|
cmds.setParent('..')
|
|
cmds.setParent('..')
|
|
|
|
# Parameters
|
|
cmds.frameLayout(
|
|
label='Parameters',
|
|
collapsable=True, borderVisible=True, collapse=True,
|
|
mh=10, mw=10
|
|
)
|
|
self._ui_params(self.parameters, self.spec['parameter_order'])
|
|
cmds.setParent('..')
|
|
|
|
# constraints
|
|
if 'constraints' in self.spec:
|
|
cmds.frameLayout(
|
|
label='Constraints',
|
|
collapsable=True, borderVisible=True, collapse=True,
|
|
mh=10, mw=10
|
|
)
|
|
self._ui_constraints(self.spec['constraints'], self.spec['constraint_order'])
|
|
cmds.setParent('..')
|
|
|
|
# fin
|
|
cmds.setParent('..')
|
|
|
|
def _clean_layout(self, layout):
|
|
"""Removes all of the childer from layout"""
|
|
children = cmds.layout(layout, query=True, childArray=True)
|
|
if children:
|
|
cmds.deleteUI(children)
|
|
|
|
def _ui_3d_placement(self, panel_name):
|
|
"""Panel 3D placement"""
|
|
if not self.loaded_to_maya:
|
|
cmds.text(label='<To be displayed after geometry load>')
|
|
|
|
# Position
|
|
cmds.attrControlGrp(
|
|
attribute=self.MayaObjects['panels'][panel_name]['curve_group'] + '.translate',
|
|
changeCommand=partial(self._panel_placement_callback, panel_name, 'translation', 'translate')
|
|
)
|
|
|
|
# Rotation
|
|
cmds.attrControlGrp(
|
|
attribute=self.MayaObjects['panels'][panel_name]['curve_group'] + '.rotate',
|
|
changeCommand=partial(self._panel_placement_callback, panel_name, 'rotation', 'rotate')
|
|
)
|
|
|
|
def _ui_param_value(self, param_name, param_range, value, idx=None, tag=''):
|
|
"""Create UI elements to display range and control the param value"""
|
|
# range
|
|
cmds.rowLayout(numberOfColumns=3)
|
|
cmds.text(label='Range ' + tag + ':')
|
|
cmds.floatField(value=param_range[0], editable=False)
|
|
cmds.floatField(value=param_range[1], editable=False)
|
|
cmds.setParent('..')
|
|
|
|
# value
|
|
value_field = cmds.floatSliderGrp(
|
|
label='Value ' + tag + ':',
|
|
field=True, value=value,
|
|
minValue=param_range[0], maxValue=param_range[1],
|
|
cal=[1, 'left'], cw=[1, 45],
|
|
step=0.01
|
|
)
|
|
# add command with reference to current field
|
|
cmds.floatSliderGrp(value_field, edit=True,
|
|
changeCommand=partial(self._param_value_callback, param_name, idx, value_field))
|
|
|
|
def _ui_params(self, params, order):
|
|
"""draw params UI"""
|
|
# control
|
|
cmds.button(label='To template state',
|
|
backgroundColor=[227 / 256, 255 / 256, 119 / 256],
|
|
command=self._to_template_callback,
|
|
ann='Snap all parameters to default values')
|
|
cmds.button(label='Randomize',
|
|
backgroundColor=[227 / 256, 186 / 256, 119 / 256],
|
|
command=self._param_randomization_callback,
|
|
ann='Randomize all parameter values')
|
|
|
|
# Parameters themselves
|
|
for param_name in order:
|
|
cmds.frameLayout(
|
|
label=param_name, collapsable=True, collapse=True, mh=10, mw=10
|
|
)
|
|
# type
|
|
cmds.textFieldGrp(label='Type:', text=params[param_name]['type'], editable=False,
|
|
cal=[1, 'left'], cw=[1, 30])
|
|
|
|
# parameters might have multiple values
|
|
values = params[param_name]['value']
|
|
param_ranges = params[param_name]['range']
|
|
if isinstance(values, list):
|
|
ui_tags = ['X', 'Y', 'Z', 'W']
|
|
for idx, (value, param_range) in enumerate(zip(values, param_ranges)):
|
|
self._ui_param_value(param_name, param_range, value, idx, ui_tags[idx])
|
|
else:
|
|
self._ui_param_value(param_name, param_ranges, values)
|
|
|
|
# fin
|
|
cmds.setParent('..')
|
|
|
|
def _ui_constraints(self, constraints, order):
|
|
"""View basic info about specified constraints"""
|
|
for constraint_name in order:
|
|
cmds.textFieldGrp(
|
|
label=constraint_name + ':', text=constraints[constraint_name]['type'],
|
|
editable=False,
|
|
cal=[1, 'left'], cw=[1, 90])
|
|
|
|
def _quick_dropdown(self, options, chosen='', label=''):
|
|
"""Add a dropdown with given options"""
|
|
menu = cmds.optionMenu(label=label)
|
|
for option in options:
|
|
cmds.menuItem(label=option)
|
|
if chosen:
|
|
cmds.optionMenu(menu, e=True, value=chosen)
|
|
|
|
return menu
|
|
|
|
# -------- Callbacks -----------
|
|
def _to_template_callback(self, *args):
|
|
"""Returns current pattern to template state and
|
|
updates UI accordingly"""
|
|
# update
|
|
print('Pattern returns to origins..')
|
|
self._restore_template()
|
|
# update geometry in lazy manner
|
|
if self.loaded_to_maya:
|
|
self.load()
|
|
# update UI in lazy manner
|
|
self.drawUI()
|
|
|
|
def _param_randomization_callback(self, *args):
|
|
"""Randomize parameter values & update everything"""
|
|
self._randomize_pattern()
|
|
|
|
# update geometry in lazy manner
|
|
if self.loaded_to_maya:
|
|
self.load()
|
|
# update UI in lazy manner
|
|
self.drawUI()
|
|
|
|
def _param_value_callback(self, param_name, value_idx, value_field, *args):
|
|
"""Update pattern with new value"""
|
|
# in case the try failes
|
|
spec_backup = copy.deepcopy(self.spec)
|
|
if isinstance(self.parameters[param_name]['value'], list):
|
|
old_value = self.parameters[param_name]['value'][value_idx]
|
|
else:
|
|
old_value = self.parameters[param_name]['value']
|
|
|
|
# restore template state -- params are interdependent
|
|
# change cannot be applied independently by but should follow specified param order
|
|
self._restore_template(params_to_default=False)
|
|
|
|
# get value
|
|
new_value = args[0]
|
|
# save new value. No need to check ranges -- correct by UI
|
|
if isinstance(self.parameters[param_name]['value'], list):
|
|
self.parameters[param_name]['value'][value_idx] = new_value
|
|
else:
|
|
self.parameters[param_name]['value'] = new_value
|
|
|
|
# reapply all parameters
|
|
self._update_pattern_by_param_values()
|
|
if self.is_self_intersecting():
|
|
result = cmds.confirmDialog(
|
|
title='Restore from broken state',
|
|
message=('WARNING: Some of the panels contain intersected edges after applying value {} to {}.'
|
|
'\nDo you want to revert to previous state?'
|
|
'\n\nNote: simulation in broken state might result in Maya crashing').format(new_value, param_name),
|
|
button=['Yes', 'No'],
|
|
defaultButton='Yes', cancelButton='No', dismissString='No')
|
|
if result == 'Yes':
|
|
self._restore(spec_backup)
|
|
cmds.floatSliderGrp(value_field, edit=True, value=old_value)
|
|
return # No need to reload geometry -- nothing changed
|
|
|
|
# update geometry in lazy manner
|
|
if self.loaded_to_maya:
|
|
self.load()
|
|
# NOTE updating values in UI in this callback causes Maya crashes!
|
|
# Without update, the 3D placement UI gets disconnected from geometry but that's minor
|
|
# self.drawUI()
|
|
|
|
def _panel_placement_callback(self, panel_name, attribute, maya_attr):
|
|
"""Update pattern spec with tranlation/rotation info from Maya"""
|
|
# get values
|
|
values = cmds.getAttr(self.MayaObjects['panels'][panel_name]['curve_group'] + '.' + maya_attr)
|
|
values = values[0] # only one attribute requested
|
|
|
|
# set values
|
|
self.pattern['panels'][panel_name][attribute] = list(values)
|
|
|
|
|
|
class Scene(object):
|
|
"""
|
|
Decribes scene setup that includes:
|
|
* body object
|
|
* floor
|
|
* light(s) & camera(s)
|
|
Assumes
|
|
* body the scene revolved aroung faces z+ direction
|
|
"""
|
|
def __init__(self, body_obj, props, scenes_path='', clean_on_die=False):
|
|
"""
|
|
Set up scene for rendering using loaded body as a reference
|
|
"""
|
|
self.self_clean = clean_on_die
|
|
|
|
self.props = props
|
|
self.config = props['config']
|
|
self.stats = props['stats']
|
|
# load body to be used as a translation reference
|
|
self._load_body(body_obj)
|
|
|
|
# scene
|
|
self._init_arnold()
|
|
self.scene = {}
|
|
if 'scene' in self.config:
|
|
self._load_maya_scene(os.path.join(scenes_path, self.config['scene']))
|
|
else:
|
|
self._simple_scene_setup()
|
|
|
|
def __del__(self):
|
|
"""Remove all objects related to current scene if requested on creation"""
|
|
if self.self_clean:
|
|
cmds.delete(self.body)
|
|
cmds.delete(self.cameras)
|
|
# Shaders & other stuff
|
|
for key in self.scene: # garment color migh become invalid
|
|
cmds.delete(self.scene[key])
|
|
|
|
def _init_arnold(self):
|
|
"""Ensure Arnold objects are launched in Maya & init GPU rendering settings"""
|
|
|
|
objects = cmds.ls('defaultArnoldDriver')
|
|
if not objects: # Arnold objects not found
|
|
# https://arnoldsupport.com/2015/12/09/mtoa-creating-the-defaultarnold-nodes-in-scripting/
|
|
print('Initialized Arnold')
|
|
mtoa.core.createOptions()
|
|
|
|
cmds.setAttr('defaultArnoldRenderOptions.renderDevice', 1) # turn on GPPU rendering
|
|
cmds.setAttr('defaultArnoldRenderOptions.render_device_fallback', 1) # switch to CPU in case of failure
|
|
cmds.setAttr('defaultArnoldRenderOptions.AASamples', 10) # increase sampling for clean results
|
|
|
|
def floor(self):
|
|
return self.scene['floor']
|
|
|
|
def cloth_SG(self):
|
|
return self.scene['cloth_SG']
|
|
|
|
def render(self, save_to, name='last'):
|
|
"""
|
|
Makes a rendering of a current scene, and saves it to a given path
|
|
"""
|
|
# https://forums.autodesk.com/t5/maya-programming/rendering-with-arnold-in-a-python-script/td-p/7710875
|
|
# NOTE that attribute names depend on Maya version. These are for Maya2018-Maya2020
|
|
im_size = self.config['resolution']
|
|
|
|
# image setup
|
|
old_setup = self._set_image_size(im_size, im_size[0]/im_size[1], im_size[0]/im_size[1])
|
|
cmds.setAttr("defaultArnoldDriver.aiTranslator", "tif", type="string")
|
|
|
|
# fixing dark rendering problem
|
|
# https://forums.autodesk.com/t5/maya-shading-lighting-and/output-render-w-color-management-is-darker-than-render-view/td-p/7207081
|
|
cmds.colorManagementPrefs(e=True, outputTransformEnabled=True, outputUseViewTransform=True)
|
|
|
|
# render all the cameras
|
|
curr_frame = cmds.currentTime(query=True)
|
|
start_time = time.time()
|
|
for camera in self.cameras:
|
|
print('Rendering from camera {}'.format(camera))
|
|
|
|
camera_name = camera.split(':')[-1] # list of one element if ':' is not found
|
|
local_name = (name + '_' + camera_name) if name else camera_name
|
|
filename = os.path.join(save_to, local_name)
|
|
cmds.setAttr("defaultArnoldDriver.prefix", filename, type="string")
|
|
|
|
cmds.arnoldRender(width=im_size[0], height=im_size[1], batch=True, frameSequence=curr_frame, camera=camera)
|
|
|
|
self._set_image_size(*old_setup) # restore settings
|
|
self.stats['render_time'][name] = time.time() - start_time
|
|
|
|
def fetch_props_from_Maya(self):
|
|
"""Get properties records from Maya
|
|
Note: it updates global config!"""
|
|
# Update color settings
|
|
self.config['garment_color'] = self._fetch_color(self.scene['cloth_shader'])
|
|
|
|
def reset_garment_color(self):
|
|
"""Force the current garment shader color"""
|
|
|
|
color_plug = self.scene['cloth_shader'] + '.color'
|
|
if cmds.connectionInfo(color_plug, isDestination=True):
|
|
connected_color_plug = cmds.connectionInfo(color_plug, sourceFromDestination=True)
|
|
|
|
cmds.disconnectAttr(connected_color_plug, color_plug)
|
|
|
|
color = self.config['garment_color']
|
|
cmds.setAttr((color_plug), color[0], color[1], color[2], type='double3')
|
|
|
|
# ------- Private -----------
|
|
|
|
def _load_body(self, bodyfilename):
|
|
"""Load body object and scale it to cm units"""
|
|
# load
|
|
self.body_filepath = bodyfilename
|
|
self.body = utils.load_file(bodyfilename, 'body')
|
|
|
|
utils.scale_to_cm(self.body)
|
|
|
|
def _fetch_color(self, shader):
|
|
"""Return current color of a given shader node"""
|
|
color_plug = shader + '.color'
|
|
if cmds.connectionInfo(shader + '.color', isDestination=True):
|
|
color_plug = cmds.connectionInfo(color_plug, sourceFromDestination=True)
|
|
|
|
return cmds.getAttr(color_plug)[0]
|
|
|
|
def _simple_scene_setup(self):
|
|
"""setup very simple scene & materials"""
|
|
colors = {
|
|
"body_color": [0.5, 0.5, 0.7],
|
|
"cloth_color": [0.8, 0.2, 0.2] if 'garment_color' not in self.config else self.config['garment_color'],
|
|
"floor_color": [0.8, 0.8, 0.8]
|
|
}
|
|
|
|
self.scene = {
|
|
'floor': self._add_floor(self.body)
|
|
}
|
|
# materials
|
|
self.scene['body_shader'], self.scene['body_SG'] = self._new_lambert(colors['body_color'], self.body)
|
|
self.scene['cloth_shader'], self.scene['cloth_SG'] = self._new_lambert(colors['cloth_color'], self.body)
|
|
self.scene['floor_shader'], self.scene['floor_SG'] = self._new_lambert(colors['floor_color'], self.body)
|
|
|
|
self.scene['light'] = mutils.createLocator('aiSkyDomeLight', asLight=True)
|
|
|
|
# Put camera
|
|
self.cameras = [self._add_simple_camera()]
|
|
|
|
# save config
|
|
self.config['garment_color'] = colors['cloth_color']
|
|
|
|
def _load_maya_scene(self, scenefile):
|
|
"""Load scene from external file.
|
|
NOTE Assumes certain naming of nodes in the scene!"""
|
|
before = set(cmds.ls())
|
|
cmds.file(scenefile, i=True, namespace='imported')
|
|
new_objects = set(cmds.ls()) - before
|
|
# Maya may modify namespace for uniquness
|
|
scene_namespace = new_objects.pop().split(':')[0] + '::'
|
|
|
|
self.scene = {
|
|
'scene_group': cmds.ls(scene_namespace + '*scene*', transforms=True)[0],
|
|
'floor': cmds.ls(scene_namespace + '*floor*', geometry=True)[0],
|
|
'body_shader': cmds.ls(scene_namespace + '*body*', materials=True)[0],
|
|
'cloth_shader': cmds.ls(scene_namespace + '*garment*', materials=True, )[0]
|
|
}
|
|
# shader groups (to be used in cmds.sets())
|
|
self.scene['body_SG'] = self._create_shader_group(self.scene['body_shader'], 'bodySG')
|
|
self.scene['cloth_SG'] = self._create_shader_group(self.scene['cloth_shader'], 'garmentSG')
|
|
|
|
if 'garment_color' in self.config: # if given, use it
|
|
self.reset_garment_color()
|
|
else:
|
|
# save garment color to config
|
|
self.config['garment_color'] = self._fetch_color(self.scene['cloth_shader'])
|
|
|
|
# apply coloring to body object
|
|
if self.body:
|
|
cmds.sets(self.body, forceElement=self.scene['body_SG'])
|
|
|
|
# collect cameras
|
|
self.cameras = cmds.ls(scene_namespace + '*camera*', transforms=True)
|
|
|
|
# adjust scene position s.t. body is standing in the middle
|
|
body_low_center = self._get_object_lower_center(self.body)
|
|
floor_low_center = self._get_object_lower_center(self.scene['floor'])
|
|
old_translation = cmds.getAttr(self.scene['scene_group'] + '.translate')[0]
|
|
cmds.setAttr(
|
|
self.scene['scene_group'] + '.translate',
|
|
old_translation[0] + body_low_center[0] - floor_low_center[0],
|
|
old_translation[1] + body_low_center[1] - floor_low_center[1],
|
|
old_translation[2] + body_low_center[2] - floor_low_center[2],
|
|
type='double3') # apply to whole group s.t. lights positions were adjusted too
|
|
|
|
def _add_simple_camera(self, rotation=[-23.2, 16, 0]):
|
|
"""Puts camera in the scene
|
|
NOTE Assumes body is facing +z direction"""
|
|
|
|
camera = cmds.camera(aspectRatio=self.config['resolution'][0] / self.config['resolution'][1])[0]
|
|
cmds.setAttr(camera + '.rotate', rotation[0], rotation[1], rotation[2], type='double3')
|
|
|
|
# to view the target body
|
|
fitFactor = self.config['resolution'][1] / self.config['resolution'][0]
|
|
cmds.viewFit(camera, self.body, f=fitFactor)
|
|
|
|
return camera
|
|
|
|
def _get_object_lower_center(self, object):
|
|
"""return 3D position of the center of the lower side of bounding box"""
|
|
bb = cmds.exactWorldBoundingBox(object)
|
|
return [
|
|
(bb[3] + bb[0]) / 2,
|
|
bb[1],
|
|
(bb[5] + bb[2]) / 2
|
|
]
|
|
|
|
def _add_floor(self, target):
|
|
"""
|
|
adds a floor under a given object
|
|
"""
|
|
target_bb = cmds.exactWorldBoundingBox(target)
|
|
|
|
size = 50 * (target_bb[4] - target_bb[1])
|
|
floor = cmds.polyPlane(n='floor', w=size, h=size)
|
|
|
|
# place under the body
|
|
floor_level = target_bb[1]
|
|
cmds.move((target_bb[3] + target_bb[0]) / 2, # bbox center
|
|
floor_level,
|
|
(target_bb[5] + target_bb[2]) / 2, # bbox center
|
|
floor, a=1)
|
|
|
|
# Make the floor non-renderable
|
|
shape = cmds.listRelatives(floor[0], shapes=True)
|
|
cmds.setAttr(shape[0] + '.primaryVisibility', 0)
|
|
|
|
return floor[0]
|
|
|
|
def _new_lambert(self, color, target=None):
|
|
"""created a new shader node with given color"""
|
|
shader = cmds.shadingNode('lambert', asShader=True)
|
|
cmds.setAttr((shader + '.color'),
|
|
color[0], color[1], color[2],
|
|
type='double3')
|
|
|
|
shader_group = self._create_shader_group(shader)
|
|
if target is not None:
|
|
cmds.sets(target, forceElement=shader_group)
|
|
|
|
return shader, shader_group
|
|
|
|
def _create_shader_group(self, material, name='shader'):
|
|
"""Create a shader group set for a given material (to be used in cmds.sets())"""
|
|
shader_group = cmds.sets(renderable=True, noSurfaceShader=True, empty=True, name=name)
|
|
cmds.connectAttr(material + '.outColor', shader_group + '.surfaceShader')
|
|
return shader_group
|
|
|
|
def _set_image_size(self, im_size, ar_pix, ar_device):
|
|
"""Set image size for rendering"""
|
|
|
|
# remember the old settings to allow restoration
|
|
old_ar_pix = cmds.getAttr("defaultResolution.pixelAspect")
|
|
old_ar_device = cmds.getAttr("defaultResolution.deviceAspectRatio")
|
|
|
|
old_size = [0, 0]
|
|
old_size[0] = cmds.getAttr("defaultResolution.width")
|
|
old_size[1] = cmds.getAttr("defaultResolution.height")
|
|
|
|
cmds.setAttr("defaultResolution.width", im_size[0])
|
|
cmds.setAttr("defaultResolution.height", im_size[1])
|
|
cmds.setAttr("defaultResolution.pixelAspect", ar_pix)
|
|
cmds.setAttr("defaultResolution.deviceAspectRatio", ar_device)
|
|
|
|
return old_size, old_ar_pix, old_ar_device
|
|
|