""" Module for basic operations on patterns """ # Basic import copy import errno import json import numpy as np import os import random import svgpathtools as svgpath # My from . import rotation as rotation_tools from . import utils standard_filenames = [ 'specification', # e.g. used by dataset generation 'template', 'prediction' ] pattern_spec_template = { 'pattern': { 'panels': {}, 'stitches': [] }, 'parameters': {}, 'parameter_order': [], 'properties': { # these are to be ensured when pattern content is updated directly 'curvature_coords': 'relative', 'normalize_panel_translation': False, 'normalized_edge_loops': True, # will trigger edge loop normalization on reload 'units_in_meter': 100 # cm } } panel_spec_template = { 'translation': [ 0, 0, 0 ], 'rotation': [ 0, 0, 0 ], 'vertices': [], 'edges': [] } class EmptyPatternError(BaseException): def __init__(self, *args: object) -> None: super().__init__(*args) # ------------ Patterns -------- class BasicPattern(object): """Loading & serializing of a pattern specification in custom JSON format. Input: * Pattern template in custom JSON format Output representations: * Pattern instance in custom JSON format * In the current state Not implemented: * Convertion to NN-friendly format * Support for patterns with darts """ # ------------ Interface ------------- def __init__(self, pattern_file=None): self.spec_file = pattern_file if pattern_file is not None: # load pattern from file self.path = os.path.dirname(pattern_file) self.name = BasicPattern.name_from_path(pattern_file) self.reloadJSON() else: # create empty pattern self.path = None self.name = self.__class__.__name__ self.spec = copy.deepcopy(pattern_spec_template) self.pattern = self.spec['pattern'] self.properties = self.spec['properties'] # mandatory part def reloadJSON(self): """(Re)loads pattern info from spec file. Useful when spec is updated from outside""" if self.spec_file is None: print('BasicPattern::WARNING::{}::Pattern is not connected to any file. Reloadig from file request ignored.'.format( self.name )) return with open(self.spec_file, 'r') as f_json: self.spec = json.load(f_json) self.pattern = self.spec['pattern'] self.properties = self.spec['properties'] # mandatory part # template normalization - panel translations and curvature to relative coords self._normalize_template() def serialize(self, path, to_subfolder=True, tag='', empty_ok=False): if not empty_ok and len(self.panel_order()) == 0: raise RuntimeError(f'{self.__class__.__name__}::ERROR::Asked to save an empty pattern') # log context if tag: tag = '_' + tag if to_subfolder: log_dir = os.path.join(path, self.name + tag) # NOTE Added change try: os.makedirs(log_dir) except OSError as e: if e.errno != errno.EEXIST: raise else: log_dir = path spec_file = os.path.join(log_dir, (self.name + tag + '_specification.json')) # Save specification with open(spec_file, 'w') as f_json: json.dump(self.spec, f_json, indent=2) # print('{}::{}::Pattern saved to {}'.format(self.__class__.__name__, self.name, spec_file)) return log_dir @staticmethod def name_from_path(pattern_file): name = os.path.splitext(os.path.basename(pattern_file))[0] if name.endswith('_specification'): name = name.split('_specification')[0] if name in standard_filenames: # use name of directory instead path = os.path.dirname(pattern_file) name = os.path.basename(os.path.normpath(path)) return name # --------- Info ------------------------ def panel_order(self, force_update=False): """ Return current agreed-upon order of panels * if not defined in the pattern or if 'force_update' is enabled, re-evaluate it based on curent panel translation and save """ if 'panel_order' not in self.pattern or force_update: self.pattern['panel_order'] = self.define_panel_order() return self.pattern['panel_order'] def define_panel_order(self, name_list=None, location_dict=None, dim=0, tolerance=10): """ (Recursive) Ordering of the panels based on their 3D translation values. * Using cm as units for tolerance (when the two coordinates are considered equal) * Sorting by all dims as keys X -> Y -> Z (left-right (looking from Z) then down-up then back-front) * based on the fuzzysort suggestion here https://stackoverflow.com/a/24024801/11206726""" if name_list is None: # start from beginning name_list = self.pattern['panels'].keys() if not name_list: return [] if location_dict is None: # obtain location for all panels to use in sorting further location_dict = {} for name in name_list: location_dict[name], _ = self._panel_universal_transtation(name) # consider only translations of the requested panel names reference = [location_dict[panel_n][dim] for panel_n in name_list] sorted_couple = sorted(zip(reference, name_list)) # sorts according to the first list sorted_reference, sorted_names = zip(*sorted_couple) sorted_names = list(sorted_names) if (dim + 1) < 3: # 3D is max # re-sort values by next dimention if they have similar values in current dimention fuzzy_start, fuzzy_end = 0, 0 # init both in case we start from 1 panel to sort for fuzzy_end in range(1, len(sorted_reference)): if sorted_reference[fuzzy_end] - sorted_reference[fuzzy_start] >= tolerance: # the range of similar values is completed if fuzzy_end - fuzzy_start > 1: sorted_names[fuzzy_start:fuzzy_end] = self.define_panel_order( sorted_names[fuzzy_start:fuzzy_end], location_dict, dim + 1, tolerance) fuzzy_start = fuzzy_end # start counting similar values anew # take care of the tail if fuzzy_start != fuzzy_end: sorted_names[fuzzy_start:] = self.define_panel_order( sorted_names[fuzzy_start:], location_dict, dim + 1, tolerance) return sorted_names # -- sub-utils -- def _edge_as_vector(self, vertices, edge_dict): """Represent edge as vector of fixed length: * First 2 elements: Vector endpoint. Original edge endvertex positions can be restored if edge vector is added to the start point, which in turn could be obtained from previous edges in the panel loop * Next 2 elements: Curvature values Given in relative coordinates. With zeros if edge is not curved """ edge_verts = vertices[edge_dict['endpoints']] edge_vector = edge_verts[1] - edge_verts[0] curvature = np.array(edge_dict['curvature']) if 'curvature' in edge_dict else [0, 0] return np.concatenate([edge_vector, curvature]) def _edge_as_curve(self, vertices, edge): start = vertices[edge['endpoints'][0]] end = vertices[edge['endpoints'][1]] if ('curvature' in edge): # NOTE: supports old curves if isinstance(edge['curvature'], list) or edge['curvature']['type'] == 'quadratic': control_scale = self._flip_y(edge['curvature'] if isinstance(edge['curvature'], list) else edge['curvature']['params'][0]) control_point = utils.rel_to_abs_2d(start, end, control_scale) return svgpath.QuadraticBezier(*utils.list_to_c([start, control_point, end])) elif edge['curvature']['type'] == 'circle': # Assuming circle # https://svgwrite.readthedocs.io/en/latest/classes/path.html#svgwrite.path.Path.push_arc radius, large_arc, right = edge['curvature']['params'] return svgpath.Arc( utils.list_to_c(start), radius + 1j*radius, rotation=0, large_arc=large_arc, sweep=not right, end=utils.list_to_c(end) ) elif edge['curvature']['type'] == 'cubic': cps = [] for p in edge['curvature']['params']: control_scale = self._flip_y(p) control_point = utils.rel_to_abs_2d(start, end, control_scale) cps.append(control_point) return svgpath.CubicBezier(*utils.list_to_c([start, *cps, end])) else: raise NotImplementedError(f'{self.__class__.__name__}::Unknown curvature type {edge["curvature"]["type"]}') else: return svgpath.Line(*utils.list_to_c([start, end])) @staticmethod def _point_in_3D(local_coord, rotation, translation): """Apply 3D transformation to the point given in 2D local coordinated, e.g. on the panel * rotation is expected to be given in 'xyz' Euler anges (as in Autodesk Maya) or as 3x3 matrix""" # 2D->3D local local_coord = np.append(local_coord, 0) # Rotate rotation = np.array(rotation) if rotation.size == 3: # transform Euler angles to matrix rotation = rotation_tools.euler_xyz_to_R(rotation) # otherwise we already have the matrix elif rotation.size != 9: raise ValueError('BasicPattern::ERROR::You need to provide Euler angles or Rotation matrix for _point_in_3D(..)') rotated_point = rotation.dot(local_coord) # translate return rotated_point + translation def _panel_universal_transtation(self, panel_name): """Return a universal 3D translation of the panel (e.g. to be used in judging the panel order). Universal translation it defined as world 3D location of mid-point of the top (in 3D) of the panel (2D) bounding box. * Assumptions: * In most cases, top-mid-point of a panel corresponds to body landmarks (e.g. neck, middle of an arm, waist) and thus is mostly stable across garment designs. * 3D location of a panel is placing this panel around the body in T-pose * Function result is independent from the current choice of the local coordinate system of the panel """ panel = self.pattern['panels'][panel_name] vertices = np.array(panel['vertices']) # out of 2D bounding box sides' midpoints choose the one that is highest in 3D top_right = vertices.max(axis=0) low_left = vertices.min(axis=0) mid_x = (top_right[0] + low_left[0]) / 2 mid_y = (top_right[1] + low_left[1]) / 2 mid_points_2D = [ [mid_x, top_right[1]], [mid_x, low_left[1]], [top_right[0], mid_y], [low_left[0], mid_y] ] rot_matrix = rotation_tools.euler_xyz_to_R(panel['rotation']) # calculate once for all points mid_points_3D = np.vstack(tuple( [self._point_in_3D(coords, rot_matrix, panel['translation']) for coords in mid_points_2D] )) top_mid_point = mid_points_3D[:, 1].argmax() return mid_points_3D[top_mid_point], np.array(mid_points_2D[top_mid_point]) # --------- Pattern operations (changes inner dicts) ---------- def _normalize_template(self): """ Updated template definition for convenient processing: * Converts curvature coordinates to realitive ones (in edge frame) -- for easy length scaling * snaps each panel center to (0, 0) if requested in props * scales everything to cm """ if self.properties['curvature_coords'] == 'absolute': for panel in self.pattern['panels']: # convert curvature vertices = self.pattern['panels'][panel]['vertices'] edges = self.pattern['panels'][panel]['edges'] for edge in edges: if 'curvature' in edge: edge['curvature'] = utils.abs_to_rel_2d( vertices[edge['endpoints'][0]], vertices[edge['endpoints'][1]], edge['curvature'] ) # now we have new property self.properties['curvature_coords'] = 'relative' if 'units_in_meter' in self.properties: if self.properties['units_in_meter'] != 100: for panel in self.pattern['panels']: self._normalize_panel_scaling(panel, self.properties['units_in_meter']) # now we have cm self.properties['original_units_in_meter'] = self.properties['units_in_meter'] self.properties['units_in_meter'] = 100 print('WARNING: pattern units converted to cm') else: print('WARNING: units not specified in the pattern. Scaling normalization was not applied') # after curvature is converted!! # Only if requested if ('normalize_panel_translation' in self.properties and self.properties['normalize_panel_translation']): print('Normalizing translation!') self.properties['normalize_panel_translation'] = False # one-time use property. Preverts rotation issues on future reads for panel in self.pattern['panels']: # put origin in the middle of the panel-- offset = self._normalize_panel_translation(panel) # udpate translation vector original = self.pattern['panels'][panel]['translation'] self.pattern['panels'][panel]['translation'] = [ original[0] + offset[0], original[1] + offset[1], original[2], ] # Recalculate origins and traversal order of panel edge loops if not normalized already if ('normalized_edge_loops' not in self.properties or not self.properties['normalized_edge_loops']): print('{}::WARNING::normalizing the order and origin choice for edge loops in panels'.format(self.__class__.__name__)) self.properties['normalized_edge_loops'] = True for panel in self.pattern['panels']: self._normalize_edge_loop(panel) # Recalculate panel order if not given already self.panel_order() def _normalize_panel_translation(self, panel_name): """ Convert panel vertices to local coordinates: Shifts all panel vertices s.t. origin is at the center of the panel """ panel = self.pattern['panels'][panel_name] vertices = np.asarray(panel['vertices']) offset = np.mean(vertices, axis=0) vertices = vertices - offset panel['vertices'] = vertices.tolist() return offset def _normalize_panel_scaling(self, panel_name, units_in_meter): """Convert all panel info to cm. I assume that curvature is alredy converted to relative coords -- scaling does not need update""" scaling = 100 / units_in_meter # vertices vertices = np.array(self.pattern['panels'][panel_name]['vertices']) vertices = scaling * vertices self.pattern['panels'][panel_name]['vertices'] = vertices.tolist() # translation translation = self.pattern['panels'][panel_name]['translation'] self.pattern['panels'][panel_name]['translation'] = [scaling * coord for coord in translation] def _normalize_edge_loop(self, panel_name): """ * Re-order edges s.t. the edge loop starts from low-left vertex * Make the edge loop follow counter-clockwise direction (uniform traversal) """ panel = self.pattern['panels'][panel_name] vertices = np.array(panel['vertices']) # Loop Origin loop_origin_id = self._vert_at_left_corner(vertices) print('{}:{}: Origin: {} -> {}'.format( self.name, panel_name, panel['edges'][0]['endpoints'][0], loop_origin_id)) rotated_edges, rotated_edge_ids = self._rotate_edges( panel['edges'], list(range(len(panel['edges']))), loop_origin_id) panel['edges'] = rotated_edges # Panel flip for uniform edge loop order (and normal direction) first_edge = self._edge_as_vector(vertices, rotated_edges[0])[:2] last_edge = self._edge_as_vector(vertices, rotated_edges[-1])[:2] flipped = False # due to the choice of origin (at the corner), first & last edge cross-product will reliably show panel normal direction if np.cross(first_edge, last_edge) > 0: # should be negative -- counterclockwise print('{}::{}::panel <{}> flipped'.format( self.__class__.__name__, self.name, panel_name )) flipped = True # Vertices vertices[:, 0] = - vertices[:, 0] # flip by X coordinate -- we'll rotate around Y panel['vertices'] = vertices.tolist() # Edges # new loop origin after update loop_origin_id = self._vert_at_left_corner(vertices) print('{}:{}: Origin: {} -> {}'.format( self.name, panel_name, panel['edges'][0]['endpoints'][0], loop_origin_id)) rotated_edges, rotated_edge_ids = self._rotate_edges(rotated_edges, rotated_edge_ids, loop_origin_id) panel['edges'] = rotated_edges # update the curvatures in edges as they changed left\right symmetry in 3D for edge_id in range(len(rotated_edges)): if 'curvature' in panel['edges'][edge_id]: curvature = panel['edges'][edge_id]['curvature'] # YES!! Only one of the curvature coordinates need update at this point panel['edges'][edge_id]['curvature'][1] = -curvature[1] # Panel translation and rotation -- local coord frame changed! panel['translation'][0] -= 2 * panel['translation'][0] panel_R = rotation_tools.euler_xyz_to_R(panel['rotation']) flip_R = np.eye(3) flip_R[0, 0] = flip_R[2, 2] = -1 # by 180 around Y panel['rotation'] = rotation_tools.R_to_euler(panel_R * flip_R) # Stitches -- update the edge references according to the new ids if 'stitches' in self.pattern.keys(): for stitch_id in range(len(self.pattern['stitches'])): for side_id in [0, 1]: if self.pattern['stitches'][stitch_id][side_id]['panel'] == panel_name: old_edge_id = self.pattern['stitches'][stitch_id][side_id]['edge'] self.pattern['stitches'][stitch_id][side_id]['edge'] = rotated_edge_ids[old_edge_id] return rotated_edge_ids, flipped # -- sub-utils -- def _edge_length(self, panel, edge): panel = self.pattern['panels'][panel] v_id_start, v_id_end = tuple(panel['edges'][edge]['endpoints']) v_start, v_end = np.array(panel['vertices'][v_id_start]), \ np.array(panel['vertices'][v_id_end]) return np.linalg.norm(v_end - v_start) @staticmethod def _vert_at_left_corner(vertices): """ Find, which vertex is in the left corner * Determenistic process """ left_corner = np.min(vertices, axis=0) vertices = vertices - left_corner # choose the one closest to zero (=low-left corner) as new origin verts_norms = np.linalg.norm(vertices, axis=1) # numpy 1.9+ origin_id = np.argmin(verts_norms) return origin_id @staticmethod def _rotate_edges(edges, edge_ids, new_origin_id): """ Rotate provided list of edges s.t. the first edge starts from vertex with id = new_origin_id Map old edge_ids to new ones accordingly * edges expects list of edges structures """ first_edge_orig_id = [idx for idx, edge in enumerate(edges) if edge['endpoints'][0] == new_origin_id] first_edge_orig_id = first_edge_orig_id[0] rotated_edges = edges[first_edge_orig_id:] + edges[:first_edge_orig_id] # map from old ids to new ids rotated_edge_ids = edge_ids[(len(rotated_edges) - first_edge_orig_id):] + edge_ids[:(len(rotated_edges) - first_edge_orig_id)] return rotated_edges, rotated_edge_ids def _restore(self, backup_copy): """Restores spec structure from given backup copy Makes a full copy of backup to avoid accidential corruption of backup """ self.spec = copy.deepcopy(backup_copy) self.pattern = self.spec['pattern'] self.properties = self.spec['properties'] # mandatory part # -------- Checks ------------ def is_self_intersecting(self): """returns True if any of the pattern panels are self-intersecting""" return any(map(self._is_panel_self_intersecting, self.pattern['panels'])) def _is_panel_self_intersecting(self, panel_name, n_vert_approximation=10): """Checks whatever a given panel contains intersecting edges """ panel = self.pattern['panels'][panel_name] vertices = np.array(panel['vertices']) edge_curves = [] for e in panel['edges']: curve = self._edge_as_curve(vertices, e) if isinstance(curve, svgpath.Arc): # NOTE: Intersections for Arcs (Circle edge) fails in svgpathtools: # They are not well implemented in svgpathtools, see # https://github.com/mathandy/svgpathtools/issues/121 # https://github.com/mathandy/svgpathtools/blob/fcb648b9bb9591d925876d3b51649fa175b40524/svgpathtools/path.py#L1960 # Hence using linear approximation for robustness: n = n_vert_approximation + 1 tvals = np.linspace(0, 1, n, endpoint=False)[1:] edge_verts = [curve.point(t) for t in tvals] edge_curves += [svgpath.Line(edge_verts[i], edge_verts[i + 1]) for i in range(n-2)] else: edge_curves.append(curve) # NOTE: simple pairwise checks of edges for i1 in range(0, len(edge_curves)): for i2 in range(i1 + 1, len(edge_curves)): intersect_t = edge_curves[i1].intersect(edge_curves[i2]) # Check exceptions -- intersection at the vertex for i in range(len(intersect_t)): t1, t2 = intersect_t[i] if t2 < t1: t1, t2 = t2, t1 if utils.close_enough(t1, 0) and utils.close_enough(t2, 1): intersect_t[i] = None intersect_t = [el for el in intersect_t if el is not None] if intersect_t: # Any other case of intersections return True return False # NOTE: Deprecated. Preserved for backward compatibility # with the first dataset of 3D garments and sewing patterns class ParametrizedPattern(BasicPattern): """ Extention to BasicPattern that can work with parametrized patterns Update pattern with new parameter values & randomize those parameters """ def __init__(self, pattern_file=None): super().__init__(pattern_file) self.parameters = self.spec['parameters'] self.parameter_defaults = { 'length': 1, 'additive_length': 0, 'curve': 1 } self.constraint_types = [ 'length_equality' ] def param_values_list(self): """Returns current values of all parameters as a list in the pattern defined parameter order""" value_list = [] for parameter in self.spec['parameter_order']: value = self.parameters[parameter]['value'] if isinstance(value, list): value_list += value else: value_list.append(value) return value_list def apply_param_list(self, values): """Apply given parameters supplied as a list of param_values_list() form""" self._restore_template(params_to_default=False) # set new values value_count = 0 for parameter in self.spec['parameter_order']: last_value = self.parameters[parameter]['value'] if isinstance(last_value, list): self.parameters[parameter]['value'] = [values[value_count + i] for i in range(len(last_value))] value_count += len(last_value) else: self.parameters[parameter]['value'] = values[value_count] value_count += 1 self._update_pattern_by_param_values() def reloadJSON(self): """(Re)loads pattern info from spec file. Useful when spec is updated from outside""" super().reloadJSON() self.parameters = self.spec['parameters'] self._normalize_param_scaling() def _restore(self, backup_copy): """Restores spec structure from given backup copy Makes a full copy of backup to avoid accidential corruption of backup """ super()._restore(backup_copy) self.parameters = self.spec['parameters'] # ---------- Parameters operations -------- def _normalize_param_scaling(self): """Convert additive parameters to cm units""" if 'original_units_in_meter' in self.properties: # pattern was scaled scaling = 100 / self.properties['original_units_in_meter'] for parameter in self.parameters: if self.parameters[parameter]['type'] == 'additive_length': self.parameters[parameter]['value'] = scaling * self.parameters[parameter]['value'] self.parameters[parameter]['range'] = [ scaling * elem for elem in self.parameters[parameter]['range'] ] # now we have cm everywhere -- no need to keep units info self.properties.pop('original_units_in_meter', None) print('WARNING: Parameter units were converted to cm') def _normalize_edge_loop(self, panel_name): """Update the edge loops and edge ids references in parameters & constraints after change""" rotated_edge_ids, flipped = super()._normalize_edge_loop(panel_name) # Parameters for parameter_name in self.spec['parameters']: self._influence_after_edge_loop_update( self.spec['parameters'][parameter_name]['influence'], panel_name, rotated_edge_ids) # Constraints if 'constraints' in self.spec: for constraint_name in self.spec['constraints']: self._influence_after_edge_loop_update( self.spec['constraints'][constraint_name]['influence'], panel_name, rotated_edge_ids) def _influence_after_edge_loop_update(self, infl_list, panel_name, new_edge_ids): """ Update the list of parameter\constraint influence with the new edge ids of given panel. flipped -- indicates if in the new edges start & end vertices have been swapped """ for infl_id in range(len(infl_list)): if infl_list[infl_id]['panel'] == panel_name: # update edge_list = infl_list[infl_id]['edge_list'] for edge_list_id in range(len(edge_list)): if isinstance(edge_list[edge_list_id], int): # Simple edge id lists in curvature params old_id = edge_list[edge_list_id] edge_list[edge_list_id] = new_edge_ids[old_id] elif isinstance(edge_list[edge_list_id]['id'], list): # Meta-edge in length parameters & constraints for i in range(len(edge_list[edge_list_id]['id'])): old_id = edge_list[edge_list_id]['id'][i] edge_list[edge_list_id]['id'][i] = new_edge_ids[old_id] else: # edge description in length parameters & constraints old_id = edge_list[edge_list_id]['id'] edge_list[edge_list_id]['id'] = new_edge_ids[old_id] def _update_pattern_by_param_values(self): """ Recalculates vertex positions and edge curves according to current parameter values (!) Assumes that the current pattern is a template: with all the parameters equal to defaults! """ for parameter in self.spec['parameter_order']: value = self.parameters[parameter]['value'] param_type = self.parameters[parameter]['type'] if param_type not in self.parameter_defaults: raise ValueError("Incorrect parameter type. Alowed are " + self.parameter_defaults.keys()) for panel_influence in self.parameters[parameter]['influence']: for edge in panel_influence['edge_list']: if param_type == 'length': self._extend_edge(panel_influence['panel'], edge, value) elif param_type == 'additive_length': self._extend_edge(panel_influence['panel'], edge, value, multiplicative=False) elif param_type == 'curve': self._curve_edge(panel_influence['panel'], edge, value) # finally, ensure secified constraints are held self._apply_constraints() def _restore_template(self, params_to_default=True): """Restore pattern to it's state with all parameters having default values Recalculate vertex positions, edge curvatures & snap values to 1 """ # Follow process backwards self._invert_constraints() for parameter in reversed(self.spec['parameter_order']): value = self.parameters[parameter]['value'] param_type = self.parameters[parameter]['type'] if param_type not in self.parameter_defaults: raise ValueError("Incorrect parameter type. Alowed are " + self.parameter_defaults.keys()) for panel_influence in reversed(self.parameters[parameter]['influence']): for edge in reversed(panel_influence['edge_list']): if param_type == 'length': self._extend_edge(panel_influence['panel'], edge, self._invert_value(value)) elif param_type == 'additive_length': self._extend_edge(panel_influence['panel'], edge, self._invert_value(value, multiplicative=False), multiplicative=False) elif param_type == 'curve': self._curve_edge(panel_influence['panel'], edge, self._invert_value(value)) # restore defaults if params_to_default: if isinstance(value, list): self.parameters[parameter]['value'] = [self.parameter_defaults[param_type] for _ in value] else: self.parameters[parameter]['value'] = self.parameter_defaults[param_type] def _extend_edge(self, panel_name, edge_influence, value, multiplicative=True): """ Shrinks/elongates a given edge or edge collection of a given panel. Applies equally to straight and curvy edges tnks to relative coordinates of curve controls Expects * each influenced edge to supply the elongatoin direction * scalar scaling_factor 'multiplicative' parameter controls the type of extention: * if True, value is treated as a scaling factor of the edge or edge projection -- default * if False, value is added to the edge or edge projection """ if isinstance(value, list): raise ValueError("Multiple scaling factors are not supported") verts_ids, verts_coords, target_line, _ = self._meta_edge(panel_name, edge_influence) # calc extention pivot if edge_influence['direction'] == 'end': fixed = verts_coords[0] # start is fixed elif edge_influence['direction'] == 'start': fixed = verts_coords[-1] # end is fixed elif edge_influence['direction'] == 'both': fixed = (verts_coords[0] + verts_coords[-1]) / 2 else: raise RuntimeError('Unknown edge extention direction {}'.format(edge_influence['direction'])) # move verts # * along target line that sits on fixed point (correct sign & distance along the line) verts_projection = np.empty(verts_coords.shape) for i in range(verts_coords.shape[0]): verts_projection[i] = (verts_coords[i] - fixed).dot(target_line) * target_line if multiplicative: # * to match the scaled projection (correct point of application -- initial vertex position) new_verts = verts_coords - (1 - value) * verts_projection else: # * to match the added projection: # still need projection to make sure the extention derection is corect relative to fixed point # normalize first for i in range(verts_coords.shape[0]): norm = np.linalg.norm(verts_projection[i]) if not np.isclose(norm, 0): verts_projection[i] /= norm # zero projections were not normalized -- they will zero-out the effect new_verts = verts_coords + value * verts_projection # update in the initial structure panel = self.pattern['panels'][panel_name] for ni, idx in enumerate(verts_ids): panel['vertices'][idx] = new_verts[ni].tolist() def _curve_edge(self, panel_name, edge, scaling_factor): """ Updated the curvature of an edge accoding to scaling_factor. Can only be applied to edges with curvature information scaling_factor can be * scalar -- only the Y of control point is changed * 2-value list -- both coordinated of control are updated """ panel = self.pattern['panels'][panel_name] if 'curvature' not in panel['edges'][edge]: raise ValueError('Applying curvature scaling to non-curvy edge ' + str(edge) + ' of ' + panel_name) control = panel['edges'][edge]['curvature'] if isinstance(scaling_factor, list): control = [ control[0] * scaling_factor[0], control[1] * scaling_factor[1] ] else: control[1] *= scaling_factor panel['edges'][edge]['curvature'] = control def _invert_value(self, value, multiplicative=True): """If value is a list, return a list with each value inverted. 'multiplicative' parameter controls the type of inversion: * if True, returns multiplicative inverse (1/value) == default * if False, returns additive inverse (-value) """ if multiplicative: if isinstance(value, list): if any(np.isclose(value, 0)): raise ZeroDivisionError('Zero value encountered while restoring multiplicative parameter.') return map(lambda x: 1 / x, value) else: if np.isclose(value, 0): raise ZeroDivisionError('Zero value encountered while restoring multiplicative parameter.') return 1 / value else: if isinstance(value, list): return map(lambda x: -x, value) else: return -value def _apply_constraints(self): """Change the pattern to adhere to constraints if given in pattern spec Assumes no zero-length edges exist""" if 'constraints' not in self.spec: return for constraint_n in self.spec['constraints']: # order preserved as it's a list constraint = self.spec['constraints'][constraint_n] constraint_type = constraint['type'] if constraint_type not in self.constraint_types: raise ValueError("Incorrect constraint type. Alowed are " + self.constraint_types) if constraint_type == 'length_equality': # get all length of the affected (meta) edges target_len = [] for panel_influence in constraint['influence']: for edge in panel_influence['edge_list']: # NOTE: constraints along a custom vector are not well tested _, _, _, length = self._meta_edge(panel_influence['panel'], edge) edge['length'] = length target_len.append(length) if len(target_len) == 0: return # target as mean of provided edges target_len = sum(target_len) / len(target_len) # calculate scaling factor for every edge to match max length # & update edges with it for panel_influence in constraint['influence']: for edge in panel_influence['edge_list']: scaling = target_len / edge['length'] if not np.isclose(scaling, 1): edge['value'] = scaling self._extend_edge(panel_influence['panel'], edge, edge['value']) def _invert_constraints(self): """Restore pattern to the state before constraint was applied""" if 'constraints' not in self.spec: return # follow the process backwards for constraint_n in reversed(self.spec['constraint_order']): # order preserved as it's a list constraint = self.spec['constraints'][constraint_n] constraint_type = constraint['type'] if constraint_type not in self.constraint_types: raise ValueError("Incorrect constraint type. Alowed are " + self.constraint_types) if constraint_type == 'length_equality': # update edges with invertes scaling factor for panel_influence in constraint['influence']: for edge in panel_influence['edge_list']: scaling = self._invert_value(edge['value']) self._extend_edge(panel_influence['panel'], edge, scaling) edge['value'] = 1 def _meta_edge(self, panel_name, edge_influence): """Returns info for the given edge or meta-edge in inified form""" panel = self.pattern['panels'][panel_name] edge_ids = edge_influence['id'] if isinstance(edge_ids, list): # meta-edge # get all vertices in order verts_ids = [panel['edges'][edge_ids[0]]['endpoints'][0]] # start for edge_id in edge_ids: verts_ids.append(panel['edges'][edge_id]['endpoints'][1]) # end vertices else: # single edge verts_ids = panel['edges'][edge_ids]['endpoints'] verts_coords = [] for idx in verts_ids: verts_coords.append(panel['vertices'][idx]) verts_coords = np.array(verts_coords) # extention line if 'along' in edge_influence: target_line = edge_influence['along'] else: target_line = verts_coords[-1] - verts_coords[0] target_line = np.array(target_line, dtype=float) # https://stackoverflow.com/questions/50625975/typeerror-ufunc-true-divide-output-typecode-d-could-not-be-coerced-to-pro if np.isclose(np.linalg.norm(target_line), 0): raise ZeroDivisionError('target line is zero ' + str(target_line)) else: target_line /= np.linalg.norm(target_line) return verts_ids, verts_coords, target_line, target_line.dot(verts_coords[-1] - verts_coords[0]) def _invalidate_all_values(self): """Sets all values of params & constraints to None if not set already Useful in direct updates of pattern panels""" updated_once = False for parameter in self.parameters: if self.parameters[parameter]['value'] is not None: self.parameters[parameter]['value'] = None updated_once = True if 'constraints' in self.spec: for constraint in self.spec['constraints']: for edge_collection in self.spec['constraints'][constraint]['influence']: for edge in edge_collection['edge_list']: if edge['value'] is not None: edge['value'] = None updated_once = True if updated_once: # only display worning if some new invalidation happened print('ParametrizedPattern::WARNING::Parameter (& constraints) values are invalidated') # ---------- Randomization ------------- def _randomize_pattern(self): """Robustly randomize current pattern""" # restore template state before making any changes to parameters self._restore_template(params_to_default=False) spec_backup = copy.deepcopy(self.spec) self._randomize_parameters() self._update_pattern_by_param_values() for _ in range(100): # upper bound on trials to avoid infinite loop if not self.is_self_intersecting(): break print('WARNING::Randomized pattern is self-intersecting. Re-try..') self._restore(spec_backup) # Try again self._randomize_parameters() self._update_pattern_by_param_values() def _new_value(self, param_range): """Random value within range given as an iteratable""" value = random.uniform(param_range[0], param_range[1]) # prevent non-reversible zero values if abs(value) < 1e-2: value = 1e-2 * (-1 if value < 0 else 1) return value def _randomize_parameters(self): """ Sets new random values for the pattern parameters Parameter type agnostic """ for parameter in self.parameters: param_ranges = self.parameters[parameter]['range'] # check if parameter has multiple values (=> multiple ranges) like for curves if isinstance(self.parameters[parameter]['value'], list): values = [] for param_range in param_ranges: values.append(self._new_value(param_range)) self.parameters[parameter]['value'] = values else: # simple 1-value parameter self.parameters[parameter]['value'] = self._new_value(param_ranges)