"""Simetri library's interlace objects."""
from itertools import combinations
from typing import Iterator, List, Any, Union
from collections import OrderedDict
import networkx as nx
import numpy as np
from numpy import isclose
from ..graphics.shape import Shape, custom_attributes
from ..graphics.batch import Batch
from ..colors import colors
from ..geometry.geometry import (
connected_pairs,
polygon_area,
distance,
double_offset_polygons,
get_polygons,
double_offset_polylines,
intersection2,
right_handed,
polygon_cg,
round_point,
offset_polygon,
offset_polygon_points,
convex_hull,
close_points2,
polygon_center,
)
from ..helpers.graph import get_cycles
from ..graphics.common import get_defaults, common_properties
from ..graphics.all_enums import Types, Connection
from ..canvas.style_map import shape_style_map, ShapeStyle
from ..canvas.canvas import Canvas
from ..settings.settings import defaults
from ..helpers.utilities import flatten, group_into_bins
from ..helpers.validation import validate_args
def _set_style(obj: Any, attribs):
for attr in attribs:
setattr(obj, attr, getattr(defaults["style"], attr))
array = np.array
# Lace (Batch)
# parallel_polyline_list (list)
# parallel_polyline1 (ParallelPolyline-Batch-PARALLELPOLYLINE)
# polyline_list (list)
# | polyline1 (Polyline-Shape)
# | divisions (list)
# | division1 (Division-Shape)
# | p1 (tuple)
# | p2 (tuple)
# | sections (list)
# | section1(Section-Shape)
# | p1 (tuple)
# | p2
# | is_overlap (bool)
# | overlap (Overlap-Batch)
# |overlaps (list)
# | overlap1(Overlap-Batch)
# | divisions (list)
# | division1(Division-Shape)
# | p1 (tuple)
# | p2 (tuple)
# | sections (list)
# | section1(Section-Shape)
# | start (Intersection-Shape)
# | end (Intersection-Shape)
# | overlap
# |
# |
# |fragments
# fragment1(Shape)
# divisions
# division1(Shape)
# p1
# p2
# sections
# section1
# plaits (list)
# plait1 (Plait-Shape)
# All objects in this module is a subclass of the Shape or Batch class.
# They are used to compute the interlacing patterns.
# Example of a Lace object.
# /\
# //\\ /\
# // \//\\
# // /\\ \\
# // // \\ \\
# \\ \\ // //
# \\ \// //
# \\ //\\//
# \\// \/
# \/
# Example of a ParallelPolylines object. The lace object above has two
# ParallelPolylines objects. Main polylines are not shown, they are
# located in the middle of the offset polylines.
# /\
# //\\
# // \\
# // \\
# \\ //
# \\ //
# \\//
# \/
# Example of a Fragment object.
# They are polygons or polylines.
# The lace object above has three fragments.
# This is the middle fragment.
# /\
# / \
# \ /
# \/
# Example of a plait object. They are polylines.
# Used for drawing under/over interlacing.
# /\
# \ \
# \ \
# / /
# / /
# \/
# Example of an Overlap object.
# The lace object above has two overlap regions.
# /\
# \/
# Example of a Polyline object.
# /\
# / \
# / \
# / \
# / \
# \ /
# \ /
# \ /
# \ /
# \/
# Example of an Division object.
# Each polyline is made up of one or more "Division" objects.
# Each division is divided into sections
# Maybe "Divisions" would be a better name instead of "Division"?
# *
# \
# *
# \
# *
# \
# *
# Example of a Section object.
# Each division is made up of one or more sections.
# Sections have intersection points at their ends.
# *
# \
# *
# * intersections have a point, division1, division2 attributes.
# """
[docs]
class Intersection(Shape):
"""Intersection of two divisions. They are at the endpoints of Section
objects. A division can have multiple sections and multiple
intersections. They can be located at the end of a division.
Args:
point (tuple): (x, y) coordinates of the intersection point.
division1 (Division): First division.
division2 (Division, optional): Second division. Defaults to None.
endpoint (bool, optional): If the intersection is at the end of a division, then endpoint is True. Defaults to False.
**kwargs: Additional attributes for cosmetic/drawing purposes.
"""
def __init__(self, point: tuple, division1: "Division", division2: "Division" = None, endpoint: bool = False, **kwargs) -> None:
super().__init__([point], xform_matrix=None, subtype=Types.INTERSECTION, **kwargs)
self._point = point
self.division1 = division1
self.division2 = division2
self.overlap = None
self.endpoint = endpoint
self.division = None # used for fragment divisions' DCEL structure
common_properties(self, id_only=True)
def _update(self, xform_matrix: array, reps=0):
"""Update the transformation matrix of the intersection.
Args:
xform_matrix (array): Transformation matrix.
reps (int, optional): Number of repetitions. Defaults to 0.
Returns:
Any: Updated intersection or list of updated intersections.
"""
if reps == 0:
self.xform_matrix = self.xform_matrix @ xform_matrix
res = self
else:
res = []
for _ in range(reps):
shape = self.copy()
shape._update(xform_matrix)
res.append(shape)
return res
[docs]
def copy(self):
"""Create a copy of the intersection.
Returns:
Intersection: A copy of the intersection.
"""
intersection = Intersection(self.point, self.division1, self.division2)
for attrib in shape_style_map:
setattr(intersection, attrib, getattr(self, attrib))
custom_attribs = custom_attributes(self)
for attrib in custom_attribs:
setattr(intersection, attrib, getattr(self, attrib))
return intersection
@property
def point(self):
"""Return the intersection point.
Returns:
list: Intersection point coordinates.
"""
return list(np.array([*self._point, 1.0]) @ self.xform_matrix)[:2]
def __str__(self):
"""String representation of the intersection.
Returns:
str: String representation.
"""
return (
f"Intersection({[round(x, defaults['n_round']) for x in self.point]}, "
f"{tuple(list([self.division1, self.division2]))}"
)
def __repr__(self):
"""String representation of the intersection.
Returns:
str: String representation.
"""
return str(self)
def __eq__(self, other):
"""Check if two intersections are equal.
Args:
other (Intersection): Another intersection.
Returns:
bool: True if equal, False otherwise.
"""
return close_points2(self.point, other.point, dist2=defaults["dist_tol"] ** 2)
[docs]
class Partition(Shape):
"""These are the polygons of the non-interlaced geometry.
Fragments and partitions are scaled versions of each other.
Args:
points (list): List of points defining the partition.
**kwargs: Additional attributes for cosmetic/drawing purposes.
"""
def __init__(self, points, **kwargs):
super().__init__(points, **kwargs)
self.subtype = Types.PART
self.area = polygon_area(self.vertices)
self.CG = polygon_cg(self.vertices)
common_properties(self)
def __str__(self):
"""String representation of the partition.
Returns:
str: String representation.
"""
return f"Part({self.vertices})"
def __repr__(self):
"""String representation of the partition.
Returns:
str: String representation.
"""
return self.__str__()
[docs]
class Fragment(Shape):
"""A Fragment is a collection of section objects that are connected
to each other. These sections are already defined. They belong to
the polyline objects in a lace. Fragments can be open or closed.
They are created by the lace object.
Args:
points (list): List of points defining the fragment.
**kwargs: Additional attributes for cosmetic/drawing purposes.
"""
def __init__(self, points, **kwargs):
super().__init__(points, **kwargs)
self.subtype = Types.FRAGMENT
self.area = polygon_area(self.vertices)
self.sections = []
self.intersections = []
self.inner_lines = []
self._divisions = []
self.CG = polygon_cg(self.vertices)
common_properties(self)
def __str__(self):
"""String representation of the fragment.
Returns:
str: String representation.
"""
return f"Fragment({self.vertices})"
def __repr__(self):
"""String representation of the fragment.
Returns:
str: String representation.
"""
return self.__str__()
@property
def divisions(self):
"""Return the divisions of the fragment.
Returns:
list: List of divisions.
"""
return self._divisions
@property
def center(self):
"""Return the center of the fragment.
Returns:
list: Center coordinates.
"""
return self.CG
def _set_divisions(self, dist_tol=None):
if dist_tol is None:
dist_tol = defaults["dist_tol"]
dist_tol2 = dist_tol * dist_tol # squared distance tolerance
d_points__section = {}
for section in self.sections:
start = section.start.point
end = section.end.point
start = round(start[0], 2), round(start[1], 2)
end = round(end[0], 2), round(end[1], 2)
d_points__section[(start, end)] = section
d_points__section[(end, start)] = section
coord_pairs = connected_pairs(self.vertices)
self._divisions = []
for pair in coord_pairs:
x1, y1 = round_point(pair[0])
x2, y2 = round_point(pair[1])
division = Division((x1, y1), (x2, y2))
division.section = d_points__section[((x1, y1), (x2, y2))]
division.fragment = self
start_point = round_point(division.section.start.point)
end_point = round_point(division.section.end.point)
if close_points2(start_point, (x1, y1), dist2=dist_tol2):
division.intersections = [division.section.start, division.section.end]
division.section.start.division = division
elif close_points2(end_point, (x1, y1), dist2=dist_tol2):
division.intersections = [division.section.end, division.section.start]
division.section.end.division = division
else:
raise ValueError("Division does not match section")
self._divisions.append(division)
n = len(self._divisions)
for i, division in enumerate(self._divisions):
division.prev = self._divisions[i - 1]
division.next = self._divisions[(i + 1) % n]
def _set_twin_divisions(self):
for division in self.divisions:
section = division.section
if section.twin and section.twin.fragment:
twin_fragment = section.twin.fragment
distances = []
for _, division2 in enumerate(twin_fragment.divisions):
dist = distance(
division.section.mid_point, division2.section.mid_point
)
distances.append((dist, division2))
distances.sort(key=lambda x: x[0])
division.twin = distances[0][1]
[docs]
class Section(Shape):
"""A section is a line segment between two intersections.
A division can have multiple sections. Sections are used to
draw the over/under plaits.
Args:
start (Intersection): Start intersection.
end (Intersection): End intersection.
is_overlap (bool, optional): If the section is an overlap. Defaults to False.
overlap (Overlap, optional): Overlap object. Defaults to None.
is_over (bool, optional): If the section is over. Defaults to False.
twin (Section, optional): Twin section. Defaults to None.
fragment (Fragment, optional): Fragment object. Defaults to None.
**kwargs: Additional attributes for cosmetic/drawing purposes.
"""
def __init__(
self,
start: Intersection = None,
end: Intersection = None,
is_overlap: bool = False,
overlap: "Overlap" = None,
is_over: bool = False,
twin: "Section" = None,
fragment: "Fragment" = None,
**kwargs,
):
super().__init__([start.point, end.point], subtype=Types.SECTION, **kwargs)
self.start = start
self.end = end
self.is_overlap = is_overlap
self.overlap = overlap
self.is_over = is_over
self.twin = twin
self.fragment = fragment
super().__init__([start.point, end.point], subtype=Types.SECTION, **kwargs)
self.length = distance(self.start.point, self.end.point)
self.mid_point = [
(self.start.point[0] + self.end.point[0]) / 2,
(self.start.point[1] + self.end.point[1]) / 2,
]
common_properties(self)
[docs]
def copy(self):
"""Create a copy of the section.
Returns:
Section: A copy of the section.
"""
overlap = self.overlap.copy() if self.overlap else None
start = self.start.copy()
end = self.end.copy()
section = Section(start, end, self.is_overlap, overlap, self.is_over)
return section
[docs]
def end_point(self):
"""Return the end point of the section.
Returns:
Intersection: End intersection.
"""
if self.start.endpoint:
res = self.start
elif self.end.endpoint:
res = self.end
else:
res = None
return res
def __str__(self):
"""String representation of the section.
Returns:
str: String representation.
"""
return f"Section({self.start}, {self.end})"
def __repr__(self):
"""String representation of the section.
Returns:
str: String representation.
"""
return self.__str__()
@property
def is_endpoint(self):
"""Return True if the section is an endpoint.
Returns:
bool: True if endpoint, False otherwise.
"""
return self.start.endpoint or self.end.endpoint
[docs]
class Overlap(Batch):
"""An overlap is a collection of four connected sections.
Args:
intersections (list[Intersection], optional): List of intersections. Defaults to None.
sections (list[Section], optional): List of sections. Defaults to None.
visited (bool, optional): If the overlap is visited. Defaults to False.
drawable (bool, optional): If the overlap is drawable. Defaults to True.
**kwargs: Additional attributes for cosmetic/drawing purposes.
"""
def __init__(
self,
intersections: list[Intersection] = None,
sections: list[Section] = None,
visited=False,
drawable=True,
**kwargs,
):
self.intersections = intersections
self.sections = sections
super().__init__(sections, **kwargs)
self.subtype = Types.OVERLAP
self.visited = visited
self.drawable = drawable
common_properties(self)
def __str__(self):
"""String representation of the overlap.
Returns:
str: String representation.
"""
return f"Overlap({self.id})"
def __repr__(self):
"""String representation of the overlap.
Returns:
str: String representation.
"""
return f"Overlap({self.id})"
[docs]
class Division(Shape):
"""A division is a line segment between two intersections.
Args:
p1 (tuple): Start point.
p2 (tuple): End point.
xform_matrix (array, optional): Transformation matrix. Defaults to None.
**kwargs: Additional attributes for cosmetic/drawing purposes.
"""
def __init__(self, p1, p2, xform_matrix=None, **kwargs):
super().__init__([p1, p2], subtype=Types.DIVISION, **kwargs)
self.p1 = p1
self.p2 = p2
self.intersections = []
self.twin = None # used for fragment divisions only
self.section = None # used for fragment divisions only
self.fragment = None # used for fragment divisions only
self.next = None # used for fragment divisions only
self.prev = None # used for fragment divisions only
self.sections = []
super().__init__(
[p1, p2], subtype=Types.DIVISION, xform_matrix=xform_matrix, **kwargs
)
common_properties(self)
def _update(self, xform_matrix, reps=0):
"""Update the transformation matrix of the division.
Args:
xform_matrix (array): Transformation matrix.
reps (int, optional): Number of repetitions. Defaults to 0.
Returns:
Any: Updated division or list of updated divisions.
"""
if reps == 0:
self.xform_matrix = self.xform_matrix @ xform_matrix
res = self
else:
res = []
for _ in range(reps):
shape = self.copy()
shape._update(xform_matrix)
res.append(shape)
return res
def __str__(self):
"""String representation of the division.
Returns:
str: String representation.
"""
return (
f"Division(({self.p1[0]:.2f}, {self.p1[1]:.2f}), "
f"({self.p2[0]:.2f}, {self.p2[1]:.2f}))"
)
def __repr__(self):
"""String representation of the division.
Returns:
str: String representation.
"""
return (
f"Division(({self.p1[0]:.2f}, {self.p1[1]:.2f}), "
f"({self.p2[0]:.2f}, {self.p2[1]:.2f}))"
)
[docs]
def copy(
self,
section: Section = None,
twin: Section = None,
):
"""Create a copy of the division.
Args:
section (Section, optional): Section object. Defaults to None.
twin (Section, optional): Twin section. Defaults to None.
Returns:
Division: A copy of the division.
"""
division = Division(self.p1[:], self.p2[:], np.copy(self.xform_matrix))
for attrib in shape_style_map:
setattr(division, attrib, getattr(self, attrib))
division.twin = twin
division.section = section
division.fragment = self.fragment
division.next = self.next
division.prev = self.prev
division.sections = [x.copy() for x in self.sections]
custom_attributes_ = custom_attributes(self)
for attrib in custom_attributes_:
setattr(division, attrib, getattr(self, attrib))
return division
def _merged_sections(self):
"""Merge sections of the division.
Returns:
list: List of merged sections.
"""
chains = []
chain = [self.intersections[0]]
sections = self.sections[:]
for section in sections:
if not section.is_over:
if section.start.id == chain[-1].id:
chain.append(section.end)
else:
chains.append(chain)
chain = [section.start, section.end]
if chain not in chains:
chains.append(chain)
return chains
def _sort_intersections(self) -> None:
"""Sort intersections of the division."""
self.intersections.sort(key=lambda x: distance(self.p1, x.point))
[docs]
def is_connected(self, other: "Division") -> bool:
"""Return True if the division is connected to another division.
Args:
other (Division): Another division.
Returns:
bool: True if connected, False otherwise.
"""
return self.p1 in other.end_points or self.p2 in other.end_points
@property
def end_points(self):
"""Return the end points of the division.
Returns:
list: List of end points.
"""
return [self.p1, self.p2]
@property
def start(self) -> Intersection:
"""Return the start intersection of the division.
Returns:
Intersection: Start intersection.
"""
return self.intersections[0]
@property
def end(self) -> Intersection:
"""Return the end intersection of the division.
Returns:
Intersection: End intersection.
"""
return self.intersections[-1]
[docs]
class Polyline(Shape):
"""
Connected points, similar to Shape objects.
They can be closed or open.
They are defined by a sequence of points.
They have divisions, sections, and intersections.
Args:
points (list): List of points defining the polyline.
closed (bool, optional): If the polyline is closed. Defaults to True.
xform_matrix (array, optional): Transformation matrix. Defaults to None.
**kwargs: Additional attributes for cosmetic/drawing purposes.
"""
def __init__(self, points, closed=True, xform_matrix=None, **kwargs):
self.__dict__["style"] = ShapeStyle()
self.__dict__["_style_map"] = shape_style_map
self._set_aliases()
self.closed = closed
kwargs["subtype"] = Types.POLYLINE
super().__init__(points, closed=closed, xform_matrix=xform_matrix, **kwargs)
self._set_divisions()
if not self.closed:
self._set_intersections()
common_properties(self)
def _update(self, xform_matrix, reps=0):
"""Update the transformation matrix of the polyline.
Args:
xform_matrix (array): Transformation matrix.
reps (int, optional): Number of repetitions. Defaults to 0.
Returns:
Any: Updated polyline or list of updated polylines.
"""
if reps == 0:
self.xform_matrix = self.xform_matrix @ xform_matrix
for division in self.divisions:
division._update(xform_matrix, reps=reps)
res = self
else:
res = []
for _ in range(reps):
shape = self.copy()
shape._update(xform_matrix)
res.append(shape)
return res
def __str__(self):
"""String representation of the polyline.
Returns:
str: String representation.
"""
return f"Polyline({self.final_coords[:, :2]})"
def __repr__(self):
"""String representation of the polyline.
Returns:
str: String representation.
"""
return self.__str__()
[docs]
def iter_sections(self) -> Iterator:
"""Iterate over the sections of the polyline.
Yields:
Section: Section object.
"""
for division in self.divisions:
yield from division.sections
[docs]
def iter_intersections(self):
"""Iterate over the intersections of the polyline.
Yields:
Intersection: Intersection object.
"""
for division in self.divisions:
yield from division.intersections
@property
def intersections(self):
"""Return the intersections of the polyline.
Returns:
list: List of intersections.
"""
res = []
for division in self.divisions:
res.extend(division.intersections)
return res
@property
def area(self):
"""Return the area of the polygon.
Returns:
float: Area of the polygon.
"""
return polygon_area(self.vertices)
@property
def sections(self):
"""Return the sections of the polyline.
Returns:
list: List of sections.
"""
sections = []
for division in self.divisions:
sections.extend(division.sections)
return sections
@property
def divisions(self):
"""Return the divisions of the polyline.
Returns:
list: List of divisions.
"""
return self.__dict__["divisions"]
def _set_divisions(self):
vertices = self.vertices
if self.closed:
vertices = list(vertices) + [vertices[0]]
pairs = connected_pairs(vertices)
divisions = [Division(p1, p2) for p1, p2 in pairs]
self.__dict__["divisions"] = divisions
def _set_intersections(self):
"""Fake intersections for open lines."""
division1 = self.divisions[0]
division2 = self.divisions[-1]
x1 = Intersection(division1.p1, division1, None, True)
division1.intersections = [x1]
x2 = Intersection(division2.p2, division2, None, True)
if division1.id == division2.id:
division1.intersections.append(x2)
else:
division2.intersections = [x2]
[docs]
class ParallelPolyline(Batch):
"""A ParallelPolylines is a collection of parallel Polylines.
They are defined by a main polyline and a list of offset
values (that can be negative or positive).
Args:
polyline (Polyline): Main polyline.
offset (float): Offset value.
lace (Lace): Lace object.
under (bool, optional): If the polyline is under. Defaults to False.
closed (bool, optional): If the polyline is closed. Defaults to True.
dist_tol (float, optional): Distance tolerance. Defaults to None.
**kwargs: Additional attributes for cosmetic/drawing purposes.
"""
def __init__(
self,
polyline,
offset,
lace,
under=False,
closed=True,
dist_tol=None,
**kwargs,
):
if dist_tol is None:
dist_tol = defaults["dist_tol"]
dist_tol2 = dist_tol * dist_tol
self.polyline = polyline
self.offset = offset
self.dist_tol = dist_tol
self.dist_tol2 = dist_tol2
self.closed = closed
if "canvas" in kwargs:
self._canvas = kwargs["canvas"]
else:
self._canvas = None
if "canvas" in kwargs:
self._canvas = kwargs["canvas"]
kwargs.pop("canvas")
self._set_offset_polylines()
self.polyline_list = [self.polyline] + self.offset_poly_list
super().__init__(self.polyline_list, **kwargs)
self.subtype = Types.PARALLEL_POLYLINE
self.overlaps = None
self.under = under
common_properties(self)
@property
def sections(self) -> List[Section]:
"""Return the sections of the parallel polyline.
Returns:
list: List of sections.
"""
sects = []
for polyline in self.polyline_list:
sects.extend(polyline.sections)
return sects
def _set_offset_polylines(self):
polyline = self.polyline
if self.closed:
vertices = list(polyline.vertices)
vertices = vertices + [vertices[0]]
offset_polygons = double_offset_polygons(
vertices, self.offset, dist_tol=self.dist_tol, canvas=self._canvas
)
else:
offset_polylines = double_offset_polylines(polyline.vertices, self.offset)
polylines = []
if self.closed:
for polygon in offset_polygons:
polylines.append(Polyline(polygon, closed=self.closed))
else:
for polyline in offset_polylines:
polylines.append(Polyline(polyline, closed=self.closed))
self.offset_poly_list = polylines
[docs]
class Lace(Batch):
"""
A Lace is a collection of ParallelPolylines objects.
They are used to create interlace patterns.
Args:
polygon_shapes (Union[Batch, list[Shape]], optional): List of polygon shapes. Defaults to None.
polyline_shapes (Union[Batch, list[Shape]], optional): List of polyline shapes. Defaults to None.
offset (float, optional): Offset value. Defaults to 2.
rtol (float, optional): Relative tolerance. Defaults to None.
swatch (list, optional): Swatch list. Defaults to None.
breakpoints (list, optional): Breakpoints list. Defaults to None.
plait_color (colors.Color, optional): Plait color. Defaults to None.
draw_fragments (bool, optional): If fragments should be drawn. Defaults to True.
palette (list, optional): Palette list. Defaults to None.
color_step (int, optional): Color step. Defaults to 1.
with_plaits (bool, optional): If plaits should be included. Defaults to True.
area_threshold (float, optional): Area threshold. Defaults to None.
radius_threshold (float, optional): Radius threshold. Defaults to None.
**kwargs: Additional attributes for cosmetic/drawing purposes.
"""
# @timing
def __init__(
self,
polygon_shapes: Union[Batch, list[Shape]] = None,
polyline_shapes: Union[Batch, list[Shape]] = None,
offset: float = 2,
rtol: float = None,
swatch: list = None,
breakpoints: list = None,
plait_color: colors.Color = None,
draw_fragments: bool = True,
palette: list = None,
color_step: int = 1,
with_plaits: bool = True,
area_threshold: float = None,
radius_threshold: float = None,
**kwargs,
) -> None:
validate_args(kwargs, shape_style_map)
(
rtol,
swatch,
plait_color,
draw_fragments,
area_threshold,
radius_threshold,
) = get_defaults(
[
"rtol",
"swatch",
"plait_color",
"draw_fragments",
"area_threshold",
"radius_threshold",
],
[
rtol,
swatch,
plait_color,
draw_fragments,
area_threshold,
radius_threshold,
],
)
if polygon_shapes:
polygon_shapes = polygon_shapes.merge_shapes()
self.polygon_shapes = self._check_polygons(polygon_shapes)
else:
self.polygon_shapes = []
if polyline_shapes:
polyline_shapes = polyline_shapes.merge_shapes()
self.polyline_shapes = self._check_polylines(polyline_shapes)
else:
self.polyline_shapes = []
if not self.polygon_shapes and not self.polyline_shapes:
msg = "Lace.__init__ : No polygons or polylines found."
raise ValueError(msg)
self.polyline_shapes = polyline_shapes
self.offset = offset
self.main_intersections = None
self.offset_intersections = None
self.xform_matrix = np.eye(3)
self.rtol = rtol
self.swatch = swatch
self.breakpoints = breakpoints
self.plait_color = plait_color
self.draw_fragments = draw_fragments
self.palette = palette
self.color_step = color_step
self.with_plaits = with_plaits
self.d_intersections = {} # key, value:intersection.id, intersection
self.d_connections = {}
self.plaits = []
self.overlaps = []
self._groups = None
self.area_threshold = area_threshold
self.radius_threshold = radius_threshold
if kwargs and "debug" in kwargs:
if kwargs["debug"]:
self._canvas = Canvas()
else:
self._canvas = None
if kwargs and "_copy" in kwargs:
# pass the pre-computed values
for k, v in kwargs:
if k == "_copy":
continue
else:
setattr(self, k, v)
else:
self._set_polyline_list() # main divisions are set here along with polylines
# polyline.divisions is the list of Division objects
if self._canvas:
for polyline in self.polyline_list:
self._canvas.draw(polyline, fill=False)
self._set_parallel_poly_list()
if self._canvas:
self._canvas.new_page()
for polyline in self.parallel_poly_list:
self._canvas.draw(polyline, fill=False)
# start_time2 = time.perf_counter()
self._set_intersections()
# end_time2 = time.perf_counter()
# print(
# f"Lace.__init__ intersections computed in {end_time2 - start_time2:0.4f} seconds"
# )
self._set_overlaps()
self._set_twin_sections()
self._set_fragments()
if not self.polyline_shapes:
self._set_outline()
# self._set_partitions()
self._set_over_under()
if self.with_plaits:
self.set_plaits()
# self._set_convex_hull()
# self._set_concave_hull()
# self._set_fragment_groups()
# self._set_partition_groups()
self._b_box = None
elements = [polyline for polyline in self.parallel_poly_list] + self.fragments
if self._canvas:
self._canvas.save("c:/tmp/lace_debug.pdf")
if "debug" in kwargs:
kwargs.pop("debug")
super().__init__(elements, **kwargs)
if kwargs and "_copy" not in kwargs:
for k, v in kwargs.items():
if k in shape_style_map:
setattr(self, k, v) # todo: we should check for valid values here
else:
raise AttributeError(f"{k}. Invalid attribute!")
self.subtype = Types.LACE
common_properties(self)
@property
def center(self):
"""Return the center of the lace.
Returns:
list: Center coordinates.
"""
return self.outline.CG
@property
def fragment_groups(self):
"""Return the fragment groups of the lace.
Returns:
dict: Dictionary of fragment groups.
"""
center = self.center
radius_frag = []
for fragment in self.fragments:
radius = int(distance(center, fragment.CG))
for rad, frag in radius_frag:
if abs(radius - rad) <= 2:
radius = rad
break
radius_frag.append((radius, fragment))
radius_frag.sort(key=lambda x: x[0])
d_groups = {}
for i, (radius, fragment) in enumerate(radius_frag):
if i == 0:
d_groups[radius] = [fragment]
else:
if radius in d_groups:
d_groups[radius].append(fragment)
else:
d_groups[radius] = [fragment]
return d_groups
def _check_polygons(self, polygon_shapes):
if isinstance(polygon_shapes, Batch):
polygon_shapes = polygon_shapes.all_shapes
for polygon in polygon_shapes:
if len(polygon.primary_points) < 3:
msg = "Lace.__init__ found polygon with less than 3 points."
raise ValueError(msg)
if not polygon.closed:
msg = "Lace.__init__ : Invalid polygons"
raise ValueError(msg)
if polygon.primary_points[0] != polygon.primary_points[-1]:
polygon.primary_points.append(polygon.primary_points[0])
# check if the polygons are clockwise
for polygon in polygon_shapes:
if not right_handed(polygon.vertices):
polygon.primary_points.reverse()
return polygon_shapes
def _check_polylines(self, polyline_shapes):
if isinstance(polyline_shapes, Batch):
polyline_shapes = polyline_shapes.all_shapes
for polyline in polyline_shapes:
if len(polyline.primary_points) < 2:
msg = "Lace.__init__ found polyline with less than 2 points."
raise ValueError(msg)
return polyline_shapes
def _update(self, xform_matrix, reps=0):
"""Update the transformation matrix of the lace.
Args:
xform_matrix (array): Transformation matrix.
reps (int, optional): Number of repetitions. Defaults to 0.
Returns:
Any: Updated lace or list of updated laces.
"""
if reps == 0:
self.xform_matrix = self.xform_matrix @ xform_matrix
for polygon in self.polygon_shapes:
polygon._update(xform_matrix)
if self.polyline_shapes:
for polyline in self.polyline_shapes:
polyline._update(xform_matrix)
for polyline in self.parallel_poly_list:
polyline._update(xform_matrix)
for fragment in self.fragments:
fragment._update(xform_matrix)
for intersection in self.main_intersections:
intersection._update(xform_matrix)
for intersection in self.offset_intersections:
intersection._update(xform_matrix)
for overlap in self.overlaps:
overlap._update(xform_matrix)
for plait in self.plaits:
plait._update(xform_matrix)
return self
else:
res = []
for _ in range(reps):
shape = self.copy()
shape._update(xform_matrix)
res.append(shape)
return res
# @timing
def _set_twin_sections(self):
for par_poly in self.parallel_poly_list:
poly1, poly2 = par_poly.offset_poly_list
for i, sec in enumerate(poly1.iter_sections()):
sec1 = sec
sec2 = poly2.sections[i]
sec1.twin = sec2
sec2.twin = sec1
# @timing
def _set_partitions(self):
for fragment in self.fragments:
self.partitions = []
for fragment in self.fragments:
partition = Shape(offset_polygon(fragment.vertices, self.offset))
self.partitions.append(partition)
# To do: This doesn't work if we have polyline shapes!
def _set_outline(self):
# outline is a special fragment that covers the whole lace
areas = []
for fragment in self.fragments:
areas.append((fragment.area, fragment))
areas.sort(reverse=True, key=lambda x: x[0])
self.outline = areas[0][1]
self.fragments.remove(self.outline)
# perimenter is the outline of the partitions
self.perimeter = Shape(offset_polygon(self.outline.vertices, -self.offset))
# skeleton is the input polylines that the lace is based on
self.skeleton = Batch(self.polyline_list)
if self._canvas:
self._canvas.new_page()
for fragment in self.fragments:
self._canvas.draw(fragment)
[docs]
def set_fragment_groups(self):
# to do : handle repeated code. same in _set_partition_groups
areas = []
for i, fragment in enumerate(self.fragments):
areas.append((fragment.area, i))
areas.sort()
bins = group_into_bins(areas, self.area_threshold)
self.fragments_by_area = OrderedDict()
for i, bin in enumerate(bins):
area_values = [x[0] for x in bin]
key = sum([x[0] for x in bin]) / len(bin)
fragments = []
for area, ind in areas:
if area in area_values:
fragments.append(self.fragments[ind])
self.fragments_by_area[key] = fragments
radii = []
for i, fragment in enumerate(self.fragments):
radii.append((distance(self.center, fragment.CG), i))
radii.sort()
bins = group_into_bins(radii, self.radius_threshold)
self.fragments_by_radius = OrderedDict()
for i, bin in enumerate(bins):
radius_values = [x[0] for x in bin]
key = sum([x[0] for x in bin]) / len(bin)
fragments = []
for radius, ind in radii:
if radius in radius_values:
fragments.append(self.fragments[ind])
self.fragments_by_radius[key] = fragments
if self._canvas:
self._canvas.new_page()
for fragment in fragments:
self._canvas.draw(fragment)
# @timing
def _set_partition_groups(self):
# to do : handle repeated code. same in set_fragment_groups
areas = []
for i, partition in enumerate(self.partitions):
areas.append((partition.area, i))
areas.sort()
bins = group_into_bins(areas, self.area_threshold)
self.partitions_by_area = OrderedDict()
for i, bin_ in enumerate(bins):
area_values = [x[0] for x in bin_]
key = sum([x[0] for x in bin_]) / len(bin_)
partitions = []
for area, ind in areas:
if area in area_values:
partitions.append(self.partitions[ind])
self.partitions_by_area[key] = partitions
radii = []
for i, partition in enumerate(self.partitions):
CG = polygon_center(partition.vertices)
radii.append((distance(self.center, CG), i))
radii.sort()
bins = group_into_bins(radii, self.radius_threshold)
self.partitions_by_radius = OrderedDict()
for i, bin_ in enumerate(bins):
radius_values = [x[0] for x in bin_]
key = sum([x[0] for x in bin_]) / len(bin_)
partitions = []
for radius, ind in radii:
if radius in radius_values:
partitions.append(self.partitions[ind])
self.partitions_by_radius[key] = partitions
# @timing
def _set_fragments(self):
G = nx.Graph()
for section in self.iter_offset_sections():
if section.is_overlap:
continue
G.add_edge(section.start.id, section.end.id, section=section)
cycles = nx.cycle_basis(G)
fragments = []
d_x = self.d_intersections
for cycle in cycles:
cycle.append(cycle[0])
nodes = cycle
edges = connected_pairs(cycle)
sections = [G.edges[edge]["section"] for edge in edges]
s_intersections = set()
for section in sections:
s_intersections.add(section.start.id)
s_intersections.add(section.end.id)
intersections = [self.d_intersections[i] for i in s_intersections]
points = [d_x[x_id].point for x_id in nodes]
if not right_handed(points):
points.reverse()
fragment = Fragment(points)
fragment.sections = sections
fragment.intersections = intersections
fragments.append(fragment)
for fragment in fragments:
for section in fragment.sections:
section.fragment = fragment
for fragment in fragments:
fragment._set_divisions()
for fragment in fragments:
fragment._set_twin_divisions()
self.fragments = fragments
def _set_concave_hull(self):
self.concave_hull = self.outline.vertices
def _set_convex_hull(self):
self.convex_hull = convex_hull(self.outline.vertices)
[docs]
def copy(self):
class Dummy(Lace):
pass
# we need to copy the polyline_list and parallel_poly_list
for polyline in self.polyline_list:
polyline.copy()
[docs]
def get_sketch(self):
"""
Create and return a Sketch object. Sketch is a Batch object
with Shape elements corresponding to the vertices of the plaits
and fragments of the Lace instance. They have 'plaits' and
'fragments' attributes to hold lists of Shape objects populated
with plait and fragment vertices of the Lace instance
respectively. They are used for drawing multiple copies of the
original lace pattern. They are light-weight compared to the
Lace objects since they only contain sufficient data to draw the
lace objects. Hundreds of these objects can be used to create
wallpaper patterns or other patterns without having to contain
unnecessary data. They do not share points with the original
Lace object.
Arguments:
----------
None
Prerequisites:
--------------
* A lace object to be copied.
Side effects:
-------------
None
Return:
--------
A Sketch object.
"""
fragments = []
for fragment in self.fragments:
polygon = Shape(fragment.vertices)
polygon.fill = True
polygon.subtype = Types.FRAGMENT
fragments.append(polygon)
plaits = []
for plait in self.plaits:
polygon = Shape(plait.vertices)
polygon.fill = True
polygon.subtype = Types.PLAIT
plaits.append(polygon)
sketch = Batch((fragments + plaits))
sketch.fragments = fragments
sketch.plaits = plaits
sketch.outline = self.outline
sketch.subtype = Types.SKETCH
sketch.draw_plaits = True
sketch.draw_fragments = True
return sketch
[docs]
def group_fragments(self, tol=None):
"""Group the fragments by the number of vertices and the area.
Args:
tol (float, optional): Tolerance value. Defaults to None.
Returns:
list: List of grouped fragments.
"""
if tol is None:
tol = defaults["tol"]
frags = self.fragments
vert_groups = [
[frag for frag in frags if len(frag.vertices) == n]
for n in set([len(f.vertices) for f in frags])
]
groups = []
for group in vert_groups:
areas = [
[f for f in group if isclose(f.area, area, rtol=tol)]
for area in set([frag.area for frag in group])
]
areas.sort(key=lambda x: x[0].area, reverse=True)
groups.append(areas)
groups.sort(key=lambda x: x[0][0].area, reverse=True)
return groups
[docs]
def get_fragment_cycles(self):
"""
Iterate over the offset sections and create a graph of the
intersections (start and end of the sections). Then find the
cycles in the graph. self.d_intersections is used to map
the graph nodes to the actual intersection points.
Returns:
list: List of fragment cycles.
"""
graph_edges = []
for section in self.iter_offset_sections():
if section.is_overlap:
continue
graph_edges.append((section.start.id, section.end.id))
return get_cycles(graph_edges)
def _set_inner_lines(self, item, n, offset, line_color=colors.blue, line_width=1):
for i in range(n):
vertices = item.vertices
dist_tol = defaults["dist_tol"]
offset_poly = offset_polygon_points(
vertices, offset * (i + 1), dist_tol=dist_tol
)
shape = Shape(offset_poly)
shape.fill = False
shape.line_width = line_width
shape.line_color = line_color
item.inner_lines.append(shape)
[docs]
def set_plait_lines(self, n, offset, line_color=colors.blue, line_width=1):
"""Create offset lines inside the plaits of the lace.
Args:
n (int): Number of lines.
offset (float): Offset value.
line_color (colors.Color, optional): Line color. Defaults to colors.blue.
line_width (int, optional): Line width. Defaults to 1.
"""
for plait in self.plaits:
plait.inner_lines = []
self._set_inner_lines(plait, n, offset, line_color, line_width)
[docs]
def set_fragment_lines(
self,
n: int,
offset: float,
line_color: colors.Color = colors.blue,
line_width=1,
) -> None:
"""
Create offset lines inside the fragments of the lace.
Args:
n (int): Number of lines.
offset (float): Offset value.
line_color (colors.Color, optional): Line color. Defaults to colors.blue.
line_width (int, optional): Line width. Defaults to 1.
"""
for fragment in self.fragments:
fragment.inner_lines = []
self._set_inner_lines(fragment, n, offset, line_color, line_width)
@property
def all_divisions(self) -> List:
"""
Return a list of all the divisions (both main and offset) in the lace.
Returns:
list: List of all divisions.
"""
res = []
for parallel_polyline in self.parallel_poly_list:
for polyline in parallel_polyline.polyline_list:
res.extend(polyline.divisions)
return res
[docs]
def iter_main_intersections(self) -> Iterator:
"""Iterate over the main intersections.
Yields:
Intersection: Intersection object.
"""
for ppoly in self.parallel_poly_list:
for division in ppoly.polyline.divisions:
yield from division.intersections
[docs]
def iter_offset_intersections(self) -> Iterator:
"""
Iterate over the offset intersections.
Yields:
Intersection: Intersection object.
"""
for ppoly in self.parallel_poly_list:
for poly in ppoly.offset_poly_list:
for division in poly.divisions:
yield from division.intersections
[docs]
def iter_offset_sections(self) -> Iterator:
"""
Iterate over the offset sections.
Yields:
Section: Section object.
"""
for ppoly in self.parallel_poly_list:
for poly in ppoly.offset_poly_list:
for division in poly.divisions:
yield from division.sections
[docs]
def iter_main_sections(self) -> Iterator:
"""Iterate over the main sections.
Yields:
Section: Section object.
"""
for ppoly in self.parallel_poly_list:
for division in ppoly.polyline.divisions:
yield from division.sections
[docs]
def iter_offset_divisions(self) -> Iterator:
"""
Iterate over the offset divisions.
Yields:
Division: Division object.
"""
for ppoly in self.parallel_poly_list:
for poly in ppoly.offset_poly_list:
yield from poly.divisions
[docs]
def iter_main_divisions(self) -> Iterator:
"""
Iterate over the main divisions.
Yields:
Division: Division object.
"""
for ppoly in self.parallel_poly_list:
yield from ppoly.polyline.divisions
@property
def main_divisions(self) -> List[Division]:
"""Main divisions are the divisions of the main polyline.
Returns:
list: List of main divisions.
"""
res = []
for parallel_polyline in self.parallel_poly_list:
res.extend(parallel_polyline.polyline.divisions)
return res
@property
def offset_divisions(self) -> List[Division]:
"""Offset divisions are the divisions of the offset polylines.
Returns:
list: List of offset divisions.
"""
res = []
for parallel_polyline in self.parallel_poly_list:
for polyline in parallel_polyline.offset_poly_list:
res.extend(polyline.divisions)
return res
@property
def intersections(self) -> List[Intersection]:
"""Return all the intersections in the parallel_poly_list.
Returns:
list: List of intersections.
"""
res = []
for parallel_polyline in self.parallel_poly_list:
for polyline in parallel_polyline.polyline_list:
res.extend(polyline.intersections)
return res
# @timing
def _set_polyline_list(self):
"""
Populate the self.polyline_list list with Polyline objects.
* Internal use only.
Arguments:
----------
None
Return:
--------
None
Prerequisites:
--------------
* self.polygon_shapes and/or self.polyline_shapes must be
established.
"""
self.polyline_list = []
if self.polygon_shapes:
for polygon in self.polygon_shapes:
self.polyline_list.append(Polyline(polygon.vertices, closed=True))
if self.polyline_shapes:
for polyline in self.polyline_shapes:
self.polyline_list.append(Polyline(polyline.vertices, closed=False))
# @timing
def _set_parallel_poly_list(self):
"""
Populate the self.parallel_poly_list list with ParallelPolyline
objects.
Arguments:
----------
None
Return:
--------
None
Prerequisites:
--------------
* self.polygon_shapes and/or self.polyline_shapes must be
established prior to this.
* Parallel polylines are created by offsetting the original
polygon and polyline shapes in two directions using the
self.offset value.
Notes:
------
This method is called by the Lace constructor. It is not
for users to call directly. Without this method, the Lace
object cannot be created.
"""
self.parallel_poly_list = []
if self.polyline_list:
for _, polyline in enumerate(self.polyline_list):
if self._canvas:
self._canvas.new_page()
self._canvas.draw(polyline, fill=False)
self.parallel_poly_list.append(
ParallelPolyline(
polyline,
self.offset,
lace=self,
closed=polyline.closed,
dist_tol=defaults["dist_tol"],
canvas=self._canvas,
)
)
# @timing
def _set_overlaps(self):
"""
Populate the self.overlaps list with Overlap objects. Side
effects listed below.
Arguments:
----------
None
Return:
--------
None
Side Effects:
-------------
* self.overlaps is populated with Overlap objects.
* Section objects' overlap attribute is populated with the
corresponding Overlap object that they are a part of. Not
all sections will have an overlap.
Prerequisites:
--------------
self.polyline and self.parallel_poly_list must be populated.
self.main_intersections, self.offset_sections and
self.d_intersections must be populated prior to creating
the overlaps.
Notes:
------
This method is called by the Lace constructor. It is not
for users to call directly.
Without this method, the Lace object cannot be created.
"""
G = nx.Graph()
for section in self.iter_offset_sections():
if section.is_overlap:
G.add_edge(section.start.id, section.end.id, section=section)
cycles = nx.cycle_basis(G)
for cycle in cycles:
cycle.append(cycle[0])
edges = connected_pairs(cycle)
sections = [G.edges[edge]["section"] for edge in edges]
s_intersections = set()
for section in sections:
s_intersections.add(section.start.id)
s_intersections.add(section.end.id)
intersections = [self.d_intersections[i] for i in s_intersections]
overlap = Overlap(intersections=intersections, sections=sections)
for section in sections:
section.overlap = overlap
for edge in edges:
section = G.edges[edge]["section"]
section.start.overlap = overlap
section.end.overlap = overlap
self.overlaps.append(overlap)
if self._canvas:
self._canvas.new_page()
for overlap in self.overlaps:
for section in overlap.sections:
if section.is_over:
line_width = 3
else:
line_width = 1
line = Shape(
[section.start.point, section.end.point], line_width=line_width
)
if self._canvas:
self._canvas.draw(line, line_width=3)
for division in self.offset_divisions:
p1 = division.start.point
p2 = division.end.point
if self._canvas:
self._canvas.draw(Shape([p1, p2]))
[docs]
def set_plaits(self):
"""
Populate the self.plaits list with Plait objects. Plaits are
optional for drawing. They form the under/over interlacing. They
are created if the "with_plaits" argument is set to be True in
the constructor. with_plaits is True by default but this can be
changed by setting the auto_plaits value to False in the
settings.py This method can be called by the user to create the
plaits after the creation of the Lace object if they were not
created initally.
* Can be called by users.
Arguments:
----------
None
Return:
--------
None
Side Effects:
-------------
* self.plaits is populated with Plait objects.
Prerequisites:
--------------
self.polyline and self.parallel_poly_list must be populated.
self.divisions and self.intersections must be populated.
self.overlaps must be populated.
Where used:
-----------
Lace.__init__
Notes:
------
This method is called by the Lace constructor. It is not
for users to call directly. Without this method, the Lace
object cannot be created.
"""
if self.plaits:
return
plait_sections = []
if self._canvas:
self._canvas.new_page()
for division in self.iter_offset_divisions():
if self._canvas:
start = division.start.point
end = division.end.point
self._canvas.draw(Shape([start, end]))
merged_sections = division._merged_sections()
for merged in merged_sections:
plait_sections.append((merged[0], merged[-1]))
# connect the open ends of the polyline_shapes
for ppoly in self.parallel_poly_list:
if not ppoly.closed:
polyline1 = ppoly.offset_poly_list[0]
polyline2 = ppoly.offset_poly_list[1]
p1_start_x = polyline1.intersections[0]
p1_end_x = polyline1.intersections[-1]
p2_start_x = polyline2.intersections[0]
p2_end_x = polyline2.intersections[-1]
plait_sections.append((p1_start_x, p2_start_x))
plait_sections.append((p1_end_x, p2_end_x))
if self._canvas:
self._canvas.new_page()
for sec in self.iter_offset_sections():
if not sec.is_over and sec.is_overlap:
plait_sections.append((sec.start, sec.end))
if self._canvas:
self._canvas.draw(
Shape([sec.start.point, sec.end.point]), line_width=2
)
if sec.is_over and sec.is_overlap:
if self._canvas:
self._canvas.draw(
Shape([sec.start.point, sec.end.point]), line_width=4
)
if self._canvas:
self._canvas.new_page()
for section in plait_sections:
self._canvas.draw(
Shape([section[0].point, section[1].point]), line_width=2
)
graph_edges = [(r[0].id, r[1].id) for r in plait_sections]
cycles = get_cycles(graph_edges)
plaits = []
count = 0
for cycle in cycles:
cycle = connected_pairs(cycle)
dup = cycle[1:]
plait = [cycle[0][0], cycle[0][1]]
for _ in range(len(cycle) - 1):
last = plait[-1]
for edge in dup:
if edge[0] == last:
plait.append(edge[1])
dup.remove(edge)
break
if edge[1] == last:
plait.append(edge[0])
dup.remove(edge)
break
plaits.append(plait)
count += 1
d_x = self.d_intersections
for plait in plaits:
intersections = [d_x[x] for x in plait]
vertices = [x.point for x in intersections]
if not right_handed(vertices):
vertices.reverse()
plait.reverse()
shape = Shape(vertices)
shape.intersections = intersections
shape.fill_color = colors.gold
shape.inner_lines = None
shape.subtype = Types.PLAIT
self.plaits.append(shape)
if self._canvas:
self._canvas.new_page()
for plait in self.plaits:
self._canvas.draw(plait, fill=True)
# @timing
def _set_intersections(self):
"""
Compute all intersection points (by calling all_intersections)
among the divisions of the polylines (both main and offset).
Populate the self.main_intersections and
self.offset_intersections lists with Intersection objects. This
method is called by the Lace constructor and customized to be
used with Lace objects only. Without this method, the Lace
object cannot be created.
* Internal use only!
Arguments:
----------
None
Return:
--------
None
Side Effects:
-------------
* self.main_intersections are populated.
* self.offset_intersections are populated.
* "sections" attribute of the divisions are populated.
* "is_overlap" attribute of the sections are populated.
* "intersections" attribute of the divisions are populated.
* "endpoint" attribute of the intersections are populated.
Where used:
-----------
Lace.__init__
Prerequisites:
--------------
* self.main_divisions must be populated.
* self.offset_divisions must be populated.
* Two endpoint intersections of the divisions must be set.
Notes:
------
This method is called by the Lace constructor. It is not
for users to call directly. Without this method, the Lace
object cannot be created.
Works only for regular under/over interlacing.
"""
# set intersections for the main polylines
main_divisions = self.main_divisions
offset_divisions = self.offset_divisions
self.main_intersections = all_intersections(
main_divisions, self.d_intersections, self.d_connections
)
# set sections for the main divisions
self.main_sections = []
for division in main_divisions:
division.intersections[0].endpoint = True
division.intersections[-1].endpoint = True
segments = connected_pairs(division.intersections)
division.sections = []
for i, segment in enumerate(segments):
section = Section(*segment)
if i % 2 == 1:
section.is_overlap = True
division.sections.append(section)
self.main_sections.append(section)
if self._canvas:
for section in self.main_sections:
self._canvas.draw(
Shape([section.start.point, section.end.point]), line_width=2
)
# set intersections for the offset polylines
self.offset_intersections = all_intersections(
offset_divisions, self.d_intersections, self.d_connections
)
if self._canvas:
self._canvas.new_page()
for intersection in self.offset_intersections:
self._canvas.circle(intersection.point, 2)
for division in offset_divisions:
p1 = division.start.point
p2 = division.end.point
self._canvas.draw(Shape([p1, p2]))
# set sections for the offset divisions
self.offset_sections = []
for i, division in enumerate(offset_divisions):
division.intersections[0].endpoint = True
division.intersections[-1].endpoint = True
lines = connected_pairs(division.intersections)
division.sections = []
for j, line in enumerate(lines):
section = Section(*line)
if j % 2 == 1:
section.is_overlap = True
division.sections.append(section)
self.offset_sections.append(section)
if self._canvas:
self._canvas.new_page()
for section in self.offset_sections:
self._canvas.draw(
Shape([section.start.point, section.end.point]), line_width=2
)
# to do: intersect main divisions with the offset divisions
def _all_polygons(self, polylines, rtol=None):
"""Return a list of polygons from a list of lists of points.
polylines: [[(x1, y1), (x2, y2)], [(x3, y3), (x4, y4)], ...]
return [[(x1, y1), (x2, y2), (x3, y3), ...], ...]
Args:
polylines (list): List of lists of points.
rtol (float, optional): Relative tolerance. Defaults to None.
Returns:
list: List of polygons.
"""
if rtol is None:
rtol = self.rtol
return get_polygons(polylines, rtol)
# @timing
def _set_over_under(self):
def next_poly(exclude):
for ppoly in self.parallel_poly_list:
poly1, poly2 = ppoly.offset_poly_list
if poly1 in exclude:
continue
ind = 0
for sec in poly1.iter_sections():
if sec.is_overlap:
if sec.overlap.drawable and sec.overlap.visited:
even_odd = ind % 2 == 1
return (poly1, poly2, even_odd)
ind += 1
return (None, None, None)
for ppoly in self.parallel_poly_list:
poly1, poly2 = ppoly.offset_poly_list
exclude = []
even_odd = 0
while poly1:
ind = 0
for i, division in enumerate(poly1.divisions):
for j, section in enumerate(division.sections):
if self._canvas:
if j == 0:
rad = 1
else:
rad = 2
self._canvas.circle(section.mid_point, rad)
if section.is_overlap:
if section.overlap is None:
msg = (
"Overlap section in the lace has no "
"overlap object.\n"
"Try different offset value and/or "
"tolerance."
)
raise RuntimeError(msg)
section.overlap.visited = True
if ind % 2 == even_odd:
if section.overlap.drawable:
section.overlap.drawable = False
section.is_over = True
section2 = poly2.divisions[i].sections[j]
section2.is_over = True
ind += 1
exclude.extend([poly1, poly2])
poly1, poly2, even_odd = next_poly(exclude)
[docs]
def fragment_edge_graph(self) -> nx.Graph:
"""
Return a networkx graph of the connected fragments.
If two fragments have a "common" division then they are connected.
Returns:
nx.Graph: Graph of connected fragments.
"""
G = nx.Graph()
fragments = [(f.area, f) for f in self.fragments]
fragments.sort(key=lambda x: x[0], reverse=True)
for fragment in [f[1] for f in fragments[1:]]:
for division in fragment.divisions:
if division.twin and division.twin.fragment:
fragment2 = division.twin.fragment
G.add_node(fragment.id, fragment=fragment)
G.add_node(fragment2.id, fragment=fragment2)
G.add_edge(fragment.id, fragment2.id, edge=division)
return G
[docs]
def fragment_vertex_graph(self) -> nx.Graph:
"""
Return a networkx graph of the connected fragments.
If two fragments have a "common" vertex then they are connected.
Returns:
nx.Graph: Graph of connected fragments.
"""
def get_neighbours(intersection):
division = intersection.division
if not division.next.twin:
return None
fragments = [division.fragment]
start_division_id = division.id
if division.next.twin:
twin_id = division.next.twin.id
else:
return None
while twin_id != start_division_id:
if division.next.twin:
if division.fragment.id not in [x.id for x in fragments]:
fragments.append(division.fragment)
twin_id = division.next.twin.id
division = division.next.twin
else:
if division.next.fragment.id not in [x.id for x in fragments]:
fragments.append(division.next.fragment)
break
return fragments
neighbours = []
for fragment in self.fragments:
for intersection in fragment.intersections:
neighbours.append(get_neighbours(intersection))
G = self.fragment_edge_graph()
G2 = nx.Graph()
for n in neighbours:
if n:
if len(n) > 2:
for pair in combinations(n, 2):
ids = tuple([x.id for x in pair])
if ids not in G.edges:
G2.add_node(ids[0], fragment=pair[0])
G2.add_node(ids[1], fragment=pair[1])
G2.add_edge(*ids)
return G2
[docs]
def all_intersections(
division_list: list[Division],
d_intersections: dict[int, Intersection],
d_connections: dict[frozenset, Intersection],
loom=False,
) -> list[Intersection]:
"""
Find all intersections of the given divisions. Sweep-line algorithm
without a self-balancing tree. Instead of a self-balancing tree,
it uses a numpy array to sort and filter the divisions. For the
number of divisions that are commonly needed in a lace, this is
sufficiently fast. It is also more robust and much easier to
understand and debug. Tested with tens of thousands of divisions but
not millions. The book has a section on this algorithm.
simetri.geometry.py has another version (called
all_intersections) for finding intersections among a given
list of divisions.
Arguments:
----------
division_list: list of Division objects.
Side Effects:
-------------
* Modifies the given division objects (in the division_list) in place
by adding the intersections to the divisions' "intersections"
attribute.
* Updates the d_intersections
* Updates the d_connections
Return:
--------
A list of all intersection objects among the given division
list.
"""
# register fake intersections at the endpoints of the open lines
for division in division_list:
if division.intersections:
for x in division.intersections:
d_intersections[x.id] = x
# All objects are assigned an integer id attribute when they are created
division_array = array(
[flatten(division.vertices) + [division.id] for division in division_list]
)
n_divisions = division_array.shape[0] # number of divisions
# precompute the min and max x and y values for each division
# these will be used with the sweep line algorithm
xmin = np.minimum(division_array[:, 0], division_array[:, 2]).reshape(
n_divisions, 1
)
xmax = np.maximum(division_array[:, 0], division_array[:, 2]).reshape(
n_divisions, 1
)
ymin = np.minimum(division_array[:, 1], division_array[:, 3]).reshape(
n_divisions, 1
)
ymax = np.maximum(division_array[:, 1], division_array[:, 3]).reshape(
n_divisions, 1
)
division_array = np.concatenate((division_array, xmin, ymin, xmax, ymax), 1)
d_divisions = {}
for division in division_list:
d_divisions[division.id] = division
i_id, i_xmin, i_ymin, i_xmax, i_ymax = range(4, 9) # column indices
# sort by xmin values
division_array = division_array[division_array[:, i_xmin].argsort()]
intersections = []
for i in range(n_divisions):
x1, y1, x2, y2, id1, sl_xmin, sl_ymin, sl_xmax, sl_ymax = division_array[i, :]
division1_vertices = [x1, y1, x2, y2]
start = i + 1 # search should start from the next division
# filter the array by checking if the divisions' bounding-boxes are
# overlapping with the bounding-box of the current division
candidates = division_array[start:, :][
(
(
(division_array[start:, i_xmax] >= sl_xmin)
& (division_array[start:, i_xmin] <= sl_xmax)
)
& (
(division_array[start:, i_ymax] >= sl_ymin)
& (division_array[start:, i_ymin] <= sl_ymax)
)
)
]
for candid in candidates:
id2 = candid[i_id]
division2_vertices = candid[:4]
if loom:
x1, y1 = division1_vertices[:2]
x2, y2 = division2_vertices[:2]
if x1 == x2 or y1 == y2:
continue
connection_type, x_point = intersection2(
*division1_vertices, *division2_vertices
)
if connection_type not in [
Connection.DISJOINT,
Connection.NONE,
Connection.PARALLEL,
]:
division1, division2 = d_divisions[int(id1)], d_divisions[int(id2)]
x_point__e1_2 = frozenset((division1.id, division2.id))
if x_point__e1_2 not in d_connections:
inters_obj = Intersection(x_point, division1, division2)
d_intersections[inters_obj.id] = inters_obj
d_connections[x_point__e1_2] = inters_obj
division1.intersections.append(inters_obj)
division2.intersections.append(inters_obj)
intersections.append(inters_obj)
for division in division_list:
division._sort_intersections()
return intersections
[docs]
def merge_nodes(
division_list: list[Division],
d_intersections: dict[int, Intersection],
d_connections: dict[frozenset, Intersection],
loom=False,
) -> list[Intersection]:
"""
Find all intersections of the given divisions. Sweep-line algorithm
without a self-balancing tree. Instead of a self-balancing tree,
it uses a numpy array to sort and filter the divisions. For the
number of divisions that are commonly needed in a lace, this is
sufficiently fast. It is also more robust and much easier to
understand and debug. Tested with tens of thousands of divisions but
not millions. The book has a section on this algorithm.
simetri.geometry.py has another version (called
all_intersections) for finding intersections among a given
list of divisions.
Arguments:
----------
division_list: list of division objects.
Side Effects:
-------------
* Modifies the given division objects (in the division_list) in place
by adding the intersections to the divisions' "intersections"
attribute.
* Updates the d_intersections
* Updates the d_connections
Return:
--------
A list of all intersection objects among the given division
list.
"""
# register fake intersections at the endpoints of the open lines
for division in division_list:
if division.intersections:
for x in division.intersections:
d_intersections[x.id] = x
# All objects are assigned an integer id attribute when they are created
division_array = array(
[flatten(division.vertices) + [division.id] for division in division_list]
)
n_divisions = division_array.shape[0] # number of divisions
# precompute the min and max x and y values for each division
# these will be used with the sweep line algorithm
xmin = np.minimum(division_array[:, 0], division_array[:, 2]).reshape(
n_divisions, 1
)
xmax = np.maximum(division_array[:, 0], division_array[:, 2]).reshape(
n_divisions, 1
)
ymin = np.minimum(division_array[:, 1], division_array[:, 3]).reshape(
n_divisions, 1
)
ymax = np.maximum(division_array[:, 1], division_array[:, 3]).reshape(
n_divisions, 1
)
division_array = np.concatenate((division_array, xmin, ymin, xmax, ymax), 1)
d_divisions = {}
for division in division_list:
d_divisions[division.id] = division
i_id, i_xmin, i_ymin, i_xmax, i_ymax = range(4, 9) # column indices
# sort by xmin values
division_array = division_array[division_array[:, i_xmin].argsort()]
intersections = []
for i in range(n_divisions):
x1, y1, x2, y2, id1, sl_xmin, sl_ymin, sl_xmax, sl_ymax = division_array[i, :]
division1_vertices = [x1, y1, x2, y2]
start = i + 1 # search should start from the next division
# filter the array by checking if the divisions' bounding-boxes are
# overlapping with the bounding-box of the current division
candidates = division_array[start:, :][
(
(
(division_array[start:, i_xmax] >= sl_xmin)
& (division_array[start:, i_xmin] <= sl_xmax)
)
& (
(division_array[start:, i_ymax] >= sl_ymin)
& (division_array[start:, i_ymin] <= sl_ymax)
)
)
]
for candid in candidates:
id2 = candid[i_id]
division2_vertices = candid[:4]
if loom:
x1, y1 = division1_vertices[:2]
x2, y2 = division2_vertices[:2]
if x1 == x2 or y1 == y2:
continue
connection_type, x_point = intersection2(
*division1_vertices, *division2_vertices
)
if connection_type not in [
Connection.DISJOINT,
Connection.NONE,
Connection.PARALLEL,
]:
division1, division2 = d_divisions[int(id1)], d_divisions[int(id2)]
x_point__e1_2 = frozenset((division1.id, division2.id))
if x_point__e1_2 not in d_connections:
inters_obj = Intersection(x_point, division1, division2)
d_intersections[inters_obj.id] = inters_obj
d_connections[x_point__e1_2] = inters_obj
division1.intersections.append(inters_obj)
division2.intersections.append(inters_obj)
intersections.append(inters_obj)
for division in division_list:
division._sort_intersections()
return intersections