init_code

This commit is contained in:
sky
2025-07-03 17:03:00 +08:00
parent a710c87a2b
commit 89766fe3d1
220 changed files with 479903 additions and 77 deletions

View File

View File

@@ -0,0 +1,141 @@
from abc import ABC, abstractmethod
import numpy as np
from pygarment.garmentcode.connector import Stitches
class BaseComponent(ABC):
"""Basic interface for garment-related components
NOTE: modifier methods return self object to allow chaining of the
operations
"""
def __init__(self, name, verbose=False) -> None:
self.name = name
self.verbose = verbose
# List or dictionary of the interfaces of this components
# available for connectivity with other components
self.interfaces = {}
# Rules for connecting subcomponents
self.stitching_rules = Stitches()
# Info
def pivot_3D(self):
"""Pivot location of a component in 3D"""
return [0, 0, 0]
def bbox(self):
"""Bounding box -- in 2D"""
return np.array([0, 0]), np.array([0, 0])
def bbox3D(self):
"""Bounding box in 3D space"""
return np.array([0, 0, 0]), np.array([0, 0, 0])
def is_self_intersecting(self):
"""Check whether the component have self-intersections"""
return False
# Operations
@abstractmethod
def translate_by(self, delta_translation):
return self
@abstractmethod
def translate_to(self, new_translation):
"""Set panel translation to be exactly that vector"""
return self
@abstractmethod
def rotate_by(self, delta_rotation):
return self
@abstractmethod
def rotate_to(self, new_rot):
return self
@abstractmethod
def assembly(self, *args, **kwargs):
pass
# ----- Placement routines: these are the same for panels and components
def place_below(self, comp, gap=2):
"""Place below the provided component"""
other_bbox = comp.bbox3D()
curr_bbox = self.bbox3D()
self.translate_by([0, other_bbox[0][1] - curr_bbox[1][1] - gap, 0])
return self
def place_by_interface(
self,
self_interface,
out_interface,
gap=2,
alignment='center',
gap_dir=None
):
"""Adjust the placement of component according to the connectivity
instruction
Alignment options:
'center' center of the interface to center of the interface
'top' - top on Y axis
'bottom' - bottom on Y axis
'left' - left on X axis
'right' - right on X axis
"""
# Align translation
self_bbox = self_interface.bbox_3d()
out_bbox = out_interface.bbox_3d()
# Determine alignment point depending on requested alignment type
point_out = (out_bbox[1] + out_bbox[0]) / 2
point_self = (self_bbox[1] + self_bbox[0]) / 2
if alignment == 'center':
pass # No modification needed
elif alignment == 'top':
point_out[1] = out_bbox[1][1] # Use max in Y
point_self[1] = self_bbox[1][1]
elif alignment == 'bottom':
point_out[1] = out_bbox[0][1] # Use min in Y
point_self[1] = self_bbox[0][1]
elif alignment == 'right':
point_out[0] = out_bbox[0][0] # Use min in X
point_self[0] = self_bbox[0][0]
elif alignment == 'left':
point_out[0] = out_bbox[1][0] # Use max in X
point_self[0] = self_bbox[1][0]
else:
raise ValueError(
f'{self.__class__.__name__}::{self.name}::ERROR::'
f'Uknown alignment type ({alignment}) requested in place_by_interface().'
f' Available types: center, top, bottom, left, right')
# Add a gap outside the current
if gap_dir is None:
full_bbox = self.bbox3D()
center = (full_bbox[0] + full_bbox[1]) / 2
mid_self = (self_bbox[1] + self_bbox[0]) / 2
gap_dir = mid_self - center
gap_dir = gap * gap_dir / np.linalg.norm(gap_dir)
diff = point_out - (point_self + gap_dir)
self.translate_by(diff)
# NOTE: Norm evaluation of vertex set will fail
# for the alignment of 2D panels, where they are likely
# to be in one line or in a panel plane instead of
# the interface place -- so I'm not using norms for gap estimation
# TODO Estimate rotation
# TODO not just placement by the midpoint of the interfaces?
# It created a little overlap when both interfaces are angled a little differently
return self

View File

@@ -0,0 +1,147 @@
import numpy as np
from scipy.spatial.transform import Rotation as R
from pygarment.garmentcode.base import BaseComponent
from pygarment.pattern.wrappers import VisPattern
class Component(BaseComponent):
"""Garment element (or whole piece) composed of simpler connected garment
elements"""
# TODOLOW Overload copy -- respecting edge sequences -- never had any problems though
def __init__(self, name) -> None:
super().__init__(name)
self.subs = [] # list of generative subcomponents
def set_panel_label(self, label: str, overwrite=True):
"""Propagate given label to all sub-panels (in subcomponents)"""
subs = self._get_subcomponents()
for sub in subs:
sub.set_panel_label(label, overwrite)
def pivot_3D(self):
"""Pivot of a component as a block
NOTE: The relation of pivots of sub-blocks needs to be
preserved in any placement operations on components
"""
mins, maxes = self.bbox3D()
return np.array(((mins[0] + maxes[0]) / 2, maxes[1],
(mins[-1] + maxes[-1]) / 2))
def length(self):
"""Length of a component in cm
Defaults the to the vertical length of a 3D bounding box
* longest_dim -- if set, returns the longest dimention out of the bounding box dimentions
"""
subs = self._get_subcomponents()
return sum([s.length() for s in subs]) if subs else 0
def translate_by(self, delta_vector):
"""Translate component by a vector"""
for subs in self._get_subcomponents():
subs.translate_by(delta_vector)
return self
def translate_to(self, new_translation):
"""Set panel translation to be exactly that vector"""
pivot = self.pivot_3D()
for subs in self._get_subcomponents():
sub_pivot = subs.pivot_3D()
subs.translate_to(np.asarray(new_translation) + (sub_pivot - pivot))
return self
def rotate_by(self, delta_rotation: R):
"""Rotate component by a given rotation"""
pivot = self.pivot_3D()
for subs in self._get_subcomponents():
# With preserving relationships between components
rel = subs.pivot_3D() - pivot
rel_rotated = delta_rotation.apply(rel)
subs.rotate_by(delta_rotation)
subs.translate_by(rel_rotated - rel)
return self
def rotate_to(self, new_rot):
# TODOLOW Implement with correct preservation of relative placement
# of subcomponents
raise NotImplementedError(
f'Component::ERROR::rotate_to is not supported on component level.'
'Use relative <rotate_by()> method instead')
def mirror(self, axis=[0, 1]):
"""Swap this component with its mirror image by recursively mirroring
subcomponents
Axis specifies 2D axis to swap around: Y axis by default
"""
for subs in self._get_subcomponents():
subs.mirror(axis)
return self
def assembly(self):
"""Construction process of the garment component
get serializable representation
Returns: simulator friendly description of component sewing pattern
"""
spattern = VisPattern()
spattern.name = self.name
subs = self._get_subcomponents()
if not subs:
return spattern
# Simple merge of subcomponent representations
for sub in subs:
sub_raw = sub.assembly().pattern
# simple merge of panels
spattern.pattern['panels'] = {**spattern.pattern['panels'],
**sub_raw['panels']}
# of stitches
spattern.pattern['stitches'] += sub_raw['stitches']
spattern.pattern['stitches'] += self.stitching_rules.assembly()
return spattern
def bbox3D(self):
"""Evaluate 3D bounding box of the current component"""
subs = self._get_subcomponents()
bboxes = [s.bbox3D() for s in subs]
if not len(subs):
# Special components without panel geometry -- no bbox defined
return np.array([[np.inf, np.inf, np.inf], [-np.inf, -np.inf, -np.inf]])
mins = np.vstack([b[0] for b in bboxes])
maxes = np.vstack([b[1] for b in bboxes])
return mins.min(axis=0), maxes.max(axis=0)
def is_self_intersecting(self):
"""Check whether the component have self-intersections on panel level"""
for s in self._get_subcomponents():
if s.is_self_intersecting():
return True
return False
# Subcomponents
def _get_subcomponents(self):
"""Unique set of subcomponents defined in the `self.subs` list or as
attributes of the object"""
all_attrs = [getattr(self, name)
for name in dir(self)
if name[:2] != '__' and name[-2:] != '__']
return list(set([att
for att in all_attrs
if isinstance(att, BaseComponent)] + self.subs))

View File

@@ -0,0 +1,197 @@
import numpy as np
from pygarment.garmentcode.interface import Interface
from pygarment.garmentcode.utils import close_enough
class StitchingRule:
"""High-level stitching instructions connecting two component interfaces
"""
def __init__(self, int1: Interface, int2: Interface,
verbose: bool = False) -> None:
"""
Inputs:
* int1, int2 -- two interfaces to connect in the stitch
NOTE: When connecting interfaces with multiple edge count on both
sides,
1) Note that the edge sequences may change their structure.
Involved interfaces and corresponding patterns will be updated
automatically
Use of the same interfaces in other stitches (creating 3+way
stitch edge) may fail.
2) The interfaces' edges are matched based on the provided order
in the interface.
The order can be controlled at the moment of interface creation
"""
# TODO Explicitely support 3+way stitches
self.int1 = int1
self.int2 = int2
self.verbose = verbose
if not self.isMatching():
self.match_interfaces()
if verbose and not close_enough(
len1 := int1.projecting_lengths().sum(),
len2 := int2.projecting_lengths().sum(),
tol=0.3): # NOTE = 3 mm
print(
f'{self.__class__.__name__}::WARNING::Projected edges do not match in the stitch: \n'
f'{len1}: {int1}\n{len2}: {int2}')
def isMatching(self, tol=0.05):
# if both the breakdown and relative partitioning is similar
frac1 = self.int1.projecting_fractions()
frac2 = self.int2.projecting_fractions()
return len(self.int1) == len(self.int2) and np.allclose(frac1, frac2, atol=tol)
def match_interfaces(self):
""" Subdivide the interface edges on both sides s.t. they are matching
and can be safely connected
(same number of edges on each side and same relative fractions)
Serializable format does not natively support t-stitches,
so the longer edges needs to be broken down into matching segments
"""
# Eval the fractions corresponding to every segment in the interfaces
# Using projecting edges to match desired gather patterns
frac1 = self.int1.projecting_fractions()
frac2 = self.int2.projecting_fractions()
min_frac = min(min(frac1), min(frac2)) # projection tolerance should not be larger than the smallest fraction
self._match_to_fractions(self.int1, frac2, tol=min(1e-2, min_frac / 2))
self._match_to_fractions(self.int2, frac1, tol=min(1e-2, min_frac / 2))
def _match_to_fractions(self, inter:Interface, to_add, tol=1e-2):
"""Add the vertices at given location to the edge sequence in a given
interface
Parameters:
* inter -- interface to modify
* to_add -- the faractions of segements to be projected onto the
edge sequence in the inter
* tol -- the proximity of vertices when they can be regarded as
the same vertex.
NOTE: tol should be shorter than the smallest expected edge
"""
# NOTE Edge sequences to subdivide might be disconnected
# (even belong to different panels), so we need to subdivide per edge
# Go over the edges keeping track of their fractions
add_id, in_id = 0, 0
covered_init, covered_added = 0, 0
curr_fractions = inter.projecting_fractions()
while in_id < len(inter.edges) and add_id < len(to_add):
# projected edges since they represent the stitch sizes
# NOTE: sometimes overshoots slightly due to error accumulation -> bounding by 1.
next_init = min(covered_init + curr_fractions[in_id], 1.)
next_added = min(covered_added + to_add[add_id], 1.)
if close_enough(next_init, next_added, tol):
# the vertex exists, skip
in_id += 1
add_id += 1
covered_init, covered_added = next_init, next_added
elif next_init < next_added:
# add on the next step
in_id += 1
covered_init = next_init
else:
# add a vertex to the edge at the new location
# Eval on projected edge
in_frac = curr_fractions[in_id]
new_v_loc = in_frac - (next_init - next_added)
split_frac = new_v_loc / in_frac
base_edge, base_panel = inter.edges[in_id], inter.panel[in_id]
# Check edge orientation
flip = inter.needsFlipping(in_id)
if flip:
split_frac = 1 - split_frac
if self.verbose:
print(f'{self.__class__.__name__}::INFO::{base_edge} from {base_panel.name} reoriented in interface')
# Split the base edge accordingly
subdiv = base_edge.subdivide_len([split_frac, 1 - split_frac])
inter.panel[in_id].edges.substitute(base_edge, subdiv) # Update the panel
# Always follows the edge order in the panel
# Swap subdiv order for interface to s.w. the interface sequence remains oriented
if flip:
subdiv.edges.reverse()
# Update interface accordingly
inter.substitute(
base_edge, subdiv, [inter.panel[in_id]
for _ in range(len(subdiv))])
# TODO what if these edges are used in other interfaces? Do they need to be updated as well?
# next step
curr_fractions = inter.projecting_fractions()
covered_init += curr_fractions[in_id]
covered_added = next_added
in_id += 1
add_id += 1
if add_id != len(to_add):
raise RuntimeError(f'{self.__class__.__name__}::ERROR::Projection on {inter.panel_names()} failed')
def assembly(self):
"""Produce a stitch that connects two interfaces
"""
if self.verbose and not self.isMatching():
print(f'{self.__class__.__name__}::WARNING::Stitch sides do not match on assembly!!')
stitches = []
for i, j in zip(range(len(self.int1.edges)), range(len(self.int2.edges))):
stitches.append([
{
'panel': self.int1.panel[i].name, # corresponds to a name.
# Only one element of the first level is expected
'edge': self.int1.edges[i].geometric_id
},
{
'panel': self.int2.panel[j].name,
'edge': self.int2.edges[j].geometric_id
}
])
# Swap indication
# NOTE: Swap is indicated on the interfaces in order to support component
# incapsulation. Same stitching rule for different participating components may have different
# fabric side preferences.
# NOTE: "right_wrong" stitch is used when either of the interfaces request it
# NOTE: Backward-compatible formulation
if self.int1.right_wrong[i] or self.int2.right_wrong[j]:
stitches[-1].append('right_wrong')
return stitches
class Stitches:
"""Describes a collection of StitchingRule objects
Needed for more compact specification and evaluation of those rules
"""
def __init__(self, *rules) -> None:
"""Rules -- any number of tuples of two interfaces (Interface, Interface) """
self.rules = [StitchingRule(int1, int2) for int1, int2 in rules]
def append(self, pair): # TODOLOW two parameters explicitely rather then "pair" object?
self.rules.append(StitchingRule(*pair))
def __getitem__(self, id):
return self.rules[id]
def assembly(self):
stitches = []
for rule in self.rules:
stitches += rule.assembly()
return stitches

