Files
2025-07-03 17:03:00 +08:00

999 lines
34 KiB
Python

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