"""
supercore.py
This file contains the SuperStructure and ContinuousLineBuilder classes.
Classes
`SuperStructure`: A subclass of Structure that provides additional methods for routing and adding
structures along a skeleton line.
`ContinuousLineBuilder`: Builds continuous lines and structures by defining a sequence of operations.
"""
import numpy as np
from warnings import warn
from dataclasses import dataclass
from shapely import (line_locate_point, line_interpolate_point, intersection_all, distance)
from shapely import LineString, Point, Polygon, MultiLineString, MultiPolygon, GeometryCollection, MultiPoint
from .anchors import Anchor, MultiAnchor, Skeletone, Layer
from .core import Structure, Entity
from .geometries import ArcLine
from .plotting import ColorHandler
from .utils import (fmodnew, flatten_lines, to_geometry_list, round_corner, buffer_line_with_variable_width)
from .functions import get_normals_along_line
from .routing import create_route
from .errors import WrongSizeError
def get_endpoint_indices(x: tuple=(1,0)) -> tuple:
"""
Calculates the indices of endpoints based on the provided tuple.
Args:
x (tuple): A tuple containing two elements, either (1, 0) or (0, 1).
Defaults to (1, 0).
Returns:
tuple: A tuple containing the indices of the endpoints.
Raises:
ValueError: If the provided tuple is not (1, 0) or (0, 1).
"""
if x == (1, 0):
return (None, -1)
elif x == (0, 1):
return (1, None)
else:
raise ValueError("Provide a valid tuple of (1, 0) or (0, 1).")
[docs]
@dataclass(slots=True)
class RoutingConfig:
"""
Represents the configuration for routing.
Args:
radius (float, optional): The radius of the routing. Defaults to 50.
num_segments (int, optional): The number of segments in the routing. Defaults to 13.
"""
radius: float = 50
"""The radius of the routing. Defaults to 50."""
num_segments: int = 13
"""The number of segments in the routing. Defaults to 13."""
[docs]
@dataclass(slots=True)
class ObjsAlongConfig:
"""
Configuration class for objects evenly placed along a line.
Args:
structure (object): The structure to be added along the skeleton line.
spacing (float, optional): The spacing between the added objects. Defaults to 100.
endpoints (tuple or bool, optional): The endpoints of the skeleton line where the objects should be added.
If a tuple is provided, it should contain the indices of the desired endpoints.
If True, the objects will be added along the entire skeleton line.
Defaults to True.
additional_rotation (float, optional): Additional rotation to be applied to the added objects. Defaults to 0.
"""
structure: Structure
"""The structure to be added along the skeleton line."""
spacing: float = 100
"""The spacing between the added objects. Defaults to 100."""
endpoints: tuple | bool = True
"""The endpoints of the skeleton line where the objects should be added."""
additional_rotation: float = 0
"""Additional rotation to be applied to the added objects."""
[docs]
class SuperStructure(Structure):
"""
A subclass of Structure representing a superstructure.
Provides additional methods for routing and adding structures along a skeleton line.
Args
route_config : dict
Configuration for routing.
"""
def __init__(self, route_config: dict):
self._route_config = route_config
super().__init__()
[docs]
def route(self,
anchors: tuple,
layers: dict=None,
airbridge: Entity | Structure=None,
extra_rotation: float=0,
print_status: bool=False,
rm_anchor: bool | tuple | str=False,
rm_route: bool=False,
cap_style: str="flat",
**kwargs) -> None:
"""
Routes between anchors and creates a route line with optional buffering.
If airbridge is provided, it will be added to the route,
allowing for crossings with the skeleton line.
Args:
anchors (tuple):
The anchors to route between. Provide labels.
layers (dict):
The layer width information.
airbridge (Entity | Structure):
The airbridge structure.
Should contain 'in' and 'out' anchors. Defaults to None
extra_rotation (float, optional):
Additional rotation angle for the airbridge. Defaults to 0.
print_status (bool, optional):
Whether to print the status of the route creation. Defaults to False.
rm_anchor (bool or str, optional):
If True, removes the anchor points after appending.
If a string is provided, removes the specified anchor point.
Defaults to False.
rm_route (bool, optional):
Whether to remove created route line. Defaults to False.
cap_style (str, optional):
The cap style for the buffered line. Defaults to "flat".
"""
p_start = self.anchors[anchors[0]].point
p_end = self.anchors[anchors[-1]].point
if airbridge:
if not airbridge.anchors.has_label(['in', 'out']):
raise TypeError("airbridge anchors could be only 'in' and 'out'")
# getting route line along the anchor points
route_line = LineString()
for labels in zip(anchors, anchors[1:]):
line = create_route(a1=self.anchors[labels[0]],
a2=self.anchors[labels[1]],
radius=self._route_config.get("radius"),
num_segments=self._route_config.get("num_segments"),
print_status=print_status,
**kwargs)
route_line = flatten_lines(route_line, line)
# get all intersection points with route line and skeletone
intersections = intersection_all([route_line, self.skeletone.lines])
# get valid intesections
if not intersections.is_empty:
# create a list of points
list_of_intersection_points = to_geometry_list(intersections)
# getting airbridge locations (i.e. removing start and end points)
ab_locs = [p for p in list_of_intersection_points if p not in [p_start, p_end]]
###################################
#### buffering along the route ####
###################################
if (intersections.is_empty) or (ab_locs==[]) or (airbridge is None):
# in case of (no intersections) or (no ab_locs) or (no airbridge) - make a simple route structure
self.bufferize_routing_line(route_line, layers, keep_line=False, cap_style=cap_style)
# remove or not to remove route line
if not rm_route:
self.skeletone.add(route_line, chaining=False, ignore_crossing=True)
else:
# getting list of distances of airbridge locations from starting point
list_distances = np.asarray(list(map(distance, ab_locs, [p_start]*len(ab_locs))))
sorted_distance_indicies = np.argsort(list_distances)
ab_locs_on_skeletone = line_locate_point(self.skeletone.lines, ab_locs, normalized=True)
ab_angles = get_normals_along_line(self.skeletone.lines, ab_locs_on_skeletone)
# create route anchor list with temporary anchor list, which will be deleted in the end
route_anchors = [anchors[0]]
temporary_anchors = []
# adding airbridges to superstructure
for i, idx in enumerate(sorted_distance_indicies):
ab_coords = (ab_locs[idx].x, ab_locs[idx].y)
ab_angle = fmodnew(ab_angles[idx] + 90 + extra_rotation)
ab = airbridge.copy()
ab.rotate(angle=ab_angle)
ab.move(*ab_coords)
# correcting the orientation of the airbridge if 'in' and 'out' are swapped
distance2in = distance(ab.anchors["in"].point, self.anchors[route_anchors[-1]].point)
distance2out = distance(ab.anchors["out"].point, self.anchors[route_anchors[-1]].point)
if distance2out < distance2in:
ab.rotate(180, origin=ab_coords)
for label in ['in', 'out']:
temporary_name = str(i) + label
ab.anchors.modify(label=label,
new_name=temporary_name)
route_anchors.append(temporary_name)
temporary_anchors.append(temporary_name)
self.append(ab)
route_anchors.append(anchors[1])
# adding all routes between airbridge anchors
for labels in zip(route_anchors[::2], route_anchors[1::2]):
route_line = create_route(a1=self.anchors[labels[0]],
a2=self.anchors[labels[1]],
radius=self._route_config.get("radius"),
num_segments=self._route_config.get("num_segments"),
print_status=print_status,
**kwargs)
self.bufferize_routing_line(route_line, layers, keep_line=False)
# remove or not to remove route line
if not rm_route:
self.skeletone.add(route_line, chaining=False, ignore_crossing=True)
# remove temporary anchors
self.anchors.remove(temporary_anchors)
# remove or not to remove anchors used for routing
if rm_anchor==True:
self.anchors.remove(*anchors)
elif isinstance(rm_anchor, (str, tuple)):
self.anchors.remove(rm_anchor)
[docs]
def add_along_skeletone(self,
bound_anchors: tuple,
structure: Structure | Entity,
locs: list=None,
num: int=1,
endpoints: bool|tuple=False,
normalized: bool=False,
additional_rotation: float | int=0,
line_idx: int=None) -> None:
"""
Adds structures along the skeleton line.
Args:
bound_anchors (tuple): Skeleton region contained between two anchors.
structure (Structure | Entity): Structure to be added.
locs (list, optional): List of locations along the skeleton line where the structures will be added.
If not provided, the structures will be evenly distributed between the two anchor points.
num (int, optional): Number of structures to be added. Ignored if locs is provided.
endpoints (bool|tuple, optional): Whether to include the endpoints of the skeleton line as locations for adding structures.
ex. tuple = (1,0) includes start point and excludes end point.
Defaults to False.
normalized (bool, optional): Whether the locations are normalized along the skeleton line. Defaults to False.
Raises:
WrongSizeError: If the number of bound_anchors is not equal to 2.
Example:
# Adds 5 structures evenly distributed along the skeleton line between anchor1 and anchor2
>>> add_along_skeleton((anchor1, anchor2), structure, num=5)
# Adds 3 structures at specific locations along the skeleton line between anchor1 and anchor2
>>> add_along_skeleton((anchor1, anchor2), structure, locs=[0.2, 0.5, 0.8])
"""
if len(bound_anchors) != 2:
raise WrongSizeError(f"Provide 2 anchors! Instead {len(bound_anchors)} is given.")
p1 = self.anchors[bound_anchors[0]].point
p2 = self.anchors[bound_anchors[1]].point
if line_idx:
line = self.skeletone.lines.geoms[line_idx]
else:
line = self.skeletone.lines
start_point = line_locate_point(line, p1, normalized=True)
end_point = line_locate_point(line, p2, normalized=True)
extra_rotation = 0
if locs is None:
if isinstance(endpoints, tuple):
i1, i2 = get_endpoint_indices(endpoints)
locs = np.linspace(start_point, end_point, num=num+1, endpoint=True)[i1:i2]
elif endpoints == True:
locs = np.linspace(start_point, end_point, num=num, endpoint=True)
else:
locs = np.linspace(start_point, end_point, num=num+2, endpoint=True)[1:-1]
normalized = True
extra_rotation = 90
pts = line_interpolate_point(line, locs, normalized=normalized).tolist()
normal_angles = get_normals_along_line(line, locs, normalized) + extra_rotation # figure out why extra_rotation is added
for point, angle in zip(pts, normal_angles):
s = structure.copy()
s.rotate(angle + additional_rotation)
s.move(point.x, point.y)
s.anchors.remove()
self.append(s)
[docs]
def bufferize_routing_line(self,
line: LineString,
layers: float | int | list | dict,
keep_line: bool=True,
cap_style: str="flat") -> None:
"""
Appends route to skeleton and create polygons by buffering.
Args:
line (LineString): The route line.
layers (Union[float, int, list, dict]): The layer information.
It can be a single value, a list of values, or a dictionary with distances and widths.
Examples:
>>> line = LineString([(0, 0), (1, 1), (2, 2)])
>>> layers = {'layer1': 0.1, 'layer2': [0.2, 0.3, 0.4]}
>>> bufferize_routing_line(line, layers)
>>> line = LineString([(0, 0), (1, 1), (2, 2)])
>>> layers = {'layer1': {'d': [0, 0.5, 1], 'w': [0.1, 0.2, 0.1], 'normalized': True}}
>>> bufferize_routing_line(line, layers)
>>> line = LineString([(0, 0), (1, 1), (2, 2)])
>>> layers = {'layer1': [0.1, 0.2, 0.3], 'layer2': {'d': [0, 0.5, 1], 'w': [0.2, 0.3, 0.2], 'normalized': False}}
>>> bufferize_routing_line(line, layers)
"""
s = Structure()
if keep_line:
s.skeletone.add(line)
if layers:
for k, width in layers.items():
if isinstance(width, (int, float)):
poly = line.buffer(distance=width/2, cap_style=cap_style)
elif isinstance(width, (list, np.ndarray)):
distances = np.linspace(0, 1, len(width), endpoint=True)
poly = buffer_line_with_variable_width(line, distances, width, normalized=True, join_style='flat')
elif isinstance(width, dict):
distances = np.asarray(width.get("d"))
widths = np.asarray(width.get("w"))
norm = width.get("normalized")
poly = buffer_line_with_variable_width(line, distances, widths, normalized=norm, join_style='flat')
else:
raise TypeError("Provide a valid 'layers' dictionary.")
s.add(Layer(k, poly))
self.append(s)
[docs]
def round_corner(
self,
layers: str | list[str],
around_point: tuple | Point,
radius: float,
**kwargs
) -> "SuperStructure":
"""
Rounds the corner of the polygon closest to a given Point in a specific layer.
Args:
layers (str | list[str]): The layer(s) on which the operation should be performed.
around_point (tuple | Point): The point around which the corner should be rounded.
radius (float | int): The radius to be applied for rounding the corners.
**kwargs: Additional keyword arguments to be passed to the rounding function.
Returns:
SuperStructure:
The modified SuperStructure instance with the rounded corner applied to the specified layer.
"""
if isinstance(around_point, tuple):
around_point = Point(around_point)
if isinstance(layers, str):
layers = [layers]
for layer in layers:
original = getattr(self, layer).polygons
rounded = round_corner(original, around_point, radius, **kwargs)
getattr(self, layer).polygons = rounded
return self
class GeomCollection(SuperStructure):
"""
Represents a collection of geometries.
Class attributes are created by layers dictionary.
Args:
layers (dict): Dictionary containing the layers and corresponding polygons/skeletone/anchors/colors.
"""
def __init__(self, layers: dict=None):
super().__init__(route_config={"radius": 50, "num_segments": 13})
if layers:
for items in layers.items():
match items:
case ("skeletone", LineString()) | ("skeletone", MultiLineString()):
self.skeletone.lines = items[1]
case ("skeletone", Skeletone()):
self.skeletone = items[1]
case ("skeletone", GeometryCollection()):
warn(message="imported skeletone contains GeometryCollection object. It will be ignored.")
case ("anchors", MultiAnchor()):
self.anchors = items[1]
case ("anchors", MultiPoint()):
for i, pt in enumerate(items[1].geoms):
self.anchors.add(Anchor(pt, 0, "anchor" + str(i)))
case ("colors", ColorHandler()):
pass
# self.colors = items[1]
case (str(), Polygon()) | (str(), MultiPolygon()):
layer = Layer(name=items[0], polygons=items[1])
self.layers.append(items[0])
setattr(self, items[0], layer)
case _:
self.layers.append(items[0])
setattr(self, *items)
if not hasattr(self, "anchors"):
self.anchors = MultiAnchor()
if not hasattr(self, "skeletone"):
self.skeletone = Skeletone()
# if self.colors.is_empty:
# self.colors.update_colors(self.layers)
[docs]
class ContinuousLineBuilder():
"""
Class for building continuous lines and structures.
Args:
routing (RoutingConfig): The routing configuration.
layers (dict): A dictionary containing layer names as keys and buffer widths as values.
objs_along (ObjsAlongConfig): The configuration for adding objects along the skeleton line.
Example:
>>> s = Structure()
>>> s.add_layer("l1", Square(10))
>>> clb = ContinuousLineBuilder(routing=RoutingConfig(radius=10, num_segments=10),
layers={"l1": 2, "l2": 4},
objs_along=ObjsAlongConfig(structure=s, spacing=40))
>>> a = Anchor((100,0), 0, "b")
>>> clb.start(Anchor((1,2), 30, "a")).turn(-60,14).go(23,65).routeto(a).go(30,110).forward(36).build_all()
"""
__slots__ = ("routing", "layers", "objs_along",
"absolute_angle", "skeletone", "anchors",
"starting_coords", "structure")
def __init__(self,
routing: RoutingConfig=None,
layers: dict=None,
objs_along: ObjsAlongConfig=None):
self.routing = routing
"""The routing configuration."""
self.layers = layers
"""A dictionary containing layer names as keys and buffer widths as values."""
self.objs_along = objs_along
"""The configuration for adding objects along the skeleton line."""
self.absolute_angle = 0
"""The current absolute angle of the line being built."""
def __repr__(self):
name = f"<CONTINUOUSLINEBUILDER {self.layers}>"
max_length = 75
if len(name) > max_length:
return f"{name[: max_length - 3]}...>"
return name
[docs]
def start(self, anchor: Anchor) -> 'ContinuousLineBuilder':
"""
Adds the given anchor to the set of anchors and initializes the starting coordinates and absolute angle.
Args:
anchor (Anchor): The anchor object to be added. It should have 'direction' and 'coords' Args.
Returns:
ContinuousLineBuilder: The instance of the ContinuousLineBuilder with the updated state.
Example:
>>> anchor = Anchor(coords=(10,20), direction=90, "a")
>>> instance = ContinuousLineBuilder()
>>> instance.start(anchor)
<MyClass object at 0x...>
"""
self.skeletone = Skeletone()
"""The Skeletone object representing the skeleton line."""
self.anchors = MultiAnchor()
"""The MultiAnchor object containing the set of anchors."""
self.structure = Structure()
"""The Structure object representing the built structure."""
self.anchors.add(anchor)
self.absolute_angle = anchor.direction
self.starting_coords = anchor.coords
"""The starting coordinates of the line being built."""
return self
[docs]
def forward(self, length: float=1) -> 'ContinuousLineBuilder':
"""
Creates a line and adds to a skeletone.
Args:
length (float): The length of the line. Default is 1.
Returns:
ContinuousLineBuilder: The instance of the ContinuousLineBuilder with the updated line.
"""
new_line = LineString([(0,0), (length,0)])
self.skeletone.add(line = new_line,
direction = self.absolute_angle,
ignore_crossing = True,
chaining = True)
if self.starting_coords:
self.skeletone.move(*self.starting_coords)
self.starting_coords = None
return self
[docs]
def turn(self, angle: float=90, radius: float=1, num_segments: int=13) -> 'ContinuousLineBuilder':
"""
Creates an arcline and appends to a skeletone.
Args:
angle (float): turn angle of the arcline. Default is 90.
radius (float): radius of the arcline. Default is 1.
num_segments (int): number of segments of the arcline. Default is 13.
Returns:
ContinuousLineBuilder: The instance of the ContinuousLineBuilder with the updated arcline.
"""
new_line = ArcLine(centerx = 0,
centery = np.sign(angle) * radius,
radius = radius,
start_angle = -np.sign(angle) * 90,
end_angle = -np.sign(angle) * 90 + angle,
numsegments = num_segments)
self.skeletone.add(line = new_line,
direction = self.absolute_angle,
ignore_crossing = True,
chaining = True)
self.absolute_angle = fmodnew(self.absolute_angle + angle)
return self
[docs]
def add_anchor(self, name: str, angle: float=None, type: str=None) -> 'ContinuousLineBuilder':
"""
Adds an Anchor at the location of the skeletone end point with given angle and type.
Args:
name (str): The name of the anchor.
angle (float, optional): The angle of the anchor. Defaults to None.
type (str, optional): The type of angle calculation. Can be "absolute", "relative", or None. Defaults to None.
Returns:
ContinuousLineBuilder: The instance of the ContinuousLineBuilder with the updated anchor.
"""
_, end_p = self.skeletone.boundary
if type == "absolute":
angle = angle
elif type == "relative":
angle = fmodnew(self.absolute_angle + angle)
else:
angle = self.absolute_angle
self.anchors.add(Anchor(end_p, angle, name))
return self
[docs]
def end(self, name: str) -> None:
"""
Adds an anchor at the end of the skeletone
Args:
name (str): The name of the anchor to be added at the end of the skeletone.
"""
self.add_anchor(name)
[docs]
def go(self, length: float=1, angle: float=90) -> 'ContinuousLineBuilder':
"""
Combines the forward and turn methods in one action.
Routing should be set before using this method.
Args:
length (float): The length of the straight section. Default is 1.
angle (float): The angle of the turn. Default is 90.
Returns:
ContinuousLineBuilder: The instance of the ContinuousLineBuilder with the updated line and turn.
Error:
Raises ValueError if routing config is not set.
"""
if not self.routing:
raise ValueError("Routing config is not set. Provide dict with keys 'radius' and 'num_segments'")
self.forward(length)
self.turn(angle, self.routing.radius, self.routing.num_segments)
return self
[docs]
def routeto(self, anchor: Anchor, **kwargs) -> 'ContinuousLineBuilder':
"""
Routes the last point in the skeletone with given anchor.
routing should be set before using this method.
Args:
anchor (Anchor): the anchor point to which the route will be created.
**kwargs: additional parameters to be passed to create_route method.
Returns:
ContinuousLineBuilder: The instance of the ContinuousLineBuilder with the updated route.
Error:
Raises ValueError if routing config is not set.
"""
if not self.routing:
raise ValueError("Routing config is not set. Provide dict with keys 'radius' and 'num_segments'")
self.add_anchor("temp")
new_line = create_route(self.anchors["temp"],
anchor,
self.routing.radius,
self.routing.num_segments,
**kwargs)
self.skeletone.add(line = new_line,
direction = None,
ignore_crossing = True,
chaining = False)
self.skeletone.fix()
self.absolute_angle = anchor.direction
self.anchors.remove("temp")
return self
[docs]
def build_layers(self, layers: dict=None, cap_style: str='round', **kwargs) -> 'ContinuousLineBuilder':
"""
Build layers for the structure.
Args:
layers (dict, optional): A dictionary containing layer names as keys and buffer widths as values.
If not provided, the layers from the object's attribute `layers` will be used.
**kwargs: Additional keyword arguments to be passed to the `buffer` method.
Returns:
ContinuousLineBuilder: The instance of the ContinuousLineBuilder with the updated layers.
"""
if not layers:
layers = self.layers
for lname, buffer_width in layers.items():
poly = self.skeletone.buffer(buffer_width/2, join_style="mitre", cap_style=cap_style, **kwargs)
if self.structure.has_layer(lname):
getattr(self.structure, lname).add(poly)
else:
self.structure.add(Layer(lname, poly))
return self
[docs]
def add_along_skeletone(self, **kwargs) -> 'ContinuousLineBuilder':
"""
Add objects along the skeleton line.
Args:
structure (object): The structure to be added along the skeleton line.
spacing (float): The spacing between the added objects.
endpoints (tuple or bool): The endpoints of the skeleton line where the objects should be added.
If a tuple is provided, it should contain the indices of the desired endpoints.
If True, the objects will be added along the entire skeleton line.
additional_rotation (float): Additional rotation to be applied to the added objects.
Returns:
ContinuousLineBuilder: The instance of the ContinuousLineBuilder with the updated structure.
"""
for k,v in kwargs.items():
setattr(self.objs_along, k, v)
num = int(self.skeletone.length / self.objs_along.spacing) + 1
p1, p2 = self.skeletone.boundary
start_point = line_locate_point(self.skeletone.lines, p1, normalized=True)
end_point = line_locate_point(self.skeletone.lines, p2, normalized=True)
if isinstance(self.objs_along.endpoints, tuple):
i1, i2 = get_endpoint_indices(self.objs_along.endpoints)
locs = np.linspace(start_point, end_point, num=num, endpoint=True)[i1:i2]
elif self.objs_along.endpoints == True:
locs = np.linspace(start_point, end_point, num=num, endpoint=True)
else:
locs = np.linspace(start_point, end_point, num=num, endpoint=True)[1:-1]
if locs.size == 0:
warn(message="No locations to add objects along the skeleton line. Check the spacing and endpoints settings.")
return self
pts = line_interpolate_point(self.skeletone.lines, locs, normalized=True).tolist()
normal_angles = get_normals_along_line(self.skeletone.lines, locs) # figure out why extra_rotation is added
for point, angle in zip(pts, normal_angles):
s = self.objs_along.structure.copy()
s.rotate(angle + self.objs_along.additional_rotation + 90)
s.move(point.x, point.y)
self.structure.append(s)
return self
[docs]
def build_all(self) -> 'ContinuousLineBuilder':
"""
Build all the layers and add objects along the skeleton line.
Returns:
ContinuousLineBuilder: The instance of the ContinuousLineBuilder with the updated structure.
"""
self.build_layers()
if self.objs_along:
self.add_along_skeletone()
return self
[docs]
def add_obj(self, obj: Structure, dir_snap: bool=True, add_rotation: float=None):
"""
Adds a copy of the given Structure object to the current point in a Line, applying optional rotation and directional snapping.
Args:
obj (Structure): The structure object to be added.
dir_snap (bool, optional): If True, aligns the object's rotation to the absolute angle of the current instance. Defaults to True.
add_rotation (float, optional): An additional rotation (in degrees) to apply to the object before snapping. Defaults to None.
Returns:
None
"""
s = obj.copy()
if add_rotation:
s.rotate(add_rotation)
if dir_snap:
s.rotate(self.absolute_angle)
_, end_p = self.skeletone.boundary
s.move(end_p.x, end_p.y)
self.structure.append(s)
def taper(self, length: float|int, layers: dict):
# TODO: Implement tapering
pass