View File

@@ -0,0 +1,998 @@
from copy import deepcopy, copy
import numpy as np
from numpy.linalg import norm
import svgpathtools as svgpath # https://github.com/mathandy/svgpathtools
from pygarment.garmentcode.utils import R2D
from pygarment.garmentcode.utils import close_enough
from pygarment.garmentcode.utils import c_to_list
from pygarment.garmentcode.utils import list_to_c
from pygarment.pattern.utils import rel_to_abs_2d, abs_to_rel_2d
ILENGTH_S_TOL = 1e-10 # NOTE: tolerance value for evaluating curve parameter (t) from acr length
class Edge:
"""Edge an individual segment of a panel border connecting two panel
vertices, the basic building block of panels
Edges are defined on 2D coordinate system with Start vertex as an origin
and (End-Start) as Ox axis
"""
def __init__(self, start=None, end=None, label='') -> None:
""" Simple edge initialization.
Parameters:
* start, end: from/to vertices that the edge connects,
describing the _interface_ of an edge
* label: semantic label of the edge to be writted down as a property on assembly
# TODOLOW Add support for fold schemes to allow guided folds at
the edge (e.g. pleats)
"""
if start is None:
start = [0, 0]
if end is None:
end = [0, 0]
assert not all(close_enough(s, e) for s, e in zip(start, end)), 'Start and end of an edge should differ'
self.start = start # NOTE: careful with references to vertex objects
self.end = end
# Semantic label
self.label = label
# ID w.r.t. other edges in a super-panel
# Filled out at the panel assembly time
self.geometric_id = 0
def length(self):
"""Return current length of an edge.
Since vertices may change their locations externally, the length
is dynamically evaluated
"""
return self._straight_len()
def _straight_len(self):
"""Length of the edge ignoring the curvature"""
return norm(np.asarray(self.end) - np.asarray(self.start))
def __eq__(self, __o: object, tol=1e-2) -> bool:
"""Special implementation of comparison: same edges == edges can be
connected by flat stitch
Edges are the same if their length is the same (if their flattened
representation is the same) => vertices do not have to be on the
same locations
NOTE: The edges may not have the same curvature and still be
considered equal ("connectible")
"""
if not isinstance(__o, Edge):
return False
# Base length is the same
if close_enough(self.length(), __o.length(), tol=tol):
return False
return True
def __str__(self) -> str:
return f'Straight:[{self.start[0]:.2f}, {self.start[1]:.2f}]->[{self.end[0]:.2f}, {self.end[1]:.2f}]'
def __repr__(self) -> str:
""" 'Official string representation' -- for nice printing of lists of edges
https://stackoverflow.com/questions/3558474/how-to-apply-str-function-when-printing-a-list-of-objects-in-python
"""
return self.__str__()
def midpoint(self):
"""Center of the edge"""
return (np.array(self.start) + np.array(self.end)) / 2
def shortcut(self):
"""Return straight shortcut for an edge,
as `np.array`
For straight edges it's the same as the edge itself
"""
return np.array([self.start, self.end])
def flip_x_axis(self):
"""Flips the Bezier curve along the x-axis by inverting the y-coordinates"""
self.start[1] = -self.start[1]
self.end[1] = -self.end[1]
return self
# Representation
def as_curve(self):
"""As svgpath curve object"""
# Get the nodes correcly
nodes = np.vstack((self.start, self.end))
params = nodes[:, 0] + 1j*nodes[:, 1]
return svgpath.Line(*params)
def linearize(self, n_verts_inside = 0):
"""Return a linear approximation of an edge using the same vertex objects
# NOTE: for the linear edge it is an egde if n_verts_inside = 0,
# else n_verts_inside = number of vertices (excluding the start
and end vertices) used to create a linearization of the edge
"""
if not n_verts_inside:
return EdgeSequence(self)
else:
n = n_verts_inside + 1
tvals = np.linspace(0, 1, n, endpoint=False)[1:]
curve = self.as_curve()
edge_verts = [c_to_list(curve.point(t)) for t in tvals]
seq = self.to_edge_sequence(edge_verts)
return seq
def to_edge_sequence(self, edge_verts):
"""
Returns the edge as a sequence of STRAIGHT edges based on points
sampled on the edge between `self.start` and `self.end` (edge_verts).
"""
seq = EdgeSequence(Edge(self.start, edge_verts[0]))
for i in range(1, len(edge_verts)):
seq.append(Edge(seq[-1].end, edge_verts[i]))
seq.append(Edge(seq[-1].end, self.end))
return seq
# Actions
def reverse(self):
"""Flip the direction of the edge"""
self.start, self.end = self.end, self.start
return self
def reflect_features(self):
"""Reflect edge fetures from one side of the edge to the other"""
# Nothing to do for straight edge
return self
def snap_to(self, new_start=None):
"""Translate the edge vertices s.t. the start is at new_start
"""
if new_start is None:
new_start = [0, 0]
self.end[0] = self.end[0] - self.start[0] + new_start[0]
self.end[1] = self.end[1] - self.start[1] + new_start[1]
self.start[:] = new_start
return self
def rotate(self, angle):
"""Rotate edge by angle in place, using first point as a reference
Parameters:
angle -- desired rotation angle in radians (!)
"""
curr_start = copy(self.start)
# set the start point to zero
self.snap_to([0, 0])
self.end[:] = np.matmul(R2D(angle), self.end)
# recover the original location
self.snap_to(curr_start)
return self
def subdivide_len(self, fractions: list, connect_internal_verts=True):
"""Add intermediate vertices to an edge,
splitting its length according to fractions
while preserving the overall shape
* merge_internal -- if False, the newly inserted vertices would be
defined
as independent objects for each edge. If True, vertex objects
will be shared
"""
# Parametrized by length
new_edges = self._subdivide(fractions, by_length=True)
if connect_internal_verts:
self._merge_subdiv_vertices(new_edges)
return new_edges
def subdivide_param(self, fractions: list, connect_internal_verts=True):
"""Add intermediate vertices to an edge,
splitting its curve parametrization according to fractions
while preserving the overall shape
NOTE: for line, it's the same as subdivision by length
"""
new_edges = self._subdivide(fractions, by_length=False)
if connect_internal_verts:
self._merge_subdiv_vertices(new_edges)
return new_edges
def _subdivide(self, fractions: list, by_length=True):
"""Subdivide edge by length or curve parametrization
NOTE: equivalent for straight lines
"""
frac = [abs(f) for f in fractions]
if not close_enough(fsum := sum(frac), 1, 1e-4):
raise RuntimeError(f'Edge Subdivision::ERROR::fraction is incorrect. The sum {fsum} is not 1')
vec = np.asarray(self.end) - np.asarray(self.start)
verts = [self.start]
seq = EdgeSequence()
for i in range(len(frac) - 1):
verts.append(
[verts[-1][0] + frac[i]*vec[0],
verts[-1][1] + frac[i]*vec[1]]
)
seq.append(Edge(verts[-2], verts[-1]))
verts.append(self.end)
seq.append(Edge(verts[-2], verts[-1]))
return seq
def _merge_subdiv_vertices(self, subdivision):
"""Merge the vertices from cosecutive edges in the given edge subdivision"""
for i in range(1, len(subdivision)):
subdivision[i].start = subdivision[i-1].end
return subdivision
# Assembly into serializable object
def assembly(self):
"""Returns the dict-based representation of edges,
compatible with core -> BasePattern JSON (dict)
"""
properties = {"endpoints": [0, 1]}
if self.label:
properties['label'] = self.label
return [self.start, self.end], properties
class CircleEdge(Edge):
"""Curvy edge as circular arc"""
def __init__(self, start=None, end=None, cy=None, label='') -> None:
"""
Define a circular arc edge
* start, end: from/to vertices that the edge connects
* cy: third point on a circle arc (= control point).
Expressed relatively w.r.t. distance between start and end.
X value for control point is fixed at x=0.5 (edge center) to
avoid ambiguity
* label: semantic label of the edge to be writted down as a property on assembly
NOTE: representing control point in relative coordinates
allows preservation of curvature (arc angle, relative radius
w.r.t. straight edge length)
When distance between vertices shrinks / extends
NOTE: full circle not supported: start & end should differ
"""
if start is None:
start = [0, 0]
if end is None:
end = [1, 0]
super().__init__(start, end, label=label)
self.control_y = cy
def length(self):
"""Return current length of an edge.
Since vertices may change their locations externally, the length
is dynamically evaluated
"""
return self._rel_radius() * self._straight_len() * self._arc_angle()
def __str__(self) -> str:
points = [self.start, [0.5, self.control_y]]
str = [f'[{p[0]:.2f}, {p[1]:.2f}]->' for p in points]
str += [f'[{self.end[0]:.2f}, {self.end[1]:.2f}]']
return 'Arc:' + ''.join(str)
def midpoint(self):
"""Center of the edge"""
return rel_to_abs_2d(self.start, self.end, [0.5, self.control_y])
# Actions
def reverse(self):
"""Flip the direction of the edge, accounting for curvatures"""
self.start, self.end = self.end, self.start
self.control_y *= -1
return self
def reflect_features(self):
"""Reflect edge features from one side of the edge to the other"""
self.control_y *= -1
return self
def _subdivide(self, fractions: list, by_length=False):
"""Add intermediate vertices to an edge,
splitting its parametrization according to fractions
while preserving the overall shape
NOTE: param subdiv == length subdiv for circle arcs
"""
# NOTE: subdivide_param() is the same as subdivide_len()
# So parent implementation is ok
# TODOLOW Implementation is very similar to CurveEdge param-based subdivision
from pygarment.garmentcode.edge_factory import EdgeFactory # TODOLOW: ami - better solution?
frac = [abs(f) for f in fractions]
if not close_enough(fsum := sum(frac), 1, 1e-4):
raise RuntimeError(f'Edge Subdivision::ERROR::fraction is incorrect. The sum {fsum} is not 1')
curve = self.as_curve()
# Sub-curves
covered_fr = 0
subcurves = []
for fr in fractions:
subcurves.append(curve.cropped(covered_fr, covered_fr + fr))
covered_fr += fr
# Convert to CircleEdge objects
subedges = EdgeSequence()
for curve in subcurves:
subedges.append(EdgeFactory.from_svg_curve(curve))
# Reference the first/last vertices correctly
subedges[0].start = self.start
subedges[-1].end = self.end
return subedges
# Special tools for circle representation
def as_curve(self):
"""Represent as svgpath Arc"""
radius, la, sweep = self.as_radius_flag()
return svgpath.Arc(
list_to_c(self.start),
list_to_c([radius, radius]), 0, la, sweep,
list_to_c(self.end)
)
def as_radius_flag(self):
"""Return circle representation as radius and arc flags"""
return (self._rel_radius() * self._straight_len(),
self._is_large_arc(),
self.control_y < 0) # left/right orientation
def as_radius_angle(self):
"""Return circle representation as radius and an angle"""
return (
self._rel_radius() * self._straight_len(),
self._arc_angle(),
self.control_y < 0
)
def linearize(self, n_verts_inside = 9):
"""Return a linear approximation of an edge using the same vertex objects
NOTE: n_verts_inside = number of vertices (excluding the start
and end vertices) used to create a linearization of the edge
"""
n = n_verts_inside + 1
tvals = np.linspace(0, 1, n, endpoint=False)[1:]
curve = self.as_curve()
edge_verts = [c_to_list(curve.point(t)) for t in tvals]
seq = self.to_edge_sequence(edge_verts)
return seq
# NOTE: The following values are calculated at runtime to allow
# changes to control point after the edge definition
def _rel_radius(self, abs_radius=None):
"""Eval relative radius (w.r.t. straight distance) from 3-point
representation"""
if abs_radius:
return abs_radius / self._straight_len()
# Using the formula for radius of circumscribed circle
# https://en.wikipedia.org/wiki/Circumscribed_circle#Other_properties
# triangle sides, assuming the begginning and end of an edge are at
# (0, 0) and (1, 0)
# accordingly
a = 1
b = norm([0.5, self.control_y])
c = norm([0.5 - 1, self.control_y])
p = (a + b + c) / 2 # semiperimeter
rad = a * b * c / np.sqrt(p * (p - a) * (p - b) * (p - c)) / 4
return rad
def _arc_angle(self):
"""Eval arc angle from control point"""
rel_rad = self._rel_radius()
# NOTE: Bound the sin to avoid out of bounds errors
# due to floating point error accumulation
arc = 2 * np.arcsin(min(max(1 / rel_rad / 2, -1.), 1.))
if self._is_large_arc():
arc = 2 * np.pi - arc
return arc
def _is_large_arc(self):
"""Indicate if the arc sweeps the large or small angle"""
return abs(self.control_y) > self._rel_radius()
def assembly(self):
"""Returns the dict-based representation of edges,
compatible with core -> BasePattern JSON (dict)
"""
ends, props = super().assembly()
# NOTE: arc representation is the same as in SVG
rad, large_arc, right = self.as_radius_flag()
props['curvature'] = {
"type": 'circle',
"params": [rad, int(large_arc), int(right)]
}
return ends, props
class CurveEdge(Edge):
"""Curvy edge as Besier curve / B-spline"""
def __init__(self, start=None, end=None, control_points=None,
relative=True,
label='') -> None:
"""Define a Bezier curve edge
* start, end: from/to vertices that the edge connects
* control_points: coordinated of Bezier control points.
Specification of One control point creates the Quadratic Bezier,
Specification of 2 control points creates Cubic Bezier.
Other degrees are not supported.
* label: semantic label of the edge to be writted down as a property on assembly
* relative: specify whether the control point coordinated are given
relative to the edge length (True) or in 2D coordinate system of a
panel (False)
"""
if control_points is None:
control_points = []
if start is None:
start = [0, 0]
if end is None:
end = [0, 0]
super().__init__(start, end, label=label)
self.control_points = control_points
if len(self.control_points) > 2:
raise NotImplementedError(f'{self.__class__.__name__}::ERROR::Up to 2 control points (cubic Bezier) are supported')
# Storing control points as relative since it preserves overall curve
# shape during edge extension/contraction
if not relative:
self.control_points = [abs_to_rel_2d(self.start, self.end, c).tolist()
for c in self.control_points]
def flip_x_axis(self):
"""Flips the Bezier curve along the x-axis by inverting the y-coordinates"""
abs_control_points = [rel_to_abs_2d(self.start, self.end, c) for c in self.control_points]
self.start[1] = -self.start[1]
self.end[1] = -self.end[1]
abs_control_points= [[x, -y] for x, y in abs_control_points]
self.control_points= [abs_to_rel_2d(self.start, self.end, c).tolist() for c in abs_control_points]
return self
def length(self):
"""Length of Bezier curve edge"""
curve = self.as_curve()
return curve.length()
def __str__(self) -> str:
points = [self.start] + self.control_points
str = [f'[{p[0]:.2f}, {p[1]:.2f}]->' for p in points]
str += [f'[{self.end[0]:.2f}, {self.end[1]:.2f}]']
return 'Curve:' + ''.join(str)
def midpoint(self):
"""Center of the edge"""
curve = self.as_curve()
t_mid = curve.ilength(curve.length()/2, s_tol=ILENGTH_S_TOL)
return c_to_list(curve.point(t_mid))
def _subdivide(self, fractions: list, by_length=False):
"""Add intermediate vertices to an edge,
splitting its curve parametrization or overall length according to
fractions while preserving the overall shape
"""
from pygarment.garmentcode.edge_factory import EdgeFactory # TODOLOW: ami - better solution?
curve = self.as_curve()
# Sub-curves
covered_fr, prev_t = 0, 0
clen = curve.length()
subcurves = []
for fr in fractions:
covered_fr += fr
if by_length:
next_t = curve.ilength(clen * covered_fr, s_tol=ILENGTH_S_TOL)
subcurves.append(curve.cropped(prev_t, next_t))
prev_t = next_t
else:
subcurves.append(curve.cropped(covered_fr - fr, covered_fr))
# Convert to CurveEdge objects
subedges = EdgeSequence()
for curve in subcurves:
subedges.append(EdgeFactory.from_svg_curve(curve))
# Reference the first/last vertices correctly
subedges[0].start = self.start
subedges[-1].end = self.end
return subedges
# Actions
def reverse(self):
"""Flip the direction of the edge, accounting for curvatures"""
self.start, self.end = self.end, self.start
# change order of control points
if len(self.control_points) == 2:
self.control_points[0], self.control_points[1] = self.control_points[1], self.control_points[0]
# Update coordinates
for p in self.control_points:
p[0], p[1] = 1 - p[0], -p[1]
return self
def reflect_features(self):
"""Reflect edge fetures from one side of the edge to the other"""
for p in self.control_points:
p[1] = -p[1]
return self
def as_curve(self, absolute=True):
"""As svgpath curve object
Converting on the fly as exact vertex location might have been updated since
the creation of the edge
"""
# Get the nodes correcly
if absolute:
cp = [rel_to_abs_2d(self.start, self.end, c) for c in self.control_points]
nodes = np.vstack((self.start, cp, self.end))
else:
cp = self.control_points
nodes = np.vstack(([0, 0], cp, [1, 0]))
params = nodes[:, 0] + 1j*nodes[:, 1]
return svgpath.QuadraticBezier(*params) if len(cp) < 2 else svgpath.CubicBezier(*params)
def linearize(self, n_verts_inside=9):
"""Return a linear approximation of an edge using the same vertex objects
NOTE: n_verts_inside = number of vertices (excluding the start
and end vertices) used to create a linearization of the edge
"""
n = n_verts_inside + 1
tvals_init = np.linspace(0, 1, n, endpoint=False)[1:]
curve = self.as_curve(absolute=False)
curve_lengths = tvals_init * curve.length()
tvals = [curve.ilength(c_len, s_tol=ILENGTH_S_TOL) for c_len in curve_lengths]
edge_verts = [rel_to_abs_2d(self.start, self.end, c_to_list(curve.point(t))) for t in tvals]
seq = self.to_edge_sequence(edge_verts)
return seq
def _extreme_points(self):
"""Return extreme points (on Y) of the current edge
NOTE: this does NOT include the border vertices of an edge
"""
# Variation of https://github.com/mathandy/svgpathtools/blob/5c73056420386753890712170da602493aad1860/svgpathtools/bezier.py#L197
curve = self.as_curve(absolute=False) # relative coords to find real extremizers
poly = svgpath.bezier2polynomial(curve, return_poly1d=True)
y = svgpath.imag(poly)
dy = y.deriv()
y_extremizers = svgpath.polyroots(
dy, realroots=True, condition=lambda r: 0 < r < 1)
extreme_points = np.array(
[rel_to_abs_2d(self.start, self.end, c_to_list(curve.point(t)))
for t in y_extremizers]
)
return extreme_points
# Assembly into serializable object
def assembly(self):
"""Returns the dict-based representation of edges,
compatible with core -> BasePattern JSON (dict)
"""
ends, props = super().assembly()
props['curvature'] = {
"type": 'quadratic' if len(self.control_points) == 1 else 'cubic',
"params": self.control_points
}
return ends, props
class EdgeSequence:
"""Represents a sequence of (possibly chained) edges (e.g. every next edge
starts from the same vertex that the previous edge ends with and
allows building some typical edge sequences
"""
def __init__(self, *args, verbose: bool = False) -> None:
self.edges = []
self.verbose = verbose
for arg in args:
self.append(arg)
# ANCHOR Properties
def __getitem__(self, i):
if isinstance(i, slice):
# return an EdgeSequence object for slices
e_slice = self.edges[i]
return EdgeSequence(e_slice)
else:
return self.edges[i]
def index(self, elem):
# Find the same object (by reference)
# list.index() is doing something different..
# https://stackoverflow.com/a/47057419
return next(i for i, e in enumerate(self.edges) if elem is e)
def __len__(self):
"""Number of edges in the sequence"""
return len(self.edges)
def __contains__(self, item):
# check presence by comparing references
return any([item is e for e in self.edges])
def __str__(self) -> str:
return 'EdgeSeq: ' + str(self.edges)
def __repr__(self) -> str:
return self.__str__()
def length(self):
"""Total length of edges"""
return sum([e.length() for e in self.edges])
def isLoop(self):
return self.edges[0].start == self.edges[-1].end and len(self) > 1 #modify is to ==
def isChained(self):
"""Does the sequence of edges represent correct chain?"""
if len(self) < 2:
return False
for i in range(1, len(self.edges)):
if self.edges[i].start is not self.edges[i-1].end:
if self.verbose:
# This should be helpful to catch bugs
print(f'{self.__class__.__name__}::WARNING!::Edge sequence is not properly chained')
return False
return True
def fractions(self) -> list:
"""Fractions of the lengths of each edge in sequence w.r.t.
the whole sequence
"""
total_len = sum([e.length() for e in self.edges])
return [e.length() / total_len for e in self.edges]
def lengths(self) -> list:
"""Lengths of individual edges in the sequence"""
return [e.length() for e in self.edges]
def verts(self):
"""Return all vertex objects"""
verts = [self.edges[0].start]
for e in self.edges:
if e.start is not verts[-1]: # avoid adding the vertices of chained edges twice
verts.append(e.start)
verts.append(e.end)
if verts[0] is verts[-1]: # don't double count the loop origin
verts.pop(-1)
return verts
def shortcut(self):
"""Opening of an edge sequence as a vector
# NOTE May not reflect true shortcut if the egdes were flipped but
the order remained
"""
return np.array([self[0].start, self[-1].end])
def bbox(self):
"""
This function evaluates the 2D bounding box of the current panel and
returns the panel vertices which are located on the bounding box (
b_points).
Output:
* bbox (list): [min_x, max_x, min_y, max_y] of verts_2d
* b_points (list): list of 2D vertices representing the b_points,
i.e., the vertices of verts_2d located on the bounding box
"""
# Take linear version of the edges
# To correctly process edges with extreme curvatures
lin_edges = EdgeSequence([e.linearize() for e in self.edges])
verts_2d = np.asarray(lin_edges.verts())
mi = verts_2d.min(axis=0)
ma = verts_2d.max(axis=0)
xs = [mi[0], ma[0]]
ys = [mi[1], ma[1]]
#return points on bounding box
b_points = []
for v in verts_2d:
if v[0] in xs or v[1] in ys:
b_points.append(v)
if len(b_points) == 2:
if not any(np.array_equal(arr, mi) for arr in b_points):
b_points = [b_points[0], mi, b_points[1]]
else:
p = [mi[0],ma[1]]
b_points = [b_points[0],p,b_points[1]]
# FIXME Use one common order for the bbox output
bbox = [mi[0], ma[0], mi[1], ma[1]]
return bbox, b_points
# ANCHOR Modifiers
# All modifiers return self object to allow chaining
# Wrappers around python's list
def append(self, item):
if isinstance(item, Edge):
self.edges.append(item)
elif isinstance(item, list): # List of edge / EdgeSeq objects
for e in item:
self.append(e)
elif isinstance(item, EdgeSequence):
self.edges += item.edges
else:
raise ValueError(f'{self.__class__.__name__}::ERROR::Trying to add object of incompatible type {type(item)}')
return self
def insert(self, i, item):
if isinstance(item, Edge):
self.edges.insert(i, item)
elif isinstance(item, list) or isinstance(item, EdgeSequence):
for j in range(len(item)):
self.edges.insert(i + j, item[j])
else:
raise NotImplementedError(f'{self.__class__.__name__}::ERROR::incerting object of {type(item)} not suported (yet)')
return self
def pop(self, i):
if isinstance(i, Edge):
i = self.index(i)
self.edges.pop(i)
return self
def substitute(self, orig, new):
"""Remove orign item from the list and place seq into it's place
orig can be either an id of an item to remove
or an instance of Edge that exists in the current sequence
"""
if isinstance(orig, Edge):
orig = self.index(orig)
if orig < 0:
orig = len(self) + orig
self.pop(orig)
self.insert(orig, new)
return self
def reverse(self):
"""Reverse edge sequence in-place"""
self.edges.reverse()
for edge in self.edges:
edge.reverse()
return self
# EdgeSequence-specific
def translate_by(self, shift):
"""Translate the edge seq vertices s.t. the first vertex is at new_origin
"""
for v in self.verts():
v[0] += shift[0]
v[1] += shift[1]
return self
def snap_to(self, new_origin=None):
"""Translate the edge seq vertices s.t. the first vertex is at new_origin
"""
if new_origin is None:
new_origin = [0, 0]
start = copy(self[0].start)
shift = [new_origin[0] - start[0], new_origin[1] - start[1]]
self.translate_by(shift)
return self
def close_loop(self):
"""if edge loop is not closed, add and edge to close it"""
self.isChained() # print warning if smth is wrong
if not self.isLoop():
self.append(Edge(self[-1].end, self[0].start))
return self
def rotate(self, angle):
"""Rotate edge sequence by angle in place, using first point as a reference
Parameters:
angle -- desired rotation angle in radians (!)
"""
curr_start = copy(self[0].start)
# set the start point to zero
self.snap_to([0, 0])
rot = R2D(angle)
for v in self.verts():
v[:] = np.matmul(rot, v)
# recover the original location
self.snap_to(curr_start)
return self
def extend(self, factor):
"""Extend or shrink the edges along the line from start of the first
edge to the end of the last edge in sequence. The start of the first
edge remains fixed
"""
# TODOLOW Version With preservation of total length?
# TODOLOW Base extention factor on change in total length of edges rather
# than on the shortcut length
# FIXME extending by negative factor should be predictable (e.g. opposite direction of extention)
# Need to take the target line from the chained order
if not self.isChained():
chained_edges = self.chained_order()
chained_edges.isChained()
if chained_edges.isLoop():
print(f'{self.__class__.__name__}::WARNING::Extending looped edge sequences is not available')
return self
else:
chained_edges = self
target_line = np.array(chained_edges[-1].end) - np.array(chained_edges[0].start)
target_line = target_line / norm(target_line)
# gather vertices
verts_coords = self.verts()
nverts_coords = np.array(verts_coords)
# adjust their position based on projection to the target line
verts_projection = np.empty(nverts_coords.shape)
fixed = nverts_coords[0]
for i in range(nverts_coords.shape[0]):
verts_projection[i] = (nverts_coords[i] - fixed).dot(target_line) * target_line
new_verts = verts_coords - (1 - factor) * verts_projection
# Update vertex objects
for i in range(len(verts_coords)):
verts_coords[i][:] = new_verts[i]
return self
def reflect(self, v0, v1):
"""Reflect 2D points w.r.t. 1D line defined by two points"""
v0, v1 = np.asarray(v0), np.asarray(v1)
vec = np.asarray(v1) - np.asarray(v0)
vec = vec / norm(vec) # normalize
# https://demonstrations.wolfram.com/ReflectionMatrixIn2D/#more
Ref = np.array([
[1 - 2 * vec[1]**2, 2*vec[0]*vec[1]],
[2*vec[0]*vec[1], - 1 + 2 * vec[1]**2]
])
# translate -> reflect -> translate back
for v in self.verts():
v[:] = np.matmul(Ref, np.asarray(v) - v0) + v0
# Reflect edge features (curvatures, etc.)
for e in self.edges:
e.reflect_features()
return self
def propagate_label(self, label):
"""Propagate label to sub-edges
NOTE: Recommended to perform after all edge modification
operations (stitching, cutting, inserting) were completed
Support for edge label propagation through those operations is not (yet) implemented
# TODO Edge labels on cuts/reassemble in the
"""
for e in self.edges:
e.label = label
# ANCHOR New sequences & versions
def copy(self):
"""Create a copy of a current edge sequence preserving the chaining
property of edge sequences"""
new_seq = deepcopy(self)
# deepcopy recreates the vertex objects on both sides of the edges
# in changed edges those vertex objects are supposed to be shared
# by neighbor edges
for i in range(1, len(new_seq)):
if self[i].start is self[i-1].end:
new_seq[i].start = new_seq[i-1].end
if self.isLoop():
new_seq[-1].end = new_seq[0].start
return new_seq
def chained_order(self):
""" Attempt to restore a chain in the EdgeSequence
The chained edge sequence may lose its property if the edges
were reversed externally.
This routine created a copy of the correct sequence with aligned
the order of edges,
It might be useful for various calculations
"""
chained = self.copy()
for i in range(len(chained)):
# Assuming the previous one is already sorted
if i > 0 and chained[i].end is chained[i-1].end:
chained[i].reverse()
# Not connected to the previous one
elif (i + 1 < len(chained)
and (chained[i].start is chained[i+1].start or chained[i].start is chained[i+1].end)):
chained[i].reverse()
# not connected to anything or connected properly -- leave as is
return chained

