Source code for zeroheliumkit.src.supercore

"""
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 dataclasses import dataclass
from shapely import (line_locate_point, line_interpolate_point, intersection_all, distance)
from shapely import LineString, Point

from .anchors import Anchor, MultiAnchor, Skeletone, Layer
from .core import Structure, Entity
from .geometries import ArcLine
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
[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] 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