Files

974 lines
43 KiB
Python
Raw Permalink Normal View History

2025-07-03 17:03:00 +08:00
"""
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)