View File

@@ -0,0 +1,499 @@
import numpy as np
from numpy.linalg import norm
import svgpathtools as svgpath
from scipy.optimize import minimize
from pygarment.garmentcode.edge import EdgeSequence, Edge, CurveEdge
from pygarment.garmentcode.edge import CircleEdge
from pygarment.garmentcode.utils import vector_angle
from pygarment.garmentcode.utils import bbox_paths
from pygarment.garmentcode.utils import close_enough
from pygarment.garmentcode.utils import c_to_list
from pygarment.garmentcode.utils import list_to_c
from pygarment.pattern.utils import rel_to_abs_2d, abs_to_rel_2d
class EdgeFactory:
@staticmethod
def from_svg_curve(seg):
"""Create Edge/CurveEdge/CircleEdge object from svgpath object
Type is determined by svgpath type
"""
start, end = c_to_list(seg.start), c_to_list(seg.end)
if isinstance(seg, svgpath.Line):
return Edge(start, end)
if isinstance(seg, svgpath.Arc):
# NOTE: assuming circular arc (same radius in both directoins)
radius = seg.radius.real
return CircleEdgeFactory.from_points_radius(
start, end, radius, seg.large_arc, seg.sweep
)
# Only Bezier left
if isinstance(seg, svgpath.QuadraticBezier):
cp = [c_to_list(seg.control)]
elif isinstance(seg, svgpath.CubicBezier):
cp = [c_to_list(seg.control1), c_to_list(seg.control2)]
else:
raise NotImplementedError(f'CurveEdge::ERROR::Incorrect curve type supplied {seg.type}')
return CurveEdge(start, end, cp, relative=False)
class CircleEdgeFactory:
@staticmethod
def from_points_angle(start, end, arc_angle, right=True):
"""Construct circle arc from two fixed points and an angle
arc_angle:
NOTE: Might fail on angles close to 2pi
"""
# Big or small arc
if arc_angle > np.pi:
arc_angle = 2 * np.pi - arc_angle
to_sum = True
else:
to_sum = False
radius = 1 / np.sin(arc_angle / 2) / 2
h = 1 / np.tan(arc_angle / 2) / 2
control_y = radius + h if to_sum else radius - h # relative control point
control_y *= -1 if right else 1
return CircleEdge(start, end, cy=control_y)
@staticmethod
def from_points_radius(start, end, radius, large_arc=False, right=True):
"""Construct circle arc relative representation
from two fixed points and an (absolute) radius
"""
# Find circle center
str_dist = norm(np.asarray(end) - np.asarray(start))
# NOTE: close enough values may give negative
# value under sqrt due to numerical errors
if close_enough(radius ** 2, str_dist ** 2 / 4, 1e-3):
center_r = 0.
else:
center_r = np.sqrt(radius ** 2 - str_dist ** 2 / 4)
# Find the absolute value of Y
control_y = radius + center_r if large_arc else radius - center_r
# Convert to relative
control_y = control_y / str_dist
# Flip sight according to "right" parameter
control_y *= -1 if right else 1
return CircleEdge(start, end, cy=control_y)
@staticmethod
def from_rad_length(rad, length, right=True, start=None):
"""NOTE: if start vertex is not provided, both vertices will be created
to match desired radius and length
"""
max_len = 2 * np.pi * rad
if length > max_len:
raise ValueError(
f'CircleEdge::ERROR::Incorrect length for specified radius')
large_arc = length > max_len / 2
if large_arc:
length = max_len - length
w_half = rad * np.sin(length / rad / 2)
edge = CircleEdgeFactory.from_points_radius(
[-w_half, 0], [w_half, 0],
radius=rad,
large_arc=large_arc,
right=right
)
if start:
edge.snap_to(start)
edge.start = start
return edge
@staticmethod
def from_three_points(start, end, point_on_arc, relative=False):
"""Create a circle arc from 3 points (start, end and any point on an arc)
NOTE: Control point specified in the same coord system as start and end
NOTE: points should not be on the same line
"""
if relative:
point_on_arc = rel_to_abs_2d(start, end, point_on_arc)
nstart, nend, npoint_on_arc = np.asarray(start), np.asarray(
end), np.asarray(point_on_arc)
# https://stackoverflow.com/a/28910804
# Using complex numbers to calculate the center & radius
x, y, z = list_to_c([start, point_on_arc, end])
w = z - x
w /= y - x
c = (x - y) * (w - abs(w) ** 2) / 2j / w.imag - x
# NOTE center = [c.real, c.imag]
rad = abs(c + x)
# Large/small arc
mid_dist = norm(npoint_on_arc - ((nstart + nend) / 2))
# Orientation
angle = vector_angle(npoint_on_arc - nstart, nend - nstart) # +/-
return CircleEdgeFactory.from_points_radius(
start, end, radius=rad,
large_arc=mid_dist > rad, right=angle > 0)
class CurveEdgeFactory:
@staticmethod
def curve_3_points(start, end, target, verbose=False):
"""Create (Quadratic) curve edge between start and end that
passes through the target point
"""
rel_target = abs_to_rel_2d(start, end, target)
if rel_target[0] > 1 or rel_target[0] < 0:
raise NotImplementedError(
"CurveEdgeFactory::Curve_by_3_points::ERROR::requested target point's projection "
"is outside of the base edge, which is not yet supported"
)
# Initialization with a target point as control point
# Ensures very smooth, minimal solution
out = minimize(
_fit_pass_point,
rel_target,
args=(rel_target)
)
if not out.success:
if verbose:
print('Curve From Extreme::WARNING::Optimization not successful')
print(out)
cp = out.x.tolist()
return CurveEdge(start, end, control_points=[cp], relative=True)
@staticmethod
def curve_from_tangents(start, end, target_tan0=None, target_tan1=None,
initial_guess=None, verbose=False):
"""Create Quadratic Bezier curve connecting given points with the target tangents
(both or any of the two can be specified)
NOTE: Target tangent vectors are automatically normalized
"""
if target_tan0 is not None:
target_tan0 = abs_to_rel_2d(start, end, target_tan0, as_vector=True)
target_tan0 /= norm(target_tan0)
if target_tan1 is not None:
target_tan1 = abs_to_rel_2d(start, end, target_tan1, as_vector=True)
target_tan1 /= norm(target_tan1)
# Initialization with a target point as control point
# Ensures very smooth, minimal solution
out = minimize(
_fit_tangents,
[0.5, 0] if initial_guess is None else initial_guess,
args=(target_tan0, target_tan1)
)
if not out.success:
print('CurveEdgeFactory::Curve From Tangents::WARNING::Optimization not successful')
if verbose:
print(out)
cp = out.x.tolist()
return CurveEdge(start, end, control_points=[cp], relative=True)
class EdgeSeqFactory:
"""Create EdgeSequence objects for some common edge sequence patterns
"""
@staticmethod
def from_svg_path(path: svgpath.Path, dist_tol=0.05, verbose=False):
"""Convert SVG path given as svgpathtool Path object to an EdgeSequence
* dist_tol: tolerance for vertex closeness to be considered the same
vertex
NOTE: Assumes that the path can be chained
"""
# Convert as is
edges = []
for seg in path._segments:
# skip segments of length zero
if close_enough(seg.length(), tol=dist_tol):
if verbose:
print('Skipped: ', seg)
continue
edges.append(EdgeFactory.from_svg_curve(seg))
# Chain the edges
if len(edges) > 1:
for i in range(1, len(edges)):
if not all(close_enough(s, e, tol=dist_tol)
for s, e in zip(edges[i].start, edges[i - 1].end)):
raise ValueError(
'EdgeSequence::from_svg_path::input path is not chained')
edges[i].start = edges[i - 1].end
return EdgeSequence(*edges, verbose=verbose)
@staticmethod
def from_verts(*verts, loop=False):
"""Generate sequence of straight edges from given vertices. If loop==True,
the method also closes the edge sequence as a loop
"""
seq = EdgeSequence(Edge(verts[0], verts[1]))
for i in range(2, len(verts)):
seq.append(Edge(seq[-1].end, verts[i]))
if loop:
seq.append(Edge(seq[-1].end, seq[0].start))
seq.isChained() # print warning if smth is wrong
return seq
@staticmethod
def from_fractions(start, end, frac=None):
"""A sequence of edges between start and end wich lengths are distributed
as specified in frac list
Parameters:
* frac -- list of legth fractions. Every entry is in (0, 1],
all entries sums up to 1
"""
frac = [abs(f) for f in frac]
if not close_enough(fsum := sum(frac), 1, 1e-4):
raise RuntimeError(f'EdgeSequence::ERROR::fraction is incorrect. The sum {fsum} is not 1')
vec = np.asarray(end) - np.asarray(start)
verts = [start]
for i in range(len(frac) - 1):
verts.append(
[verts[-1][0] + frac[i]*vec[0],
verts[-1][1] + frac[i]*vec[1]]
)
verts.append(end)
return EdgeSeqFactory.from_verts(*verts)
@staticmethod
def side_with_cut(start=(0, 0), end=(1, 0), start_cut=0, end_cut=0):
""" Edge with internal vertices that allows to stitch only part of the border represented
by the long side edge
start_cut and end_cut specify the fraction of the edge to to add extra vertices at
"""
nstart, nend = np.array(start), np.array(end)
verts = [start]
if start_cut > 0:
verts.append((start + start_cut * (nend-nstart)).tolist())
if end_cut > 0:
verts.append((end - end_cut * (nend-nstart)).tolist())
verts.append(end)
edges = EdgeSeqFactory.from_verts(*verts)
return edges
# ------ Darts ------
@staticmethod
def dart_shape(width, side_len=None, depth=None):
"""Shape of simple triangular dart:
specified by desired width and either the dart side length or depth
"""
if side_len is None and depth is None:
raise ValueError(
'EdgeFactory::ERROR::dart shape is not fully specified.'
' Add dart side length or dart perpendicular'
)
if depth is None:
if width / 2 > side_len:
raise ValueError(
f'EdgeFactory::ERROR::Requested dart shape (w={width}, side={side_len}) '
'does not form a valid triangle')
depth = np.sqrt((side_len**2 - (width / 2)**2))
return EdgeSeqFactory.from_verts([0, 0], [width / 2, -depth], [width, 0])
# --- SVG ----
@staticmethod
def halfs_from_svg(svg_filepath, target_height=None):
"""Load a shape from an SVG and split it in half (vertically)
* target_height -- scales the shape s.t. it's height matches the given
number
Shapes restrictions:
1) every path in the provided SVG is assumed to form a closed loop
that has exactly 2 intersection points with a vertical line
passing though the middle of the shape
2) The paths should not be nested (inside each other) or intersect
as to not create disconnected pieces of the edge when used in
shape projection
"""
paths, _ = svgpath.svg2paths(svg_filepath)
# Scaling
if target_height is not None:
bbox = bbox_paths(paths)
scale = target_height / (bbox[-1] - bbox[-2])
paths = [p.scaled(scale) for p in paths]
# Get the half-shapes
left, right = split_half_svg_paths(paths)
# Turn into Edge Sequences
left_seqs = [EdgeSeqFactory.from_svg_path(p) for p in left]
right_seqs = [EdgeSeqFactory.from_svg_path(p) for p in right]
# In SVG OY is looking downward, we are using OY looking upward
# Flip the shape to align
bbox = bbox_paths(paths)
center_y = (bbox[2] + bbox[3]) / 2
left_seqs = [p.reflect([bbox[0], center_y],
[bbox[1], center_y]) for p in left_seqs]
right_seqs = [p.reflect([bbox[0], center_y],
[bbox[1], center_y]) for p in right_seqs]
# Edge orientation s.t. the shortcut directions align with OY
# It preserves the correct relative placement of the shapes later
for p in left_seqs:
if (p.shortcut()[1][1] - p.shortcut()[0][1]) < 0:
p.reverse()
for p in right_seqs:
if (p.shortcut()[1][1] - p.shortcut()[0][1]) < 0:
p.reverse()
return left_seqs, right_seqs
# --- For Curves ---
def _fit_pass_point(cp, target_location):
""" Fit the control point of basic [[0, 0] -> [1, 0]] Quadratic Bezier s.t.
it passes through the target location.
* cp - initial guess for Quadratic Bezier control point coordinates
(relative to the edge)
* target_location -- target to fit extremum to --
expressed in RELATIVE coordinates to your desired edge
"""
control_bezier = np.array([
[0, 0],
cp,
[1, 0]
])
params = list_to_c(control_bezier)
curve = svgpath.QuadraticBezier(*params)
inter_segment = svgpath.Line(
target_location[0] + 1j * target_location[1] * 2,
target_location[0] + 1j * (- target_location[1] * 2)
)
intersect_t = curve.intersect(inter_segment)
point = curve.point(intersect_t[0][0])
diff = abs(point - list_to_c(target_location))
return diff**2
def _fit_tangents(cp, target_tangent_start, target_tangent_end, reg_strength=0.01):
""" Fit the control point of basic [[0, 0] -> [1, 0]] Quadratic Bezier s.t.
it's expremum is close to target location.
* cp - initial guess for Quadratic Bezier control point coordinates
(relative to the edge)
* target_location -- target to fit extremum to --
expressed in RELATIVE coordinates to your desired edge
"""
control_bezier = np.array([
[0, 0],
cp,
[1, 0]
])
params = list_to_c(control_bezier)
curve = svgpath.QuadraticBezier(*params)
fin = 0
if target_tangent_start is not None:
# NOTE: tangents seems to use opposite left/right convention
target0 = target_tangent_start[0] + 1j*target_tangent_start[1]
fin += (abs(curve.unit_tangent(0) - target0))**2
if target_tangent_end is not None:
target1 = target_tangent_end[0] + 1j*target_tangent_end[1]
fin += (abs(curve.unit_tangent(1) - target1))**2
# NOTE: Tried _max_curvature() and Y value regularizaton,
# but it seems like they are not needed
return fin
# ---- For SVG Loading ----
def split_half_svg_paths(paths):
"""Sepate SVG paths in half over the vertical line -- for insertion into an
edge side
Paths shapes restrictions:
1) every path in the provided list is assumed to form a closed loop
that has
exactly 2 intersection points with a vetrical line passing though the
middle of the shape
2) The paths geometry should not be nested
as to not create disconnected pieces of the edge when used in
shape projection
"""
# Shape Bbox
bbox = bbox_paths(paths)
center_x = (bbox[0] + bbox[1]) / 2
# Mid-Intersection
inter_segment = svgpath.Line(
center_x + 1j * bbox[2],
center_x + 1j * bbox[3]
)
right, left = [], []
for p in paths:
# Intersect points
intersect_t = p.intersect(inter_segment)
if len(intersect_t) != 2:
raise ValueError(f'SplitSVGHole::ERROR::Each Provided Svg path should cross vertical like exactly 2 times')
# Split
from_T, to_T = intersect_t[0][0][0], intersect_t[1][0][0]
if to_T < from_T:
from_T, to_T = to_T, from_T
side_1 = p.cropped(from_T, to_T)
# This order should preserve continuity
side_2 = svgpath.Path(
*p.cropped(to_T, 1)._segments,
*p.cropped(0, from_T)._segments)
# Collect correctly
if side_1.bbox()[2] > center_x:
side_1, side_2 = side_2, side_1
right.append(side_2)
left.append(side_1)
return left, right

