Source code for dphox.path


import numpy as np
from shapely.geometry import LineString, MultiLineString
from typing import Iterable, List, Optional, Union

from .pattern import Pattern
from .geometry import Geometry
from .port import Port
from .typing import CurveLike, CurveTuple, Float4, PathWidth
from .utils import DECIMALS, linestring_points, MAX_GDS_POINTS, min_aspect_bounds


[docs]class Curve(Geometry): """A discrete curve consisting of points and tangents that used to define paths of varying widths. Note: In our definition of curve, we allow for multiple curve segments that are unconnected to each other. Attributes: curve: A function :math:`f(t) = (x(t), y(t))`, given :math:`t \\in [0, 1]`, or a length (float), or a list of points, or a tuple of points and tangents. resolution: Number of evaluations to define :math:`f(t)` (number of points in the curve). """ def __init__(self, *curves: Union[float, "Curve", CurveLike, List[CurveLike]]): points, tangents = get_ndarray_curve(curves) super().__init__(points, {}, [], tangents) self.port = self.path_port()
[docs] def angles(self, path: bool = True): """Calculate the angles for the tangents along the curve. Args: path: Whether to report the angles for the full coalesced curve. Returns: The angles of the tangents along the curve. """ if not path: return [np.unwrap(np.arctan2(t[1], t[0])) for t in self.tangents] t = np.hstack(self.tangents) return np.unwrap(np.arctan2(t[1], t[0]))
[docs] def total_length(self, path: bool = True): """Calculate the total length at the end of each line segment of the curve. Args: path: Whether to report the lengths of the segments for the full coalesced curve. Returns: The lengths for the individual line segments of the curve. """ if path: return np.cumsum(self.lengths()) else: return [np.cumsum(p) for p in self.lengths(path=False)]
[docs] def lengths(self, path: bool = True): """Calculate the lengths of each line segment of the curve. Args: path: Whether to report the lengths of the segments for the full coalesced curve. Returns: The lengths for the individual line segments of the curve. """ if path: return np.linalg.norm(np.diff(self.points), axis=0) else: return [np.linalg.norm(np.diff(p), axis=0) for p in self.geoms]
@property def pathlength(self): return np.sum(self.lengths(path=True))
[docs] def curvature(self, path: bool = True, min_frac: float = 1e-3): """Calculate the curvature vs length along the curve. Args: path: Whether to report the curvature vs length for the full coalesced curve. min_frac: The minimum Returns: A tuple of the lengths and curvature along the length. """ min_dist = min_frac * np.mean(self.lengths(path=True)) if not path: return [(np.cumsum(d)[d > min_dist], np.diff(a)[d > min_dist] / d[d > min_dist]) for d, a in zip(self.lengths(path=False), self.angles(path=False))] d, a = self.lengths(path=True), self.angles(path=True) return np.cumsum(d)[d > min_dist], np.diff(a)[d > min_dist] / d[d > min_dist]
@property def normals(self): """Calculate the normals (perpendicular to the tangents) along the curve. Returns: The normals for the curve. """ return [np.vstack((-np.sin(a), np.cos(a))) for a in self.angles()]
[docs] def path_port(self, w: float = 1): """Get the port and orientations from the normals of the curve assuming it is a piecewise path. Note: This function will not make sense if there are multiple unconnected curves. This is generally reserved for path-related operations. Unexpected behavior will occur if this method is used for arbitrary curve sets. Args: w: width of the port. Returns: The ports for the curve. """ n = self.normals n = (n[0].T[0], n[-1].T[-1]) p = (self.geoms[0].T[0], self.geoms[-1].T[-1]) return { 'a0': Port.from_points(np.array((p[0] + n[0] * w / 2, p[0] - n[0] * w / 2))), 'b0': Port.from_points(np.array((p[1] - n[1] * w / 2, p[1] + n[1] * w / 2))) }
@property def shapely(self): """Shapely geometry Returns: The multiline string for the geometries. """ return MultiLineString([LineString(p.T) for p in self.geoms])
[docs] def coalesce(self): """Coalesce path segments into a single path Note: Caution: This assumes a C1 path, so paths with discontinuities will have incorrect tangents. Returns: The coalesced Curve. """ self.geoms = [self.points] self.tangents = [np.hstack(self.tangents)] return self
@property def interpolated(self): """Interpolated curve such that all segments have equal length. Returns: The interpolated path. """ lengths = [np.sum(length) for length in self.lengths(path=False)] # interpolate, but also ensure endpoints have the correct original tangents def _interp(g: np.ndarray, t: np.ndarray, p: LineString, length: float): ls = LineString([p.interpolate(d * length) for d in np.linspace(0, 1, g.shape[1])]) points = linestring_points(ls).T tangents = np.gradient(points, axis=1).T tangents = np.vstack((t.T[0], tangents[1:-1], t.T[-1])).T return CurveTuple(points, tangents) return Curve([_interp(g, t, p, length) for g, t, p, length in zip(self.geoms, self.tangents, self.shapely.geoms, lengths)])
[docs] def path(self, width: Union[float, Iterable[PathWidth]] = 1, offset: Union[float, Iterable[PathWidth]] = 0, decimals: int = DECIMALS) -> Pattern: """Path (pattern) converted from this curve using width and offset specifications. Args: width: Width of the path. If a list of callables, apply a parametric width to each curve segment. offset: Offset of the path. If a list of callables, apply a parametric offset to each curve segment. decimals: Decimal precision of the path. Returns: A pattern representing the path. """ path_patterns = [] widths = [width] * self.num_geoms if not isinstance(width, list) and not isinstance(width, tuple) else width offsets = [offset] * self.num_geoms if not isinstance(offset, list) and not isinstance(offset, tuple) else offset if len(widths) != self.num_geoms: raise AttributeError(f"Expected len(widths) == self.num_geoms, but got {len(widths)} != {self.num_geoms}") if len(offsets) != self.num_geoms: raise AttributeError(f"Expected len(offsets) == self.num_geoms, but got {len(offsets)} != {self.num_geoms}") for segment, tangent, width, offset in zip(self.geoms, self.tangents, widths, offsets): if callable(width): t = np.linspace(0, 1, segment.shape[1])[:, np.newaxis] width = width(t) if callable(offset): t = np.linspace(0, 1, segment.shape[1])[:, np.newaxis] offset = offset(t) path_patterns.append(curve_to_path(segment, width, tangent, offset, decimals)) path = Pattern(path_patterns).set_port({'a0': path_patterns[0].port['a0'], 'b0': path_patterns[-1].port['b0']}) path.curve = self # path.refs.append(path.curve) return path
[docs] def hvplot(self, line_width: float = 2, color: str = 'black', bounds: Optional[Float4] = None, alternate_color: Optional[str] = None, plot_ports: bool = True): """Plot this device on a matplotlib plot. Args: line_width: The width of the line for plotting. color: The color for plotting the pattern. alternate_color: Plot segments of the curve alternating :code:`color` and :code`alternate_color`. bounds: Bounds of the plot. plot_ports: Plot the ports of the curve. Returns: The holoviews Overlay for displaying all of the polygons. """ import holoviews as hv alternate_color = alternate_color or color b = min_aspect_bounds(self.bounds) if bounds is None else bounds plots_to_overlay = [hv.Curve((curve[0], curve[1])).opts(data_aspect=1, frame_height=200, line_width=line_width, ylim=(b[1], b[3]), xlim=(b[0], b[2]), color=(color, alternate_color)[i % 2], tools=['hover']) for i, curve in enumerate(self.geoms)] if plot_ports: plots_to_overlay.extend(port.hvplot(name) for name, port in self.port.items()) return hv.Overlay(plots_to_overlay)
@property def pattern(self): return Pattern(self.geoms) @property def segments(self): return [Curve(CurveTuple(g, t)) for g, t in zip(self.geoms, self.tangents)] @property def copy(self) -> "Curve": """Copies the pattern using deepcopy. Returns: A copy of the Pattern so that changes do not propagate to the original :code:`Pattern`. """ curve = Curve([CurveTuple(g, t) for g, t in zip(self.geoms, self.tangents)]) curve.port = self.port_copy curve.refs = [ref.copy for ref in self.refs] return curve
[docs]def curve_to_path(points: np.ndarray, widths: Union[float, np.ndarray], tangents: np.ndarray, offset: Union[float, np.ndarray] = 0, decimals: int = DECIMALS, max_num_points: int = MAX_GDS_POINTS): """Converts a curve to a path. Args: points: The points along the curve. tangents: The normal directions / derivatives evaluated at the points along the curve. widths: The widths at each point along the curve (measured perpendicular to the tangents). offset: Offset of the path. decimals: Number of decimals precision for the curve output. max_num_points: Maximum number of points allowed in the curve (otherwise, break it apart). Note that the polygon will have twice this amount. Returns: The resulting Pattern. """ # step 1: find the path polygon points based on the points, tangents, widths, and offset angles = np.arctan2(tangents[1], tangents[0]) w = np.vstack((-np.sin(angles) * widths, np.cos(angles) * widths)) / 2 off = np.vstack((-np.sin(angles) * offset, np.cos(angles) * offset)) / 2 top_path = np.around(points + w + off, decimals).T bottom_path = np.around(points - w + off, decimals).T front_port = np.array([bottom_path[-1], top_path[-1]]) back_port = np.array([top_path[0], bottom_path[0]]) # step 2: split the path if there are too many points in it resolution = top_path.shape[0] num_split = np.ceil(resolution / max_num_points).astype(np.int32) ranges = [(i * max_num_points, (i + 1) * max_num_points + 1) for i in range(num_split)] # step 3: convert the resulting polygon list into a Pattern whose polygons form the path. pattern = Pattern([np.vstack((top_path[s[0]:s[1]], bottom_path[s[0]:s[1]][::-1])).T for s in ranges]) pattern.port = { 'a0': Port.from_points(back_port), 'b0': Port.from_points(front_port) } return pattern
[docs]def get_ndarray_curve(curvelike_list: Iterable[Union[float, "Curve", CurveLike, List[CurveLike]]]): """A recursive list of lists of curvelike objects, which turned into a flat list of 2d ndarray polygons. Args: curvelike_list: List of polygon-like objects including :code:`CurveSet`, shapely linestrings, :code:`CurveTuple` (tuple of points and tangents), and more. Returns: A list of :math:`M` polygons that are each represented as :math:`2 \\times N_m` :code:`ndarray`'s. """ linestrings = [] tangents = [] for curve in curvelike_list: new_tangents = [] if isinstance(curve, CurveTuple): new_linestrings = [curve.points] new_tangents = [curve.tangents] elif np.isscalar(curve): # just a straight segment new_linestrings = np.array(((0, 0), (curve, 0))).T elif isinstance(curve, (list, tuple)): # recursively apply to the list. new_linestrings_and_tangents = [get_ndarray_curve([p]) for p in curve] new_linestrings = sum((linestrings for linestrings, _ in new_linestrings_and_tangents), []) new_tangents = sum((tangents for _, tangents in new_linestrings_and_tangents), []) elif isinstance(curve, Curve): new_linestrings = curve.geoms new_tangents = curve.tangents elif isinstance(curve, np.ndarray): if curve.ndim not in [2, 3]: raise AttributeError("The number of dimensions for the curve must be 2 or 3") new_linestrings = [curve] if curve.ndim == 2 else curve.tolist() elif isinstance(curve, LineString): new_linestrings = [linestring_points(curve).T] elif isinstance(curve, MultiLineString): new_linestrings = [linestring_points(geom).T for geom in curve.geoms] else: raise TypeError(f'Pattern does not accept type {type(curve)}') tangents.extend(new_tangents or [np.gradient(p, axis=1) for p in new_linestrings]) linestrings.extend(new_linestrings) return linestrings, tangents
[docs]def straight(length: float): """Just a straight line along the x axis, generally this only needs 2 evaluations unless there is a taper. Args: length: Length of the straight line. Returns: A straight segment. """ return Curve(CurveTuple(np.vstack(((0, 0), (length, 0))).T, np.vstack(((1, 0), (1, 0))).T))