View File

@@ -0,0 +1,365 @@
from copy import copy
from numpy.linalg import norm
import numpy as np
from pygarment.garmentcode.edge import EdgeSequence, Edge
from pygarment.garmentcode.utils import close_enough
class Interface:
"""Description of an interface of a panel or component
that can be used in stitches as a single unit
"""
def __init__(self, panel, edges, ruffle=1., right_wrong=False):
"""
Parameters:
* panel - Panel object
* edges - Edge or EdgeSequence -- edges in the panel that are
allowed to connect to
# TODO rename to something more generic/projection related?
* ruffle - ruffle coefficient for a particular edge. Interface
object will supply projecting_edges() shape
s.t. the ruffles with the given rate are created. Default = 1.
(no ruffles, smooth connection)
* right_wrong -- control of stitch orientation -- indication if this interface's
right side of the fabric should be connected to the wrong side of another interface.
Default -- False -- connect right side of the fabric to the right side of the faric,
sufficient in most cases.
"""
self.edges = edges if isinstance(edges, EdgeSequence) else EdgeSequence(edges)
self.panel = [panel for _ in range(len(self.edges))] # matches every edge
self.right_wrong = [right_wrong for _ in range(len(self.edges))]
# Allow to enfoce change the direction of edge
# (used in many-to-many stitches correspondance determination)
self.edges_flipping = [False for _ in range(len(self.edges))]
# Ruffles are applied to sections
# Since extending a chain of edges != extending each edge individually
if isinstance(ruffle, list):
assert len(ruffle) == len(edges), "Ruffles and Edges don't match"
self.ruffle = []
last_coef = None
last_start = 0
for i, coef in enumerate(ruffle):
if coef == last_coef or last_coef is None:
last_coef = coef # Making sure to overwrite None
continue
self.ruffle.append(dict(coeff=last_coef, sec=[last_start, i]))
last_start, last_coef = i, coef
self.ruffle.append(dict(coeff=last_coef, sec=[last_start, len(ruffle)]))
else:
self.ruffle = [dict(coeff=ruffle, sec=[0, len(self.edges)])]
def projecting_edges(self, on_oriented=False) -> EdgeSequence:
"""Return edges shape that should be used when projecting interface
onto another panel
NOTE: reflects current state of the edge object. Call this function
again if egdes change (e.g. their direction)
# FIXME projection only works w.r.t. the line connecting the first and
# the last vertex of the edge sequence -> use with cation
"""
# Per edge set ruffle application
projected = self.edges.copy() if not on_oriented else self.oriented_edges()
for r in self.ruffle:
if not close_enough(r['coeff'], 1, 1e-3):
if r['sec'][1] >= len(projected) or not projected[r['sec'][1] - 1:r['sec'][1] + 1].isChained():
projected[r['sec'][0]:r['sec'][1]].extend(1 / r['coeff'])
else:
# Don't let extention to affect the rest of the sequence
# Find the vert that separates the ruffle seqences
prev_edge, next_edge = projected[r['sec'][1] - 1], projected[r['sec'][1]]
# Common vertex
common_v = prev_edge.end if prev_edge.end is next_edge.end or prev_edge.end is next_edge.start else prev_edge.start
# Create copy and assign to next edge
common_v_copy = common_v.copy()
copy_to_end = False
if common_v is next_edge.end:
next_edge.end = common_v_copy
copy_to_end = True
else:
next_edge.start = common_v_copy
# Extend the sequence
projected[r['sec'][0]:r['sec'][1]].extend(1 / r['coeff'])
# move the next edges s.t. created vertex alignes with the original common vertex
projected[r['sec'][1]:].translate_by([common_v[0] - common_v_copy[0], common_v[1] - common_v_copy[1]])
# re-chain the edges
if copy_to_end:
next_edge.end = common_v
else:
next_edge.start = common_v
return projected
# ANCHOR --- Projections for stitching -- to connect edges correctly and create correct ruffles
def projecting_lengths(self):
"""Desired projected length of the interface edges, as specified by ruffle coefficients"""
projecting_lengths = []
for r in self.ruffle:
if not close_enough(r['coeff'], 1, 1e-3):
projecting_lengths += [e.length() / r['coeff'] for e in self.edges[r['sec'][0]:r['sec'][1]]]
else:
projecting_lengths += [e.length() for e in self.edges[r['sec'][0]:r['sec'][1]]]
return np.array(projecting_lengths)
def projecting_fractions(self):
"""Desired projected fractions of the interface edges, as specified by ruffle coefficients
Fractions calculated w.r.t. to total projection lengths
"""
projecting_lengths = self.projecting_lengths()
return projecting_lengths / projecting_lengths.sum()
def needsFlipping(self, i):
""" Check if particular edge (i) should be re-oriented to follow the
general direction of the interface
* tol -- tolerance in distance differences that triggers flipping (in cm)
"""
return self.edges_flipping[i]
# ANCHOR --- Info ----
def oriented_edges(self):
""" Orient the edges withing the interface sequence along the general
direction of the interface
Creates a copy of the interface s.t. not to disturb the original
edge objects
"""
# NOTE we cannot we do the same for the edge sub-sequences:
# - midpoint of a sequence is less representative
# - more likely to have weird relative 3D orientations
# => heuristic won't work as well
oriented = self.edges.copy()
for i in range(len(self.edges)):
if self.needsFlipping(i):
oriented[i].reverse()
oriented[i].flipped = True
else:
oriented[i].flipped = False
return oriented
def verts_3d(self):
"""Return 3D locations of all vertices that participate in the
interface"""
verts_2d = []
matching_panels = []
for e, panel in zip(self.edges, self.panel):
if all(e.start is not v for v in verts_2d): # Ensuring uniqueness
verts_2d.append(e.start)
matching_panels.append(panel)
if all(e.end is not v for v in verts_2d): # Ensuring uniqueness
verts_2d.append(e.end)
matching_panels.append(panel)
# To 3D
verts_3d = []
for v, panel in zip(verts_2d, matching_panels):
verts_3d.append(panel.point_to_3D(v))
return np.asarray(verts_3d)
def bbox_3d(self):
"""Return Interface bounding box"""
# NOTE: Vertex repetitions don't matter for bbox evaluation
verts_3d = []
for e, panel in zip(self.edges, self.panel):
# Using curve linearization for more accurate approximation of bbox
lin_edges = e.linearize()
verts_2d = lin_edges.verts()
verts_3d += [panel.point_to_3D(v) for v in verts_2d]
verts_3d = np.asarray(verts_3d)
return verts_3d.min(axis=0), verts_3d.max(axis=0)
def __len__(self):
return len(self.edges)
def __str__(self) -> str:
return f'Interface: {[p.name for p in self.panel]}: {str(self.oriented_edges())}'
def __repr__(self) -> str:
return self.__str__()
def panel_names(self):
return [p.name for p in self.panel]
# ANCHOR --- Interface Updates -----
def reverse(self, with_edge_dir_reverse=False):
"""Reverse the order of edges in the interface
(without updating the edge objects)
Reversal is useful for reordering interface edges for correct
matching in the multi-stitches
"""
self.edges.edges.reverse()
self.panel.reverse()
self.edges_flipping.reverse()
if with_edge_dir_reverse:
self.edges_flipping = [not e for e in self.edges_flipping]
enum = len(self.edges)
for r in self.ruffle:
# Update ids
r['sec'][0] = enum - r['sec'][0]
r['sec'][1] = enum - r['sec'][1]
# Swap
r['sec'][0], r['sec'][1] = r['sec'][1], r['sec'][0]
return self
def flip_edges(self):
"""Reverse the direction of edges in the interface
(without updating the edge objects)
Reversal is useful for updating interface edges for correct
matching in the multi-stitches
"""
self.edges_flipping = [not e for e in self.edges_flipping]
return self
# TODO Edge Sequence Function?
def reorder(self, curr_edge_ids, projected_edge_ids):
"""Change the order of edges from curr_edge_ids to projected_edge_ids
in the interface
Note that the input should prescrive new ordering for all affected
edges e.g. if moving 0 -> 1, specify the new location for 1 as well
"""
for i, j in zip(curr_edge_ids, projected_edge_ids):
for r in self.ruffle:
if (i >= r['sec'][0] and i < r['sec'][1]
and (j < r['sec'][0] or j >= r['sec'][1])):
raise NotImplementedError(
f'{self.__class__.__name__}::ERROR::reordering between panel-related sub-segments is not supported')
new_edges = EdgeSequence()
new_panel_list = []
new_flipping_info = []
new_right_wrong = []
for i in range(len(self.panel)):
id = i if i not in curr_edge_ids else projected_edge_ids[curr_edge_ids.index(i)]
# edges
new_edges.append(self.edges[id])
new_flipping_info.append(self.edges_flipping[id])
# panels
new_panel_list.append(self.panel[id])
# connectivity indication
new_right_wrong.append(self.right_wrong[id])
self.edges = new_edges
self.panel = new_panel_list
self.edges_flipping = new_flipping_info
self.right_wrong = new_right_wrong
def substitute(self, orig, new_edges, new_panels):
"""Update the interface edges with correct correction of panels
* orig -- could be an edge object or the id of edges that need
substitution
* new_edges -- new edges to insert in place of orig
* new_panels -- per-edge panel objects indicating where each of
new_edges belong to
NOTE: the ruffle indicator for the new_edges is expected to be the
same as for orig edge
Specifying new indicators is not yet supported
"""
if isinstance(orig, Edge):
orig = self.edges.index(orig)
if orig < 0:
orig = len(self.edges) + orig
self.edges.substitute(orig, new_edges)
# Update panels & flip info & right_wrong info
self.panel.pop(orig)
curr_edges_flip = self.edges_flipping.pop(orig)
curr_right_wrong = self.right_wrong.pop(orig)
if isinstance(new_panels, list) or isinstance(new_panels, tuple):
for j in range(len(new_panels)):
self.panel.insert(orig + j, new_panels[j])
# TODOLOW Note propagation of default values. Allow to specify them as func input!
self.edges_flipping.insert(orig + j, curr_edges_flip)
self.right_wrong.insert(orig + j, curr_right_wrong)
else:
self.panel.insert(orig, new_panels)
self.edges_flipping.insert(orig, curr_edges_flip)
self.right_wrong.insert(orig + j, curr_right_wrong)
# Propagate ruffle indicators
ins_len = 1 if isinstance(new_edges, Edge) else len(new_edges)
if ins_len > 1:
for it in self.ruffle: # UPD ruffle indicators
if it['sec'][0] > orig:
it['sec'][0] += ins_len - 1
if it['sec'][1] > orig:
it['sec'][1] += ins_len - 1
return self
def set_right_wrong(self, right_wrong):
"""Set all right_wrong values for the edges in current interface to the input value"""
self.right_wrong = [right_wrong for _ in range(len(self.edges))]
return self
# ANCHOR ----- Statics ----
@staticmethod
def from_multiple(*ints):
"""Create interface from other interfaces:
* Allows to use different panels in one interface
* different ruffle values in one interface
# NOTE the relative order of edges is preserved from the
original interfaces and the incoming interface sequence
This order will then be used in the SrtitchingRule when
determing connectivity between interfaces
"""
new_int = copy(ints[0]) # shallow copy -- don't create unnecessary objects
new_int.edges = EdgeSequence()
new_int.edges_flipping = []
new_int.panel = []
new_int.ruffle = []
new_int.right_wrong = []
for elem in ints:
shift = len(new_int.edges)
new_int.ruffle += [copy(r) for r in elem.ruffle]
for r in new_int.ruffle[-len(elem.ruffle):]:
r.update(sec=[r['sec'][0] + shift, r['sec'][1] + shift])
new_int.edges.append(elem.edges)
new_int.panel += elem.panel
new_int.right_wrong += elem.right_wrong
new_int.edges_flipping += elem.edges_flipping
return new_int
@staticmethod
def _is_order_matching(panel_s, vert_s, panel_1, vert1, panel_2, vert2) -> bool:
"""Check which of the two vertices vert1 (panel_1) or vert2 (panel_2)
is closer to the vert_s
from panel_s in 3D"""
s_3d = panel_s.point_to_3D(vert_s)
v1_3d = panel_1.point_to_3D(vert1)
v2_3d = panel_2.point_to_3D(vert2)
return norm(v1_3d - s_3d) < norm(v2_3d - s_3d)

View File

@@ -0,0 +1,606 @@
"""Shortcuts for common operations on panels and components"""
from copy import deepcopy, copy
import numpy as np
from numpy.linalg import norm
from scipy.spatial.transform import Rotation as R
from scipy.optimize import minimize
import svgpathtools as svgpath
from pygarment.garmentcode.edge import Edge, CurveEdge, EdgeSequence, ILENGTH_S_TOL
from pygarment.garmentcode.interface import Interface
from pygarment.garmentcode.utils import vector_angle, close_enough, c_to_list, c_to_np
from pygarment.garmentcode.utils import list_to_c
from pygarment.garmentcode.base import BaseComponent
# ANCHOR ----- Edge Sequences Modifiers ----
def cut_corner(target_shape: EdgeSequence, target_interface: Interface,
verbose: bool = False):
""" Cut the corner made of edges 1 and 2 following the shape of target_shape
This routine updated the panel geometry and interfaces appropriately
Parameters:
* 'target_shape' is an EdgeSequence that is expected to contain one
Edge or sequence of chained Edges
(next one starts from the end vertex of the one before)
# NOTE: 'target_shape' might be scaled (along the main direction)
to fit the corner size
* Panel to modify
* target_interface -- the chained pairs of edges that form the corner
to cut, s.t. the end vertex of eid1 is at the corner
# NOTE: Onto edges are expected to be straight lines for simplicity
# NOTE There might be slight computational errors in the resulting
shape, that are more pronounced on svg visualizations due to
scaling and rasterization
Side-Effects:
* Modified the panel shape to insert new edges
* Adds new interface object corresponding to new edges to the
panel interface list
Returns:
* Newly inserted edges
* New interface object corresponding to new edges
"""
# TODO Support any number of edges in the target corner edges
# ---- Evaluate optimal projection of the target shape onto the corner
corner_shape = target_shape.copy()
panel = target_interface.panel[0] # TODO Support multiple panels???
target_edges = target_interface.edges
# Get rid of directions by working on vertices
if target_edges[0].start is target_edges[-1].end:
# Orginal edges have beed reversed in normalization or smth
target_edges.edges.reverse() # UPD the order
if corner_shape[0].start is corner_shape[-1].end:
# Orginal edges have beed reversed in normalization or smth
corner_shape.edges.reverse() # UPD the order
if corner_shape[0].start[1] > corner_shape[-1].end[1]:
# now corner shape is oriented the same way as vertices
corner_shape.reverse()
corner_shape.snap_to([0,0])
shortcut = corner_shape.shortcut()
# Curves (can be defined outside)
curve1 = target_edges[0].as_curve()
curve2 = target_edges[1].as_curve()
# align order with the projecting shape, s.t.
# curve2 is always the lower one
swaped = False
if target_edges[0].start[1] > target_edges[-1].end[1]:
curve1, curve2 = curve2, curve1
swaped = True
# NOW curve1 is lower then curve2
# ----- FIND OPTIMAL PLACE -----
start = [0.5, 0.5]
out = minimize(
_fit_location_corner, start,
args=(shortcut[1] - shortcut[0], curve1, curve2),
bounds=[(0, 1), (0, 1)])
if verbose and not out.success:
print(f'Cut_corner::ERROR::finding the projection (translation) is unsuccessful. Likely an error in edges choice')
print(out)
if verbose and not close_enough(out.fun):
print(f'Cut_corner::WARNING::projection on {target_interface} finished with fun={out.fun}')
print(out)
loc = out.x
point1 = c_to_list(curve1.point(loc[0]))
# re-align corner_shape with found shifts
corner_shape.snap_to(point1)
# ----- UPD panel ----
# Complete to the full corner -- connect with the initial vertices
if swaped:
# The edges are aligned as v2 -> vc -> v1
corner_shape.reverse()
loc[0], loc[1] = loc[1], loc[0]
# Insert a new shape
cut_edge1, _ = target_edges[0].subdivide_param([loc[0], 1-loc[0]])
_, cut_edge2 = target_edges[1].subdivide_param([loc[1], 1-loc[1]])
cut_edge1.end = corner_shape[0].start # Connect with new insert
cut_edge2.start = corner_shape[-1].end
corner_shape.insert(0, cut_edge1)
corner_shape.append(cut_edge2)
# Substitute edges in the panel definition
panel.edges.pop(target_edges[0])
panel.edges.substitute(target_edges[1], corner_shape)
# Update interface definitions
target_edges = EdgeSequence(target_edges.edges) # keep the same edge references,
# but not the same edge sequence reference
# In case it matches one of the interfaces (we don't want target edges to be overriden)
iter = panel.interfaces if isinstance(panel.interfaces, list) else panel.interfaces.values()
for intr in iter:
# Substitute old edges with what's left from them after cutting
if target_edges[0] in intr.edges:
intr.edges.substitute(target_edges[0], corner_shape[0])
if target_edges[1] in intr.edges:
intr.edges.substitute(target_edges[1], corner_shape[-1])
# Add new interface corresponding to the introduced cut
new_int = Interface(panel, corner_shape[1:-1])
if isinstance(panel.interfaces, list):
panel.interfaces.append(new_int)
else:
panel.interfaces[f'int_{len(panel.interfaces)}'] = new_int
return corner_shape[1:-1], new_int
def cut_into_edge(target_shape, base_edge:Edge, offset=0, right=True,
flip_target=False, tol=1e-2):
""" Insert edges of the target_shape into the given base_edge, starting
from offset edges in target shape are rotated s.t. start -> end
vertex vector is aligned with the edge
NOTE: Supports making multiple cuts in one go maintaining the relative
distances between cuts
provided that
* they are all specified in the same coordinate system
* (for now) the openings (shortcuts) of each cut are aligned with
OY direction
Parameters:
* target_shape -- list of single edge, chained edges, or multiple
chaind EdgeSequences to be inserted in the edge.
* base_edge -- edge object, defining the border
* Offset -- position of the center of the target shape along the edge.
* right -- which direction the cut should be oriented w.r.t. the
direction of base edge
* flip_target -- reflect the shape w.r.t its central perpendicular
(default=False, no action taken)
Returns:
* Newly created edges that accomodate the cut
* Edges corresponding to the target shape
* Edges that lie on the original base edge
"""
# TODO Not only for Y-aligned shapes
# TODOLOW Add a parameter: Align target_shape by center or from the start of the offset
# NOTE: the optimization routine might be different for the two options
if isinstance(target_shape, EdgeSequence):
return cut_into_edge_single(
target_shape, base_edge, offset, right, tol)
# center of the shape
shortcuts = np.asarray([e.shortcut() for e in target_shape])
median_y = (shortcuts[:, :, 1].max() + shortcuts[:, :, 1].min()) / 2
# Flip the shapes if requested
if flip_target:
target_shape = [s.copy() for s in target_shape]
# Flip
target_shape = [s.reflect([0, median_y], [1, median_y])
for s in target_shape]
# Flip the order as well to reflect orientation change
target_shape = [s.reverse() for s in target_shape]
# Calculate relative offsets to place the whole shape at the target offset
shortcuts = np.asarray([e.shortcut() for e in target_shape])
rel_offsets = [(s[0][1] + s[1][1]) / 2 - median_y for s in shortcuts]
per_seq_offsets = [offset + r for r in rel_offsets]
# Project from farthest to closest
sorted_tup = sorted(zip(per_seq_offsets, target_shape), reverse=True)
proj_edge, int_edges = base_edge, EdgeSequence(base_edge)
new_in_edges = EdgeSequence()
all_new_edges = EdgeSequence(base_edge)
for off, shape in sorted_tup:
new_edge, in_edges, new_interface = cut_into_edge(
shape, proj_edge, offset=off, right=right, tol=tol)
all_new_edges.substitute(proj_edge, new_edge)
int_edges.substitute(proj_edge, new_interface)
new_in_edges.append(in_edges)
proj_edge = new_edge[0]
return all_new_edges, new_in_edges, int_edges
def cut_into_edge_single(target_shape, base_edge: Edge, offset=0, right=True,
tol=1e-2, verbose: bool = False):
""" Insert edges of the target_shape into the given base_edge, starting
from offset
edges in target shape are rotated s.t. start -> end vertex vector is
aligned with the edge
Parameters:
* target_shape -- list of single edge or chained edges to be inserted
in the edge.
* base_edge -- edge object, defining the border
* right -- which direction the cut should be oriented w.r.t. the
direction of base edge
* Offset -- position of the center of the target shape along the edge.
Returns:
* Newly created edges that accommodate the cut
* Edges corresponding to the target shape
* Edges that lie on the original base edge
"""
target_shape = EdgeSequence(target_shape)
new_edges = target_shape.copy().snap_to([0, 0]) # copy and normalize translation of vertices
# Simplify to vectors
shortcut = new_edges.shortcut() # "Interface" of the shape to insert
target_shape_w = norm(shortcut)
edge_len = base_edge.length()
if offset < target_shape_w / 2 - tol or offset > (edge_len - target_shape_w / 2) + tol:
# NOTE: This is not a definitive check, and the cut might still not fit, depending on the base_edge curvature
raise ValueError(f'Operators-CutingIntoEdge::ERROR::offset value is not within the base_edge length')
# find starting vertex for insertion & place edges there
curve = base_edge.as_curve()
rel_offset = curve.ilength(offset, s_tol=ILENGTH_S_TOL)
# ----- OPTIMIZATION ---
start = [0.1, 0.1]
out = minimize(
_fit_location_edge, start,
args=(rel_offset, target_shape_w, curve),
bounds=[(0, 1)])
shift = out.x
# Error checks
if verbose and not out.success:
print(f'Cut_edge::ERROR::finding the projection (translation) is unsuccessful. Likely an error in edges choice')
if not close_enough(out.fun, tol=0.01):
if verbose:
print(out)
raise RuntimeError(f'Cut_edge::ERROR::projection on {base_edge} finished with fun={out.fun}')
if rel_offset + shift[0] > 1 + tol or (rel_offset - shift[1]) < 0 - tol:
raise RuntimeError(
f'Cut_edge::ERROR::projection on {base_edge} is out of edge bounds: '
f'[{rel_offset - shift[1], rel_offset + shift[0]}].'
' Check the offset value')
# All good -- integrate the target shape
ins_point = c_to_np(curve.point(rel_offset - shift[1])) if (rel_offset - shift[1]) > tol else base_edge.start
fin_point = c_to_np(curve.point(rel_offset + shift[0])) if (rel_offset + shift[0]) < 1 - tol else base_edge.end
# Align the shape with an edge
# find rotation to apply on target shape
insert_vector = np.asarray(fin_point) - np.asarray(ins_point)
angle = vector_angle(shortcut[1] - shortcut[0], insert_vector)
new_edges.rotate(angle)
# place
new_edges.snap_to(ins_point)
# Check orientation
avg_vertex = np.asarray(new_edges.verts()).mean(0)
right_position = np.sign(np.cross(insert_vector, avg_vertex - np.asarray(new_edges[0].start))) == -1
if not right and right_position or right and not right_position:
# flip shape to match the requested direction
new_edges.reflect(new_edges[0].start, new_edges[-1].end)
# Integrate edges
# NOTE: no need to create extra edges if the the shape is incerted right at the beggining or end of the edge
base_edge_leftovers = EdgeSequence()
start_id, end_id = 0, len(new_edges)
if ins_point is base_edge.start:
new_edges[0].start = base_edge.start # Connect into the original edge
else:
# TODOLOW more elegant subroutine
start_part = base_edge.subdivide_param([rel_offset - shift[1], 1 - (rel_offset - shift[1])])[0]
start_part.end = new_edges[0].start
new_edges.insert(0, start_part)
base_edge_leftovers.append(new_edges[0])
start_id = 1
if fin_point is base_edge.end:
new_edges[-1].end = base_edge.end # Connect into the original edge
else:
end_part = base_edge.subdivide_param([rel_offset + shift[0], 1 - (rel_offset + shift[0])])[-1]
end_part.start = new_edges[-1].end
new_edges.append(end_part)
base_edge_leftovers.append(new_edges[-1])
end_id = -1
return new_edges, new_edges[start_id:end_id], base_edge_leftovers
def _fit_location_corner(l, diff_target, curve1, curve2,
verbose: bool = False):
"""Find the points on two curves s.t. vector between them is the same as
shortcut"""
# Current points on curves
point1 = c_to_np(curve1.point(l[0]))
point2 = c_to_np(curve2.point(l[1]))
diff_curr = point2 - point1
if verbose:
print('Location Progression: ', (diff_curr[0] - diff_target[0])**2,
(diff_curr[1] - diff_target[1])**2)
return ((diff_curr[0] - diff_target[0])**2
+ (diff_curr[1] - diff_target[1])**2)
def _fit_location_edge(shift, location, width_target, curve,
verbose: bool = False):
"""Find the points on two curves s.t. vector between them is the same as
shortcut"""
# Current points on curves
pointc = c_to_np(curve.point(location)) # TODO this is constant
point1 = c_to_np(curve.point(location + shift[0]))
point2 = c_to_np(curve.point(location - shift[1]))
if verbose:
print('Location Progression: ', (_dist(point1, point2) - width_target)**2)
# regularize points to be at the same distance from center
reg_symmetry = (_dist(point1, pointc) - _dist(point2, pointc))**2
return (_dist(point1, point2) - width_target)**2 + reg_symmetry
# ANCHOR ----- Panel operations ------
def distribute_Y(component, n_copies, odd_copy_shift=0, name_tag='panel'):
"""Distribute copies of component over the circle around Oy"""
copies = [ component ]
component.name = f'{name_tag}_0' # Unique
delta_rotation = R.from_euler('XYZ', [0, 360 / n_copies, 0], degrees=True)
for i in range(n_copies - 1):
new_component = deepcopy(copies[-1])
new_component.name = f'{name_tag}_{i + 1}' # Unique
new_component.rotate_by(delta_rotation)
new_component.translate_to(delta_rotation.apply(new_component.translation))
copies.append(new_component)
# shift around to resolve collisions (hopefully)
if odd_copy_shift:
for i in range(n_copies):
if not i % 2:
copies[i].translate_by(copies[i].norm() * odd_copy_shift)
return copies
def distribute_horisontally(component, n_copies, stride=20, name_tag='panel'):
"""Distribute copies of component over the straight horisontal line
perpendicular to the norm"""
copies = [ component ]
component.name = f'{name_tag}_0' # Unique
if isinstance(component, BaseComponent):
translation_dir = component.rotation.apply([0, 0, 1]) # Horisontally along the panel
# FIXME What if it's looking up?
translation_dir = np.cross(translation_dir, [0, 1, 0]) # perpendicular to Y
translation_dir = translation_dir / norm(translation_dir)
delta_translation = translation_dir * stride
else:
translation_dir = [1, 0, 0]
for i in range(n_copies - 1):
new_component = deepcopy(copies[-1]) # TODO proper copy
new_component.name = f'{name_tag}_{i + 1}' # Unique
new_component.translate_by(delta_translation)
copies.append(new_component)
return copies
# ANCHOR ----- Sleeve support -----
def even_armhole_openings(front_opening, back_opening, tol=1e-2, verbose: bool = False):
"""
Rearrange sleeve openings for front and back s.t. their projection
on vertical line is the same, while preserving the overall shape.
Allows for creation of two symmetric sleeve panels from them
!! Important: assumes that the front opening is longer then back opening
"""
# Construct sleeve panel shapes from opening inverses
cfront, cback = front_opening.copy(), back_opening.copy()
cback.reflect([0, 0], [1, 0]).reverse().snap_to(cfront[-1].end)
# Cutout
slope = np.array([cfront[0].start, cback[-1].end])
slope_vec = slope[1] - slope[0]
slope_perp = np.asarray([-slope_vec[1], slope_vec[0]])
slope_midpoint = (slope[0] + slope[1]) / 2
# Intersection with the sleeve itself line
# svgpath tools allow solution regardless of egde types
inter_segment = svgpath.Line(
list_to_c(slope_midpoint - 20 * slope_perp),
list_to_c(slope_midpoint + 20 * slope_perp)
)
target_segment = cfront[-1].as_curve()
intersect_t = target_segment.intersect(inter_segment)
if len(intersect_t) != 1 and verbose:
print(
f'Redistribute Sleeve Openings::WARNING::{len(intersect_t)} intersection points instead of one. '
f'Front and back opening curves might be the same with lengths: {cfront.length()}, {cback.length()}'
)
if (len(intersect_t) >= 1
and not (close_enough(intersect_t[0][0], 0, tol=tol) # checking if they are already ok separated
or close_enough(intersect_t[0][0], 1, tol=tol))):
# The current separation is not satisfactory
# Update the opening shapes
intersect_t = intersect_t[0][0]
subdiv = front_opening.edges[-1].subdivide_param([intersect_t, 1 - intersect_t])
front_opening.substitute(-1, subdiv[0])
# Move this part to the back opening
subdiv[1].start, subdiv[1].end = copy(subdiv[1].start), copy(subdiv[1].end) # Disconnect vertices in subdivided version
subdiv.pop(0) # TODOLOW No reflect in the edge class??
subdiv.reflect([0, 0], [1, 0]).reverse().snap_to(back_opening[-1].end)
subdiv[0].start = back_opening[-1].end
back_opening.append(subdiv[0])
# Align the slope with OY direction
# for correct size of sleeve panels
slope_angle = np.arctan(-slope_vec[0] / slope_vec[1])
front_opening.rotate(-slope_angle)
back_opening.rotate(slope_angle)
return front_opening, back_opening
# ANCHOR ----- Curve tools -----
def _avg_curvature(curve, points_estimates=100):
"""Average curvature in a curve"""
# NOTE: this work slow, but direct evaluation seems
# infeasible
# Some hints here:
# https://math.stackexchange.com/questions/220900/bezier-curvature
t_space = np.linspace(0, 1, points_estimates)
return sum([curve.curvature(t) for t in t_space]) / points_estimates
def _max_curvature(curve, points_estimates=100):
"""Average curvature in a curve"""
# NOTE: this work slow, but direct evaluation seems
# infeasible
# Some hints here: https://math.stackexchange.com/questions/1954845/bezier-curvature-extrema
t_space = np.linspace(0, 1, points_estimates)
return max([curve.curvature(t) for t in t_space])
def _bend_extend_2_tangent(
shift, cp, target_len, direction,
target_tangent_start, target_tangent_end,
point_estimates=50):
"""Evaluate how well curve preserves the length and tangents
NOTE: point_estimates controls average curvature evaluation.
The higher the number, the more stable the optimization,
but higher computational cost
"""
control = np.array([
cp[0],
[cp[1][0] + shift[0], cp[1][1] + shift[1]],
[cp[2][0] + shift[2], cp[2][1] + shift[3]],
cp[-1] + direction * shift[4]
])
params = control[:, 0] + 1j*control[:, 1]
curve_inverse = svgpath.CubicBezier(*params)
length_diff = (curve_inverse.length() - target_len)**2 # preservation
tan_0_diff = (abs(curve_inverse.unit_tangent(0) - target_tangent_start))**2
tan_1_diff = (abs(curve_inverse.unit_tangent(1) - target_tangent_end))**2
# NOTE: tried regularizing based on Y value in relative coordinates (for speed),
# But it doesn't produce good results
curvature_reg = _max_curvature(curve_inverse, points_estimates=point_estimates)**2
end_expantion_reg = 0.001*shift[-1]**2
return length_diff + tan_0_diff + tan_1_diff + curvature_reg + end_expantion_reg
def curve_match_tangents(curve, target_tan0, target_tan1, target_len=None,
return_as_edge=False, verbose: bool = False):
"""Update the curve to have the desired tangent directions at endpoints
while preserving curve length or desired target length ('target_len') and overall direction
Returns
* control points for the final CubicBezier curves
* Or CurveEdge instance, if return_as_edge=True
NOTE: Only Cubic Bezier curves are supported
NOTE: Expects good enough initialization ('curve') that approximated desired solution
"""
if not isinstance(curve, svgpath.CubicBezier):
raise NotImplementedError(
f'Curve_match_tangents::ERROR::Only Cubic Bezier curves are supported ',
f'(got {type(curve)})')
curve_cps = c_to_np(curve.bpoints())
direction = curve_cps[-1] - curve_cps[0]
direction /= np.linalg.norm(direction)
target_tan0 = target_tan0 / np.linalg.norm(target_tan0)
target_tan1 = target_tan1 / np.linalg.norm(target_tan1)
# match tangents with the requested ones while preserving length
out = minimize(
_bend_extend_2_tangent, # with tangent matching
[0, 0, 0, 0, 0],
args=(
curve_cps,
curve.length() if target_len is None else target_len,
direction,
list_to_c(target_tan0),
list_to_c(target_tan1),
70 # NOTE: Low values cause instable resutls
),
method='L-BFGS-B',
)
if not out.success:
if verbose:
print(f'Curve_match_tangents::WARNING::optimization not successfull')
print(out)
shift = out.x
fin_curve_cps = [
curve_cps[0].tolist(),
[curve_cps[1][0] + shift[0], curve_cps[1][1] + shift[1]],
[curve_cps[2][0] + shift[2], curve_cps[2][1] + shift[3]],
(curve_cps[-1] + direction*shift[-1]).tolist(),
]
if return_as_edge:
fin_inv_edge = CurveEdge(
start=fin_curve_cps[0],
end=fin_curve_cps[-1],
control_points=fin_curve_cps[1:3],
relative=False
)
return fin_inv_edge
return fin_curve_cps
# ---- Utils ----
def _dist(v1, v2):
return norm(v2-v1)
def _fit_scale(s, shortcut, v1, v2, vc, d_v1, d_v2):
"""Evaluate how good a shortcut fits the corner if the vertices are shifted
a little along the line"""
# Shortcut can be used as 2D vector, not a set of 2D points, e.g.
shifted = deepcopy(shortcut)
shifted[0] += (shortcut[0] - shortcut[1]) * s[0] # this only changes the end vertex though
shifted[1] += (shortcut[1] - shortcut[0]) * s[1] # this only changes the end vertex though
return ((d_v1 - _dist(shifted[0], v1) - _dist(shifted[0], vc))**2
+ (d_v2 - _dist(shifted[1], v2) - _dist(shifted[1], vc))**2
)

View File

@@ -0,0 +1,408 @@
import numpy as np
from copy import copy
from argparse import Namespace
from scipy.spatial.transform import Rotation as R
from pygarment.pattern.core import BasicPattern
from pygarment.garmentcode.base import BaseComponent
from pygarment.garmentcode.edge import Edge, EdgeSequence, CircleEdge
from pygarment.garmentcode.utils import close_enough, vector_align_3D
from pygarment.garmentcode.operators import cut_into_edge
from pygarment.garmentcode.interface import Interface
class Panel(BaseComponent):
""" A Base class for defining a Garment component corresponding to a single
flat fiece of fabric
Defined as a collection of edges on a 2D grid with specified 3D placement
(world coordinates)
NOTE: All operations methods return 'self' object to allow sequential
applications
"""
def __init__(self, name, label='') -> None:
"""Base class for panel creations
* Name: panel name. Expected to be a unique identifier of a panel object
* label: additional panel label (non-unique)
"""
super().__init__(name)
self.label = label
self.translation = np.zeros(3)
self.rotation = R.from_euler('XYZ', [0, 0, 0]) # zero rotation
# NOTE: initiating with empty sequence allows .append() to it safely
self.edges = EdgeSequence()
# Info
def pivot_3D(self):
"""Pivot point of a panel in 3D"""
return self.point_to_3D([0, 0])
def length(self, longest_dim=False):
"""Length of a panel element in cm
Defaults the to the vertical length of a 2D bounding box
* longest_dim -- if set, returns the longest dimention out of the bounding box dimentions
"""
bbox = self.bbox()
x = abs(bbox[1][0] - bbox[0][0])
y = abs(bbox[1][1] - bbox[0][1])
return max(x, y) if longest_dim else y
def is_self_intersecting(self):
"""Check whether the panel has self-intersection"""
edge_curves = []
for e in self.edges:
if isinstance(e, CircleEdge):
# 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:
edge_curves += [eseg.as_curve() for eseg in e.linearize(n_verts_inside=10)]
else:
edge_curves.append(e.as_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 close_enough(t1, 0) and 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
# ANCHOR - Operations -- update object in-place
def set_panel_label(self, label: str, overwrite=True):
"""If overwrite is not enabled, only updates the label if it's empty."""
if not self.label or overwrite:
self.label = label
def set_pivot(self, point_2d, replicate_placement=False):
"""Specify 2D point w.r.t. panel local space
to be used as pivot for translation and rotation
Parameters:
* point_2d -- desired point 2D point w.r.t current pivot (origin)
of panel local space
* replicate_placement -- will replicate the location of the panel
as it was before pivot change
default - False (no adjustment, the panel may "jump" in 3D)
"""
point_2d = copy(point_2d) # Remove unwanted object reference
# In case an actual vertex was used as a target point
if replicate_placement:
self.translation = self.point_to_3D(point_2d)
# FIXME Replicate rotation
# UPD vertex locations relative to new pivot
for v in self.edges.verts():
v[0] -= int(point_2d[0])
v[1] -= int(point_2d[1])
def top_center_pivot(self):
"""One of the most useful pivots
is the one in the middle of the top edge of the panel
"""
vertices = np.asarray(self.edges.verts())
# 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]
]
mid_points_3D = np.vstack(tuple(
[self.point_to_3D(coords) for coords in mid_points_2D]
))
top_mid_point = mid_points_3D[:, 1].argmax()
self.set_pivot(mid_points_2D[top_mid_point])
return self
def translate_by(self, delta_vector):
"""Translate panel by a vector"""
self.translation = self.translation + np.array(delta_vector)
# NOTE: One may also want to have autonorm only on the assembly?
self.autonorm()
return self
def translate_to(self, new_translation):
"""Set panel translation to be exactly that vector"""
self.translation = np.asarray(new_translation)
self.autonorm()
return self
def rotate_by(self, delta_rotation: R):
"""Rotate panel by a given rotation
* delta_rotation: scipy rotation object
"""
self.rotation = delta_rotation * self.rotation
self.autonorm()
return self
def rotate_to(self, new_rot: R):
"""Set panel rotation to be exactly the given rotation
* new_rot: scipy rotation object
"""
if not isinstance(new_rot, R):
raise ValueError(f'{self.__class__.__name__}::ERROR::Only accepting rotations in scipy format')
self.rotation = new_rot
self.autonorm()
return self
def rotate_align(self, vector):
"""Set panel rotation s.t. it's norm is aligned with a given 3D
vector"""
vector = np.asarray(vector)
vector = vector / np.linalg.norm(vector)
n = self.norm()
self.rotate_by(vector_align_3D(n, vector))
return self
def center_x(self):
"""Adjust translation over x s.t. the center of the panel is aligned
with the Y axis (center of the body)"""
center_3d = self.point_to_3D(self._center_2D())
self.translation[0] += -center_3d[0]
return self
def autonorm(self):
"""Update right/wrong side orientation, s.t. the normal of the surface
looks outside he world origin,
taking into account the shape and the global position.
This should provide correct panel orientation in most cases.
NOTE: for best results, call autonorm after translation
specification
"""
norm_dr = self.norm()
# NOTE: Nothing happens if self.translation is zero
if np.dot(norm_dr, self.translation) < 0:
# Swap if wrong
self.edges.reverse()
def mirror(self, axis=None):
"""Swap this panel with its mirror image
Axis specifies 2D axis to swap around: Y axis by default
"""
if axis is None:
axis = [0, 1]
# Case Around Y
if close_enough(axis[0], tol=1e-4): # reflection around Y
# Vertices
self.edges.reflect([0, 0], [0, 1])
# Position
self.translation[0] *= -1
# Rotations
curr_euler = self.rotation.as_euler('XYZ')
curr_euler[1] *= -1
curr_euler[2] *= -1
self.rotate_to(R.from_euler('XYZ', curr_euler))
# Fix right/wrong side
self.autonorm()
else:
# TODO Any other axis
raise NotImplementedError(f'{self.name}::ERROR::Mirrowing over arbitrary axis is not implemented')
return self
def add_dart(self, dart_shape, edge, offset, right=True, edge_seq=None, int_edge_seq=None, ):
""" Shortcut for adding a dart to a panel:
* Performs insertion of the dart_shape in the given edge (parameters are the same
as in pyp.ops.cut_into_edge)
* Creates stitch to connect the dart sides
* Modifies edge_sequnces with full set (edge_seq) or only the interface part (int_edge_seq)
of the created edges, if those are provided
Returns new edges after insertion, and the interface part (excludes dart edges)
"""
edges_new, dart_edges, int_new = cut_into_edge(
dart_shape,
edge,
offset=offset,
right=right)
self.stitching_rules.append(
(Interface(self, dart_edges[0]), Interface(self, dart_edges[1])))
# Update the edges if given
if edge_seq is not None:
edge_seq.substitute(edge, edges_new)
edges_new = edge_seq
if int_edge_seq is not None:
int_edge_seq.substitute(edge, int_new)
int_new = int_edge_seq
return edges_new, int_new
# ANCHOR - Build the panel -- get serializable representation
def assembly(self):
"""Convert panel into serialazable representation
NOTE: panel EdgeSequence is assumed to be a single loop of edges
"""
# FIXME Some panels have weird resulting alignemnt when th
# is pivot setup is removed -- there is a bug somewhere
# always start from zero for consistency between panels
self.set_pivot(self.edges[0].start, replicate_placement=True)
# Basics
panel = Namespace(
translation=self.translation.tolist(),
rotation=self.rotation.as_euler('XYZ', degrees=True).tolist(),
vertices=[self.edges[0].start],
edges=[])
for i in range(len(self.edges)):
vertices, edge = self.edges[i].assembly()
# add new vertices
if panel.vertices[-1] == vertices[0]: # We care if both point to the same vertex location, not necessarily the same vertex object
vert_shift = len(panel.vertices) - 1 # first edge vertex = last vertex already in the loop
panel.vertices += vertices[1:]
else:
vert_shift = len(panel.vertices)
panel.vertices += vertices
# upd vertex references in edges according to new vertex ids in
# the panel vertex loop
edge['endpoints'] = [id + vert_shift for id in edge['endpoints']]
edge_shift = len(panel.edges) # before adding new ones
self.edges[i].geometric_id = edge_shift # remember the mapping of logical edge to geometric id in panel loop
panel.edges.append(edge)
# Check closing of the loop and upd vertex reference for the last edge
if panel.vertices[-1] == panel.vertices[0]:
panel.vertices.pop()
panel.edges[-1]['endpoints'][-1] = 0
# Add panel label, if known
if self.label:
panel.label = self.label
spattern = BasicPattern()
spattern.name = self.name
spattern.pattern['panels'] = {self.name: vars(panel)}
# Assembly stitching info (panel might have inner stitches)
spattern.pattern['stitches'] = self.stitching_rules.assembly()
return spattern
# ANCHOR utils
def _center_2D(self, n_verts_inside = 3):
"""Approximate Location of the panel center.
NOTE: uses crude linear approximation for curved edges,
n_verts_inside = number of vertices (excluding the start
and end vertices) used to create a linearization of an edge
"""
# NOTE: assuming that edges are organized in a loop and share vertices
lin_edges = EdgeSequence([e.linearize(n_verts_inside)
for e in self.edges])
verts = lin_edges.verts()
return np.mean(verts, axis=0)
def point_to_3D(self, point_2d):
"""Calculate 3D location of a point given in the local 2D plane """
point_2d = np.asarray(point_2d)
if len(point_2d) == 2:
point_2d = np.append(point_2d, 0)
point_3d = self.rotation.apply(point_2d)
point_3d += self.translation
return point_3d
def norm(self):
"""Normal direction for the current panel using bounding box"""
# To make norm evaluation work for non-convex panels
# Determine points located on bounding box (b_verts_2d), compute
# norm of consecutive b_verts_3d and the b_verts_3d mean (b_center_3d),
# then weight the norms.
# The dominant norm direction should be the correct one
_, b_verts_2d = self.edges.bbox()
b_verts_3d = [self.point_to_3D(bv_2d) for bv_2d in b_verts_2d]
b_center_3d = np.mean((b_verts_3d), axis=0)
norms = []
num_b_verts_3d = len(b_verts_3d)
for i in range(num_b_verts_3d):
vert_0 = b_verts_3d[i]
vert_1 = b_verts_3d[(i + 1) % num_b_verts_3d]
# Pylance + NP error for unreachanble code -- see https://github.com/numpy/numpy/issues/22146
# Works ok for numpy 1.23.4+
norm = np.cross(vert_0 - b_center_3d, vert_1 - b_center_3d)
norm /= np.linalg.norm(norm)
norms.append(norm)
# Current norm direction
avg_norm = sum(norms) / len(norms)
if close_enough(np.linalg.norm(avg_norm), 0):
# Indecisive averaging, so using just one of the norms
# NOTE: sometimes happens on thin arcs
avg_norm = norms[0]
if self.verbose:
print(f'{self.__class__.__name__}::{self.name}::WARNING::Norm evaluation failed, assigning norm based on the first edge')
final_norm = avg_norm / np.linalg.norm(avg_norm)
# solve float errors
for i, ni in enumerate(final_norm):
if np.isclose([ni], [0.0]):
final_norm[i] = 0.0
return final_norm
def bbox(self):
"""Evaluate 2D bounding box"""
# Using curve linearization for more accurate approximation of bbox
lin_edges = EdgeSequence([e.linearize() for e in self.edges])
verts_2d = np.asarray(lin_edges.verts())
return verts_2d.min(axis=0), verts_2d.max(axis=0)
def bbox3D(self):
"""Evaluate 3D bounding box of the current panel"""
# Using curve linearization for more accurate approximation of bbox
lin_edges = EdgeSequence([e.linearize() for e in self.edges])
verts_2d = lin_edges.verts()
verts_3d = np.asarray([self.point_to_3D(v) for v in verts_2d])
return verts_3d.min(axis=0), verts_3d.max(axis=0)

View File

@@ -0,0 +1,153 @@
"""Parameter class wrappers around parameter files allowing definition of computed parameters
"""
import yaml
from pathlib import Path
from copy import deepcopy
import random
from pygarment.garmentcode.utils import nested_get, nested_set, close_enough
class BodyParametrizationBase:
"""Base class for body parametrization wrappers that allows definition of
dependent parameters
"""
def __init__(self, param_file='') -> None:
self.params = {}
self.load(param_file)
def __getitem__(self, key):
return self.params[key]
def __iter__(self):
"""
Return an iterator of dict keys
"""
return iter(self.params)
# Updates
def __setitem__(self, key, value):
self.params[key] = value
self.eval_dependencies(key)
def load(self, param_file):
"""Load new values from file"""
with open(param_file, 'r') as f:
dict = yaml.safe_load(f)['body']
self.params.update(dict)
self.eval_dependencies() # Parameters have been updated
def load_from_dict(self, in_dict):
self.params.update(in_dict)
self.eval_dependencies() # Parameters have been updated
# Processing
def eval_dependencies(self, key=None):
"""Evaluate dependent attributes, e.g. after a new value has been set
Define your dependent parameters in the overload of this function
* key -- the information on what field is being updated
"""
pass
# Save
def save(self, path, name='body_measurements'):
with open(Path(path) / f'{name}.yaml', 'w') as f:
yaml.dump(
{'body': self.params},
f,
default_flow_style=False
)
class DesignSampler:
"""Base class for design parameters sampling """
def __init__(self, param_file='') -> None:
self.params = {}
if param_file:
self.load(param_file)
def load(self, param_file):
"""Load new values from file"""
with open(param_file, 'r') as f:
dict = yaml.safe_load(f)['design']
self.params.update(dict)
def default(self):
return self.params
# ---- Randomization of values ----
def randomize(self):
"""Generate random values for the current design parameters"""
random_params = deepcopy(self.params)
# NOTE dealing with the nested dict
self._randomize_subset(random_params, [])
return random_params
def _randomize_subset(self, random_params, path):
subset = nested_get(random_params, path) if path else random_params
for key in subset:
if 'v' in subset[key].keys():
self._randomize_value(random_params, path + [key])
else:
self._randomize_subset(random_params, path + [key])
def _randomize_value(self, random_params, path):
""" Randomize the value of one parameter
Path is leading to the leaf of param dict. value.
"""
range = nested_get(random_params, path + ['range'])
p_type = nested_get(random_params, path + ['type'])
# Check Defaults
try:
def_prob = nested_get(random_params, path + ['default_prob'])
except KeyError as e: # Default probability not given -> Sample uniformly
def_prob = None
def_value = nested_get(self.params, path + ['v'])
if self.__use_default(def_prob):
new_val = def_value
else:
if 'select' in p_type or p_type == 'bool' or 'file' in p_type: # All discrete types
if p_type == 'select_null' and None not in range:
range.append(None)
# Exclude default
if def_prob is not None:
range.remove(def_value)
new_val = random.choice(range)
elif p_type == 'int':
new_val = self.__randint_exclude(range, None if def_prob is None else def_value)
elif p_type == 'float':
new_val = self.__uniform_exclude(range, None if def_prob is None else def_value)
nested_set(random_params, path + ['v'], new_val)
def __use_default(self, probability):
if probability is None:
return False
return random.random() < probability
def __randint_exclude(self, range, exclude):
rand_v = random.randint(*range)
if exclude is not None and rand_v == exclude:
return self.__randint_exclude(range, exclude)
return rand_v
def __uniform_exclude(self, range, exclude):
rand_v = random.uniform(*range)
if exclude is not None and close_enough(rand_v, exclude):
return self.__uniform_exclude(range, exclude)
return rand_v

View File

@@ -0,0 +1,164 @@
from typing import TypeVar, Generic, Sequence, Callable
import numpy as np
from numpy.linalg import norm
from scipy.spatial.transform import Rotation
import svgpathtools as svgpath
# proper inserstions by key with bicest module in python <3.10
# https://stackoverflow.com/questions/27672494/how-to-use-bisect-insort-left-with-a-key
T = TypeVar('T')
V = TypeVar('V')
class KeyWrapper(Generic[T, V]):
def __init__(self, iterable: Sequence[T], key: Callable[[T], V]):
self.it = iterable
self.key = key
def __getitem__(self, i: int) -> V:
return self.key(self.it[i])
def __len__(self) -> int:
return len(self.it)
def vector_angle(v1, v2):
"""Find an angle between two 2D vectors"""
v1, v2 = np.asarray(v1), np.asarray(v2)
cos = np.dot(v1, v2) / (norm(v1) * norm(v2))
angle = np.arccos(cos)
# Cross to indicate correct relative orienataion of v2 w.r.t. v1
cross = np.cross(v1, v2)
if abs(cross) > 1e-5:
angle *= np.sign(cross)
return angle
def R2D(angle):
"""2D rotation matrix by an angle"""
return np.array([[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]])
def vector_align_3D(v1, v2):
"""Find a rotation to align v1 with v2"""
v1, v2 = np.asarray(v1), np.asarray(v2)
cos = np.dot(v1, v2) / (norm(v1) * norm(v2))
cos = max(min(cos, 1), -1) # NOTE: getting rid of numbers like 1.000002 that appear due to numerical instability
angle = np.arccos(cos)
# Cross to get the axis of rotation
cross = np.cross(v1, v2)
cross = cross / norm(cross)
return Rotation.from_rotvec(cross * angle)
def close_enough(f1, f2=0, tol=1e-4):
"""Compare two floats correctly """
return abs(f1 - f2) < tol
def bbox_paths(paths):
"""Bounding box of a list of paths/Edge Sequences"""
bboxes = np.array([p.bbox() for p in paths])
return (min(bboxes[:, 0]), max(bboxes[:, 1]),
min(bboxes[:, 2]), max(bboxes[:, 3]))
def lin_interpolation(val1, val2, factor):
"""Linear interpolation between val1 and val2 with factor [0, 1]
with factor == 0, output is val1
with factor == 1, output is val2
"""
if factor < 0 or factor > 1:
raise ValueError(f'lin_interpolation::ERROR::Expected a factor \in [0, 1], got {factor}')
return (1 - factor) * val1 + factor * val2
# ---- Complex numbers converters -----
def c_to_list(num):
"""Convert complex number to a list of 2 elements
Allows processing of lists of complex numbers
"""
if isinstance(num, (list, tuple, set, np.ndarray)):
return [c_to_list(n) for n in num]
else:
return [num.real, num.imag]
def c_to_np(num):
"""Convert complex number to a numpy array of 2 elements
Allows processing of lists of complex numbers
"""
if isinstance(num, (list, tuple, set, np.ndarray)):
return np.asarray([c_to_list(n) for n in num])
else:
return np.asarray([num.real, num.imag])
def list_to_c(num):
"""Convert 2D list or list of 2D lists into complex number/list of complex
numbers"""
if isinstance(num[0], (list, tuple, set, np.ndarray)):
return [complex(n[0], n[1]) for n in num]
else:
return complex(num[0], num[1])
# ---- Nested Dictionaries shortcuts ----
# https://stackoverflow.com/a/37704379
def nested_get(dic, keys):
for key in keys:
dic = dic[key]
return dic
def nested_set(dic, keys, value):
for key in keys[:-1]:
dic = dic.setdefault(key, {})
dic[keys[-1]] = value
def nested_del(dic, keys):
for key in keys[:-1]:
dic = dic[key]
del dic[keys[-1]]
# ----- Curves -----
def curve_extreme_points(curve, on_x=False, on_y=True):
"""Return extreme points of the current edge
NOTE: this does NOT include the border vertices of an edge
"""
# Variation of https://github.com/mathandy/svgpathtools/blob/5c73056420386753890712170da602493aad1860/svgpathtools/bezier.py#L197
poly = svgpath.bezier2polynomial(curve, return_poly1d=True)
x_extremizers, y_extremizers = [], []
if on_y:
y = svgpath.imag(poly)
dy = y.deriv()
y_extremizers = svgpath.polyroots(dy, realroots=True,
condition=lambda r: 0 < r < 1)
if on_x:
x = svgpath.real(poly)
dx = x.deriv()
x_extremizers = svgpath.polyroots(dx, realroots=True,
condition=lambda r: 0 < r < 1)
all_extremizers = x_extremizers + y_extremizers
extreme_points = np.array([c_to_list(curve.point(t))
for t in all_extremizers])
return extreme_points