Source code for upxo.geoEntities.mulsline2d

"""
Multi-straight line 2D geometric entity module for UPXO.

This module provides multi-straight-line (polyline) representations in 2D space.
It supports closed and open polylines with operations for node/line management,
spatial queries, adjacent relationship detection, and geometric transformations.

Core Classes
------------
- ``MSline2d`` : Multi-straight-line in 2D (collection of connected line segments).
- ``ring2d`` : Ring structure in 2D (to be further developed).
- ``mulring2d`` : Multiple rings in 2D (to be further developed).

Key Features
------------
- Line chain construction from various input formats (node points, coordinates,
  existing lines).
- Node and line enumeration, spacing, and connectivity checks.
- Adjacency and spatial relationship detection between polylines.
- Centroid and length calculations.
- Open/closed topology management.
- Node coordinate access and updates.
- Spatial tree indexing for nearest-neighbor queries.
- Point location finding within polyline node sequences.
- Plotting and visualization support.

Applications
------------
- Non-conformal to conformal geometry conversion.
- Hierarchical grain structure feature generation.
- General geometry operations and polyline manipulation.
- Boundary and edge representation in meshing and simulation.

Usage
-----
::

    from upxo.geoEntities.mulsline2d import MSline2d

Coordinate System
-----------------
Standard 2D Cartesian with notation (X+, Y+) = (right, up):

                     Y+
                     |
                     |
                     |
                     |
    X-               |               X+
    -----+-----+-----O-----+-----+-----
                     |
                     |
                     |
                     |
                     Y-

@author: Dr. Sunil Anandatheertha

Examples
--------------
**Construct from line objects:**
    >>> from upxo.geoEntities.mulsline2d import MSline2d
    >>> from upxo.geoEntities.sline2d import Sline2d
    >>> lines = [
    ...     Sline2d(0, 0, 1, 1),
    ...     Sline2d(1, 1, 2, 2),
    ...     Sline2d(2, 2, 3, 1)
    ... ]
    >>> msl = MSline2d.from_lines(lines, close=False)
    >>> msl.nlines, msl.nnodes
    (3, 4)

**Construct from coordinates:**
    >>> coords = [(0, 0), (1, 1), (2, 2), (3, 1)]
    >>> msl = MSline2d.by_coords(coords, close=True)
    >>> msl.length
    >>> msl.centroid

**Query relationships:**
    >>> msl1 = MSline2d.by_coords([(0, 0), (1, 1)])
    >>> msl2 = MSline2d.by_coords([(1, 1), (2, 2)])
    >>> msl1.do_i_precede(msl2)  # Check if msl1 comes before msl2
    True

**Find closest nodes:**
    >>> from upxo.geoEntities.point2d import Point2d
    >>> msl = MSline2d.by_coords([(0, 0), (2, 0), (4, 0)])
    >>> closest = msl.find_closest_nodes(Point2d(1.5, 0.5))

See Also
--------
- ``upxo.geoEntities.sline2d`` : Single straight-line entity.
- ``upxo.geoEntities.point2d`` : 2D point entity.
- ``upxo.geoEntities.polygon2d`` : 2D polygon entity.

"""

import math
import numpy as np
from copy import deepcopy
from scipy.spatial import cKDTree
from shapely.geometry import Point as ShPnt, Polygon as ShPol
from functools import wraps
import matplotlib.pyplot as plt
import upxo._sup.dataTypeHandlers as dth
from scipy.spatial import distance
from scipy.spatial.distance import cdist
from upxo.geoEntities.point2d import Point2d
from upxo.geoEntities.bases import UPXO_Point, UPXO_Edge
np.seterr(divide='ignore')
from upxo._sup.validation_values import isinstance_many
from upxo.geoEntities.sline2d import Sline2d as sl2d
from upxo.geoEntities.point2d import p2d_leanest
from shapely.geometry import Polygon, MultiPolygon
from upxo._sup.data_ops import mean_coordinates

NUMBERS = dth.dt.NUMBERS
ITERABLES = dth.dt.ITERABLES

[docs] class MSline2d(): """ Multi-straight-line (polyline) entity in 2D for UPXO. A connected chain of :class:`~upxo.geoEntities.sline2d.Sline2d` line segments sharing end-nodes. Supports open and closed topologies. Attributes ---------- lines : list Constituent ``Sline2d`` segments. nodes : list Ordered endpoint nodes along the polyline. features : dict Feature metadata associated with the polyline. closed : bool Whether the polyline is closed. Examples -------- .. code-block:: python from upxo.geoEntities.mulsline2d import MSline2d as msl2d from upxo.geoEntities.sline2d import Sline2d as sl2d e0 = sl2d(0.0, 0.0, 1.0, 1.0) e1 = sl2d(1.0, 1.0, 1.5, 1.5) e2 = sl2d(1.5, 1.5, 2.5, 2.5) e3 = sl2d(2.5, 2.5, 4.0, 4.0) e4 = sl2d(4.0, 4.0, 4.0, 6.0) me = msl2d.from_lines([e0, e1, e2, e3, e4], close=False) """ __slots__ = ('lines', 'nodes', 'features', 'closed') EPS_coord_coincide = 1E-8 def __init__(self, nodes=None, llist=None, closed=None): """ Initialize a multi-straight-line 2D entity. Parameters ---------- nodes : list, optional Points (Point2d) forming the polyline chain. llist : list, optional List of Sline2d (straight line 2D) objects forming the polyline. closed : bool, optional If True, polyline is closed; if False, polyline is open. """ self.lines = llist self.nodes = nodes self.closed = closed self.features = {'neigh_gids': None} def __repr__(self): """ Return string representation of multi-straight-line object. Returns ------- str Concise representation including number of lines, object ID, and start/end node coordinates. """ return f"MSL2. nln={len(self.lines)}. ID: {id(self)}: {self.nodes[0]}, {self.nodes[-1]}" def __iter__(self): """ Return an iterable of the line segments in self. Returns ------- iterator Iterator over ``self.lines``. Examples -------- .. code-block:: python from upxo.geoEntities.mulsline2d import MSline2d from upxo.geoEntities.sline2d import Sline2d lines = [Sline2d(0.0,0.0, 1.0,1.0), Sline2d(1.0,1.0, 1.5,1.5), Sline2d(1.5,1.5, 2.5,2.5), Sline2d(2.5,2.5, 4.0,4.0), Sline2d(4.0,4.0, 4.0,6.0)] MULLINE = MSline2d.from_lines(lines, close=True) lines = [line for line in MULLINE.lines] print(lines) """ return iter(self.lines) def __getitem__(self, i): """ Return the line at index ``i``. Parameters ---------- i : int Zero-based index into ``self.lines``. Returns ------- Sline2d Line segment at position ``i``. Raises ------ ValueError If ``i`` exceeds the number of lines. Examples -------- .. code-block:: python from upxo.geoEntities.mulsline2d import MSline2d from upxo.geoEntities.sline2d import Sline2d lines = [Sline2d(0.0,0.0, 1.0,1.0), Sline2d(1.0,1.0, 1.5,1.5), Sline2d(1.5,1.5, 2.5,2.5), Sline2d(2.5,2.5, 4.0,4.0), Sline2d(4.0,4.0, 4.0,6.0)] MULLINE = MSline2d.from_lines(lines, close=True) MULLINE[4] """ if i >= self.nlines: raise ValueError('Index exceeds maximum number of lines.') return self.lines[i]
[docs] @classmethod def from_lines(cls, llist, close=True): """ Construct a multi-straight-line 2D from a list of Sline2d objects. Parameters ---------- llist : list of Sline2d Ordered line segments forming the polyline chain. close : bool, optional If True, append a closing segment from the last node back to the first node. Returns ------- MSline2d Assembled polyline. Examples -------- .. code-block:: python from upxo.geoEntities.mulsline2d import MSline2d as msl2d from upxo.geoEntities.sline2d import Sline2d as sl2d e0 = sl2d(0.0, 0.0, 1.0, 1.0) e1 = sl2d(1.0, 1.0, 1.5, 1.5) e2 = sl2d(1.5, 1.5, 2.5, 2.5) e3 = sl2d(2.5, 2.5, 4.0, 4.0) e4 = sl2d(4.0, 4.0, 4.0, 6.0) me_closed = msl2d.from_lines([e0, e1, e2, e3, e4], close=True) me_open = msl2d.from_lines([e0, e1, e2, e3, e4], close=False) """ llist, closed = llist, False nodes = [line.pnta for line in llist] nodes.append(llist[-1].pntb) if close: fl = llist[0] # First line ll = llist[-1] # Last line llist.append(sl2d(ll.x1, ll.y1, fl.x0, fl.y0)) nodes.append(nodes[0]) closed = True return cls(nodes=nodes, llist=llist, closed=closed)
[docs] @classmethod def by_nodes(cls, nodes, close=True): """ Construct a multi-straight-line 2D from a list of Point2d nodes. Parameters ---------- nodes : list of Point2d Ordered nodes of the polyline. close : bool, optional If True, append a closing segment. Returns ------- MSline2d Assembled polyline. Examples -------- .. code-block:: python from upxo.geoEntities.mulsline2d import MSline2d from upxo.geoEntities.point2d import Point2d nodes = [Point2d(0, 0), Point2d(1, 1), Point2d(2, 2), Point2d(3, 3), Point2d(5, 5)] MSline2d.by_nodes(nodes).lines """ if type(nodes) not in ITERABLES: raise ValueError('Invalid nodes input.') if len(nodes) < 2: raise ValueError('Invalid nodes iterable length. Must be >= 2.') if type(close) != bool: raise ValueError('Invalid close input.') # ---------------------------------------- llist = [sl2d(nodes[i].x, nodes[i].y, nodes[i+1].x, nodes[i+1].y) for i in range(len(nodes)-1)] nodes = [line.pnta for line in llist] # print(llist[-1].pntb) # print(nodes) nodes.append(llist[-1].pntb) if close: llist.append(sl2d(nodes[-1].x, nodes[-1].y, nodes[0].x, nodes[0].y)) nodes.append(nodes[0]) return cls(nodes=nodes, llist=llist, closed=close)
[docs] @classmethod def by_coords(cls, coords, close=True): """ Construct a multi-straight-line 2D from a sequence of (x, y) coordinates. Parameters ---------- coords : array-like of shape (n, 2) Ordered (x, y) coordinate pairs. close : bool, optional If True, append a closing segment. Returns ------- MSline2d Assembled polyline. Examples -------- .. code-block:: python from upxo.geoEntities.mulsline2d import MSline2d import numpy as np coords = np.array([(0,0), (1,1), (2,2), (3,3), (5,5)]) msl = MSline2d.by_coords(coords) msl.lines msl.nodes """ if type(coords) not in ITERABLES: raise ValueError('Invalid nodes input.') if len(coords) < 2: raise ValueError('Invalid nodes iterable length. Must be >= 2.') # ---------------------------------------- llist = [sl2d(coords[i][0], coords[i][1], coords[i+1][0], coords[i+1][1]) for i in range(len(coords)-1)] nodes = [line.pnta for line in llist] if close: llist.append(sl2d(nodes[-1].x, nodes[-1].y, nodes[0].x, nodes[0].y)) nodes.append(nodes[0]) return cls(nodes=nodes, llist=llist, closed=close)
[docs] @classmethod def by_walk(self, var_l='constant', var_ang='constant', specs = {'n': 5, 'max_total_length': 10, 'min_total_length': 8, 'mean_length': 1,} ): """ Generate polyline by "walking" with variable step length/angle. Parameters ---------- var_l : str, optional Length variation type (e.g., 'constant', 'random'). var_ang : str, optional Angle variation type (e.g., 'constant', 'random'). specs : dict, optional Specifications dict with keys: 'n' (count), 'max_total_length', 'min_total_length', 'mean_length'. Notes ----- To be developed. """ raise NotImplementedError("by_random_walk is not yet implemented.")
@property def nlines(self): """ Return number of lines. Returns ------- int Number of constituent line segments. """ return len(self.lines) @property def centroid(self): """ Compute and return centroid of the polyline. Returns ------- numpy.ndarray Mean coordinate (x, y) of all nodes. """ return self.get_node_coords().mean(axis=0) @property def centroid_p2dl(self): """ Return centroid as p2d_leanest point object. Returns ------- p2d_leanest Lightweight point object at polyline centroid. """ return p2d_leanest(*self.centroid) @property def length(self): """ Return total length of all lines in polyline. Returns ------- float Sum of all individual line lengths. """ return sum([line.length for line in self.lines]) @property def lengths(self): """ Return individual line lengths as list. Returns ------- list Lengths of each line in the polyline. """ return [line.length for line in self.lines] @property def nnodes(self): """ Return number of polyline nodes. Returns ------- int Node count, excluding the duplicate closing node when closed. """ return len(self.nodes[:-1]) if self.closed else len(self.nodes) @property def coords(self): """ Get all node coordinates as 2D array. Returns ------- numpy.ndarray Array of shape (n_nodes, 2) with (x, y) coordinates. """ return np.array([[p.x, p.y] for p in self.nodes]) @property def tree(self): """ Build spatial KD-tree index for neighbor queries. Returns ------- scipy.spatial.cKDTree KD-tree constructed from node coordinates. """ return cKDTree(self.coords) @property def length_mean(self): """ Return mean length of all line segments. Returns ------- float Total length divided by number of lines. """ return self.length/self.nlines @property def gradients(self): """ Return gradient of every line. Returns ------- list Gradient value for each constituent line. """ return [line.gradient for line in self.lines] @property def get_nodes(self): """ Return unique nodes in the multi-straight-line object. Returns ------- numpy.ndarray Unique node coordinate rows. """ nodes = [[line.x0, line.y0] for line in self.lines] nodes += [[line.x1, line.y1] for line in self.lines] return np.unique(nodes, axis=0)
[docs] def get_node_coords(self): """ Extract and return all node coordinates as array. Returns ------- numpy.ndarray Array of shape (n_nodes, 2) with (x, y) coordinates of each node. """ return np.array([[node.x, node.y] for node in self.extract_nodes()])
@property def mid_nodes(self): """ Return midpoint nodes of all lines. Returns ------- list Mid-point of each line segment in the polyline. Examples -------- .. code-block:: python from upxo.geoEntities.mulsline2d import MSline2d as msl2d from upxo.geoEntities.sline2d import Sline2d as sl2d lines = [sl2d(0.0,0.0,1.0,1.0), sl2d(1.0,1.0,1.5,1.5), sl2d(1.5,1.5,2.5,2.5), sl2d(2.5,2.5,4.0,4.0), sl2d(4.0,4.0,4.0,6.0)] MULLINE = msl2d.from_lines(lines, close=True) MULLINE.mid_nodes MULLINE.centroid_p2dl """ return [line.mid_point for line in self.lines] @property def line_ids(self): """ Return memory id of each line. Returns ------- list of int Python ``id`` values for all constituent lines. """ return [id(line) for line in self.lines]
[docs] def flip(self, saa=True, throw=False): """ Reverse the order of lines in the polyline. Parameters ---------- saa : bool, optional If True, modify self in place (save-and-apply). If False, operate on a deep copy. throw : bool, optional If True, return the (possibly copied) result. Returns ------- MSline2d or None Flipped polyline if ``throw`` is True, else ``None``. Examples -------- .. code-block:: python from upxo.geoEntities.mulsline2d import MSline2d as msl2d from upxo.geoEntities.sline2d import Sline2d as sl2d lines = [sl2d(0.0,0.0,1.0,1.0), sl2d(1.0,1.0,1.5,1.5), sl2d(1.5,1.5,2.5,2.5), sl2d(2.5,2.5,4.0,4.0), sl2d(4.0,4.0,4.0,6.0)] MULLINE = msl2d.from_lines(lines, close=True) MULLINE.flip() MULLINE.lines MULLINE.nodes """ if saa: self.lines = list(np.flip(self.lines, axis=0)) for line in self.lines: line.flip() self.update_nodes() if throw: return self if not saa: mulslines2d = deepcopy(self) mulslines2d.lines = list(np.flip(mulslines2d.lines, axis=0)) for line in mulslines2d.lines: line.flip() mulslines2d.update_nodes() if throw: return mulslines2d
[docs] def do_i_precede(self, multisline2d): """ Check if self spatially precedes the input multisline2d. Parameters ---------- multisline2d : MSline2d Candidate successor polyline. Returns ------- i_precede : bool True if self immediately precedes ``multisline2d``. flip_needed : bool True if ``multisline2d`` must be flipped to achieve nodal continuity with self. """ condition_a = self.nodes[-1].eq_fast(multisline2d.nodes[0])[0] condition_b = self.nodes[-1].eq_fast(multisline2d.nodes[-1])[0] if condition_a: i_precede, flip_needed = True, False if condition_b: i_precede, flip_needed = True, True if not condition_a and not condition_b: i_precede, flip_needed = False, False return i_precede, flip_needed
[docs] def do_i_proceed(self, multisline2d): """ Check if self spatially comes after the input multisline2d. Parameters ---------- multisline2d : MSline2d Candidate predecessor polyline. Returns ------- i_proceed : bool True if self immediately succeeds ``multisline2d``. flip_needed : bool True if ``multisline2d`` must be flipped to achieve nodal continuity with self. """ condition_a = self.nodes[0].eq_fast(multisline2d.nodes[0])[0] condition_b = self.nodes[0].eq_fast(multisline2d.nodes[-1])[0] if condition_a: i_precede, flip_needed = True, False if condition_b: i_precede, flip_needed = True, True if not condition_a and not condition_b: i_precede, flip_needed = False, False return i_precede, flip_needed
[docs] def is_adjacent(self, multisline2d): """ Check spatial adjacency with another multiline. Parameters ---------- multisline2d : MSline2d Another multi-straight-line object to test. Returns ------- tuple (is_adjacent_bool, left_relation, right_relation) tuple. Notes ----- Checks if multiline is immediately preceded or succeeded by input. """ left = self.do_i_proceed(multisline2d) # msl2d is to the left of self right = self.do_i_precede(multisline2d) # msl2d is to the rght of self return any(left[0], right[0]), left, right
[docs] def find_spatially_next_multisline2d(self, multislines2d): """ From a list of multisline2d objects, multislines2d, find the ones which come spatially immediately after self. Parameters ---------- multislines2d : list of MSline2d Candidate successor polylines. Returns ------- list ``do_i_precede`` results for each candidate. """ precedes = [] for msl in multislines2d: precedes.append(self.do_i_precede(msl)) return precedes
[docs] def has_coord(self, coord, return_flags=False): """ Check if coordinate exists in polyline node sequence. Parameters ---------- coord : array-like 2D coordinate (x, y) to search for. return_flags : bool, optional If True, return boolean flags array. Returns ------- bool or (bool, numpy.ndarray) If return_flags=False: boolean indicating existence. If return_flags=True: (existence_bool, flags_array) tuple. """ # Validations: coord must be np.array 2D coordfinate _coords_ = self.get_node_coords() flags = (_coords_[:, 0] == coord[0]) & (_coords_[:, 1] == coord[1]) if return_flags: return any(flags), flags else: return any(flags)
[docs] def find_coord_location(self, coord): """ Find index location of coordinate in node sequence. Parameters ---------- coord : array-like 2D coordinate (x, y) to search for. Returns ------- numpy.ndarray or None Index array if coordinate found, None if not found. """ # Validations: coord must be np.array 2D coordfinate exists, flags = self.has_coord(coord, return_flags=True) if exists: return np.argwhere(flags) else: return None
[docs] def extract_nodes(self): """ Extract all unique nodes from polyline lines. Returns ------- list List of Point2d nodes (including closing node if closed). """ nodes = [line.pnta for line in self.lines] if self.closed: nodes.append(self.nodes[0]) else: nodes.append(self.lines[-1].pntb) return nodes
[docs] def update_nodes(self): """ Update internal node list from current line definitions. Notes ----- Reconstructs self.nodes from line start/end points, respecting open/closed topology. """ self.nodes = [line.pnta for line in self.lines] if self.closed: self.nodes.append(self.nodes[0]) else: self.nodes.append(self.lines[-1].pntb)
[docs] def close(self, reclose=False): """ Close the self multi-straight line object. Parameters ---------- reclose : bool, optional If ``True``, append a closing line even when already closed. Returns ------- None Updates ``self.lines`` in place. Examples -------- a. """ if not self.closed: fl, ll = self[0], self[-1] self.lines.append(sl2d(ll.x1, ll.y1, fl.x0, fl.y0)) else: if not reclose: # Nothing to do here as self already closed and recvlose is # False pass if reclose: fl, ll = self[0], self[-1] self.lines.append(sl2d(ll.x1, ll.y1, fl.x0, fl.y0))
[docs] def unclose(self): """ Remove the closing line. Returns ------- None Deletes the last line from ``self.lines``. """ del self.lines[-1]
[docs] def distances_nodes(self, points): """ Compute distances from every node to each given point. Parameters ---------- points : numpy.ndarray of shape (m, 2) Query points. Returns ------- numpy.ndarray of shape (n_nodes, m) Euclidean distance from each node to each query point. Notes ----- Uses vectorised broadcasting: ``points[:, np.newaxis]`` is broadcast against the node array so all pairwise distances are computed in one operation. Examples -------- .. code-block:: python from upxo.geoEntities.mulsline2d import MSline2d as msl2d from upxo.geoEntities.sline2d import Sline2d as sl2d import numpy as np lines = [sl2d(0.0,0.0,1.0,1.0), sl2d(1.0,1.0,1.5,1.5), sl2d(1.5,1.5,2.5,2.5), sl2d(2.5,2.5,4.0,4.0), sl2d(4.0,4.0,4.0,6.0)] MULLINE = msl2d.from_lines(lines, close=True) points = np.random.random((2, 2)) MULLINE.distances_nodes(points) """ # Validation # ------------------------------------- nodes = np.array(self.nodes) """ nodes = np.random.random((4, 2)) points = np.random.random((10,2)) distances = np.sqrt(np.sum((points[:, np.newaxis] - nodes) ** 2, axis=2)) points[:, np.newaxis] broadcasts the points array to have shape (10, 1, 3), effectively creating a third dimension for broadcasting. points[:, np.newaxis] - nodes broadcasts nodes to shape (10, 4, 3) and subtracts each node from each point, resulting in an array of differences. np.sum(... ** 2, axis=2) squares each difference element-wise, sums along the third axis (axis=2), and then takes the square root to get the Euclidean distances. """ distances = np.sqrt(np.sum((points[:, np.newaxis] - nodes) ** 2, axis=2)).T return distances
[docs] def find_closest_nodes(self, point): """ Find the closest node index (or indices) to a query point. Parameters ---------- point : array-like of shape (2,) Query point coordinates. Returns ------- int or list of int Index of the closest node, or a list of indices if multiple nodes are equidistant. Examples -------- **Example 1** — random query point: .. code-block:: python from upxo.geoEntities.mulsline2d import MSline2d as msl2d from upxo.geoEntities.sline2d import Sline2d as sl2d import numpy as np lines = [sl2d(0.0,0.0,1.0,1.0), sl2d(1.0,1.0,1.5,1.5), sl2d(1.5,1.5,2.5,2.5)] MULLINE = msl2d.from_lines(lines, close=True) point = np.random.random(2) * np.random.randint(10) MULLINE.find_closest_nodes(point) **Example 2** — midpoint of first line: .. code-block:: python from upxo.geoEntities.mulsline2d import MSline2d as msl2d from upxo.geoEntities.sline2d import Sline2d as sl2d lines = [sl2d(0.0,0.0,1.0,1.0), sl2d(1.0,1.0,1.5,1.5), sl2d(1.5,1.5,2.5,2.5)] MULLINE = msl2d.from_lines(lines, close=True) point = lines[0].mid MULLINE.find_closest_nodes(point) """ # Validation # ------------------------------------- distances = self.distances_nodes(np.array(point)[:, np.newaxis].T).T.squeeze() closest_points = np.argwhere(distances == distances.min()).T.squeeze().tolist() return closest_points
[docs] def add_nodes(self, nodes): """ Insert new nodes into the polyline by splitting existing lines. Parameters ---------- nodes : list of Point2d New nodes to insert. Each node is located on an existing line segment and splits it. Notes ----- If a node does not lie on any line segment it is silently ignored. The internal node list is updated after each insertion. Examples -------- **Example 1** — insert two nodes on a 5-segment polyline: .. code-block:: python from upxo.geoEntities.mulsline2d import MSline2d as msl2d from upxo.geoEntities.sline2d import Sline2d as sl2d from upxo.geoEntities.point2d import Point2d lines = [sl2d(0.0,0.0,1.0,1.0), sl2d(1.0,1.0,1.5,1.5), sl2d(1.5,1.5,2.5,2.5), sl2d(2.5,2.5,4.0,4.0), sl2d(4.0,4.0,4.0,6.0)] mulline = msl2d.from_lines(lines, close=True) mulline.add_nodes([Point2d(0.5, 0.5), Point2d(2.0, 2.0)]) mulline.lines **Example 2** — insert three nodes on a 6-segment polyline: .. code-block:: python from upxo.geoEntities.mulsline2d import MSline2d as msl2d from upxo.geoEntities.sline2d import Sline2d as sl2d from upxo.geoEntities.point2d import Point2d lines = [sl2d(0.0,0.0,1.0,1.0), sl2d(1.0,1.0,1.5,1.5), sl2d(1.5,1.5,2.5,2.5), sl2d(2.5,2.5,4.0,4.0), sl2d(4.0,4.0,5.0,4.0), sl2d(5.0,4.0,5.0,0.0)] mulline = msl2d.from_lines(lines, close=True) mulline.add_nodes([Point2d(0.5, 0.5), Point2d(2.0, 2.0), Point2d(1.75, 0)]) mulline.lines """ for node in nodes: line_indices = [] for i, line in enumerate(self.lines, start=0): if line.fully_contains_point(p2d=node, method='through'): line_indices.append(i) if len(line_indices) != 0: line = self.lines[line_indices[0]] new_line = line.split(method='p2d', divider=node, saa=True, throw=True, update='pntb')[1] self.lines.insert(line_indices[0]+1, new_line) self.update_nodes()
[docs] def splice_nodes_and_lines(self, method='points', points=None, perform_checks=True): """ Splice/interpolate nodes and lines at specified points. Parameters ---------- method : str, optional Method for splicing. Options include 'points' (default). points : list, optional Points at which to splice. perform_checks : bool, optional Whether to perform validation checks. Notes ----- To be developed. """ raise NotImplementedError("splice is not yet implemented.")
[docs] def roll(self, roll_distance): """ Roll/rotate line order within the polyline chain. Parameters ---------- roll_distance : int Number of positions to roll lines. Notes ----- Updates internal node list after rolling. Useful for changing polyline starting point while preserving connectivity. """ self.lines = list(np.roll(self.lines, -roll_distance)) self.update_nodes()
[docs] def sub_divide(self, line_number=0, f=0.5): """ Sub-divide a single line in ``self.lines`` at a fractional position. Parameters ---------- line_number : int, optional Index in ``self.lines`` to subdivide (zero-based). f : float, optional Fractional position along the target line in ``(0, 1)`` where the split is made. Examples -------- .. code-block:: python from upxo.geoEntities.mulsline2d import MSline2d as msl2d from upxo.geoEntities.sline2d import Sline2d as sl2d lines = [sl2d(0.0,0.0,1.0,1.0), sl2d(1.0,1.0,1.5,1.5), sl2d(1.5,1.5,2.5,2.5), sl2d(2.5,2.5,4.0,4.0), sl2d(4.0,4.0,4.0,6.0)] me = msl2d.from_lines(lines, close=False) me.sub_divide(line_number=0, f=0.25) me.sub_divide(line_number=3, f=0.50) for i in range(5): me.sub_divide(line_number=i, f=0.50) me.lines """ if not isinstance(line_number, int): raise TypeError('Invalid line number type.') if line_number > len(self.lines): raise ValueError('Invalid line number spacification.') if line_number == 0: new_lines = self.lines[0].split(f=f, saa=False, throw=True) self.lines = new_lines + self.lines[1:] elif line_number == len(self.lines): new_lines = self.lines[-1].split(f=f, saa=False, throw=True) self.lines = self.lines[:-1] + new_lines else: # end result: left--line0--line1--right left = [line for line in self.lines[:line_number]] # Divisiozn operation stzrts line01 = self.lines[line_number].split(f=f, saa=False, throw=True) # Division operation ends right = [line for line in self.lines[line_number+1:]] self.lines = left + line01 + right
[docs] def remove_point_by_index(self, index=2, remove='previous_line'): """ Remove a node at the given index by merging its adjacent line segments. Parameters ---------- index : int, optional Zero-based node index to remove. remove : {'previous_line', 'next_line', 'both'} Which adjacent line to delete: ``'previous_line'`` — extend the next line backward; ``'next_line'`` — extend the previous line forward; ``'both'`` — replace both adjacent lines with a new direct line. Examples -------- **Example 1** — remove via previous line: .. code-block:: python from upxo.geoEntities.mulsline2d import MSline2d as msl2d from upxo.geoEntities.sline2d import Sline2d as sl2d lines = [sl2d(0.0,0.0,1.0,1.0), sl2d(1.0,1.0,1.5,1.5), sl2d(1.5,1.5,2.5,2.5), sl2d(2.5,2.5,4.0,4.0), sl2d(4.0,4.0,4.0,6.0)] MULLINE = msl2d.from_lines(lines, close=True) MULLINE.remove_point_by_index(index=2, remove='previous_line') MULLINE.lines **Example 2** — remove via next line: .. code-block:: python from upxo.geoEntities.mulsline2d import MSline2d as msl2d from upxo.geoEntities.sline2d import Sline2d as sl2d lines = [sl2d(0.0,0.0,1.0,1.0), sl2d(1.0,1.0,1.5,1.5), sl2d(1.5,1.5,2.5,2.5), sl2d(2.5,2.5,4.0,4.0), sl2d(4.0,4.0,4.0,6.0)] MULLINE = msl2d.from_lines(lines, close=True) MULLINE.remove_point_by_index(index=2, remove='next_line') MULLINE.lines **Example 3** — remove both adjacent lines: .. code-block:: python from upxo.geoEntities.mulsline2d import MSline2d as msl2d from upxo.geoEntities.sline2d import Sline2d as sl2d lines = [sl2d(0.0,0.0,1.0,1.0), sl2d(1.0,1.0,1.5,1.5), sl2d(1.5,1.5,2.5,2.5), sl2d(2.5,2.5,4.0,4.0), sl2d(4.0,4.0,4.0,6.0)] MULLINE = msl2d.from_lines(lines, close=True) MULLINE.remove_point_by_index(index=2, remove='both') MULLINE.lines """ # Validations # ------------------------------------- previous_line, next_line = index-1, index # ------------------------------------- if remove == 'previous_line': # Next line will be updayed and previous line will be removed self.lines[next_line].move_i(self.lines[previous_line].coord_i) del self.lines[previous_line] elif remove == 'next_line': # Next line will be removed and previous line will be updated self.lines[previous_line].move_j(self.lines[next_line].coord_j) del self.lines[next_line] elif remove == 'both': # Next line and previous line will be removed and a new line will # be made in its place x0, y0 = self.lines[previous_line].coord_i x1, y1 = self.lines[next_line].coord_j new_line = sl2d(x0, y0, x1, y1) self.lines = self.lines[:previous_line] + [new_line] + self.lines[next_line+1:] else: raise ValueError('Invalid update specirfication.')
[docs] def remove_point_by_location(self, location=(None, None, None), remove='previous_line'): """ Remove the node closest to a given location. Parameters ---------- location : array-like of shape (2,) Query coordinates used to find the closest node. remove : {'previous_line', 'next_line', 'both'} Passed through to :meth:`remove_point_by_index`. Notes ----- If multiple nodes tie for closest, all are removed recursively. When only 2 lines remain and the closing line is redundant, the closing line is automatically deleted. Examples -------- **Example 1** — remove by endpoint coordinate: .. code-block:: python from upxo.geoEntities.mulsline2d import MSline2d as msl2d from upxo.geoEntities.sline2d import Sline2d as sl2d lines = [sl2d(0.0,0.0,1.0,1.0), sl2d(1.0,1.0,1.5,1.5), sl2d(1.5,1.5,2.5,2.5), sl2d(2.5,2.5,4.0,4.0), sl2d(4.0,4.0,4.0,6.0)] MULLINE = msl2d.from_lines(lines, close=True) MULLINE.remove_point_by_location(location=lines[0].coord_i, remove='previous_line') MULLINE.lines **Example 2** — remove by midpoint coordinate: .. code-block:: python from upxo.geoEntities.mulsline2d import MSline2d as msl2d from upxo.geoEntities.sline2d import Sline2d as sl2d lines = [sl2d(0.0,0.0,1.0,1.0), sl2d(1.0,1.0,1.5,1.5), sl2d(1.5,1.5,2.5,2.5), sl2d(2.5,2.5,4.0,4.0), sl2d(4.0,4.0,4.0,6.0)] MULLINE = msl2d.from_lines(lines, close=True) MULLINE.remove_point_by_location(location=lines[0].mid, remove='previous_line') MULLINE.lines **Example 3** — remove by random location: .. code-block:: python from upxo.geoEntities.mulsline2d import MSline2d as msl2d from upxo.geoEntities.sline2d import Sline2d as sl2d import numpy as np lines = [sl2d(0.0,0.0,1.0,1.0), sl2d(1.0,1.0,1.5,1.5), sl2d(1.5,1.5,2.5,2.5), sl2d(2.5,2.5,4.0,4.0), sl2d(4.0,4.0,4.0,6.0)] MULLINE = msl2d.from_lines(lines, close=True) location = np.random.random(2) * np.random.randint(10) MULLINE.remove_point_by_location(location=location, remove='previous_line') MULLINE.lines """ # Validations # ------------------------------------- if self.n == 1: """ When there is a single line in the multi-line, no point can be removed. Function call will exit. """ return # ------------------------------------- indices = self.find_closest_nodes(location) # ------------------------------------- if isinstance(indices, int): self.remove_point_by_index(index=indices, remove=remove) elif isinstance(indices, list) and len(indices) > 1: self.remove_point_by_index(index=indices[0], remove=remove) for i, index in enumerate(indices[1:]): self.remove_point_by_location(location=location, remove=remove) # print('Multiple nodes were removed.') # ------------------------------------- if self.n == 2: """ In some cases, only 2 lines remain, of which the second one will usually be the closing line. So, its just the closing line sitting on top of original lines. As the closing line, in this case, jhas the same 'end'-points but is just flipped in direction, they both are essentially the same lines. In this case, the closing line will be removed after the chack has confirmed the existence of such a closing line. """ # Check for equal length point0, point1 = self.lines[1].coord_list same_endpoints = [self.lines[0].is_point_endpoint(point0), self.lines[0].is_point_endpoint(point1), ] if all(same_endpoints): """Retain the original lines and trim the closing line.""" del self.lines[1]
[docs] def plot(self, ax=None, connect_ends=False): """ Plot polyline on matplotlib axes. Parameters ---------- ax : matplotlib.axes.Axes, optional Axes object to plot on. If None, creates new figure/axes. connect_ends : bool, optional If True, draw line connecting polyline start and end points. Returns ------- matplotlib.axes.Axes Axes object with plotted polyline. Examples -------- >>> msl = MSline2d.by_coords([(0, 0), (1, 1), (2, 0)]) >>> ax = msl.plot() """ coords = self.get_node_coords() if ax is None: fig, ax = plt.subplots() ax.plot(coords[:, 0], coords[:, 1], '-o') if connect_ends: if self.closed and len(coords) > 2: ax.plot(coords[0, :], coords[-2, :], '-o') elif not self.closed and len(coords) > 1: ax.plot(coords[0, :], coords[-1, :], '-o') return ax
[docs] def check_overlaping_points(self, tolerance=1E-8): """ Check for overlapping/duplicate nodes within tolerance. Parameters ---------- tolerance : float, optional Maximum distance for considering points duplicates. Default: 1E-8. Returns ------- bool True if overlapping points found, False otherwise. """ coords = self.get_node_coords() d = cdist(coords, coords) if np.argwhere(d <= tolerance).shape[0] > coords.shape[0]: return True else: return False
[docs] def check_overlaping_lines(self): """ Check for overlapping line segments in polyline. Returns ------- bool or None True if overlapping lines detected, None if to be completed. Notes ----- To be developed. Current implementation collects midpoints and gradients but does not perform overlap checks. """ midpoints = [line.mid_point for line in self.lines] gradients = [line.gradient for line in self.lines]
[docs] def clone(self): """ Return an independent copy of the node and line containers. The node and line objects are shared with the original. That is safe for :meth:`smooth`, which replaces ``nodes`` and ``lines`` with new lists and new interior points rather than mutating them, and it is far cheaper than ``copy.deepcopy``. """ new = MSline2d.__new__(MSline2d) new.lines = list(self.lines) new.nodes = list(self.nodes) new.closed = self.closed new.features = dict(self.features) return new
[docs] def smooth(self, max_smooth_level=2): """ Smooth the polyline by replacing node coordinates with local means. Parameters ---------- max_smooth_level : int, optional Number of smoothing passes applied via :func:`~upxo._sup.data_ops.mean_coordinates`. Notes ----- Terminal nodes (first and last) are preserved. If fewer than 3 nodes are present, no smoothing is performed. Examples -------- .. code-block:: python from upxo.geoEntities.mulsline2d import MSline2d as msl2d from upxo.geoEntities.point2d import Point2d nodes = [Point2d(0.0, 0.0), Point2d(1.0, 0.0), Point2d(1.0, 1.0), Point2d(2.5, 2.0), Point2d(4.0, 2.0), Point2d(4.0, 6.0), Point2d(4.0, 8.0), Point2d(2.0, 8.0)] ml = msl2d.by_nodes(nodes, close=False) ax = ml.plot() ml.smooth(max_smooth_level=3) ax.plot(ml.coords[:, 0], ml.coords[:, 1]) """ # --------------------------------------------------- smoothing_carried_out = True if self.nnodes > 4: coords = mean_coordinates(self.coords, max_smooth_level) elif self.nnodes == 4: coords = mean_coordinates(self.coords, 3) elif self.nnodes == 3: coords = mean_coordinates(self.coords, 2) else: smoothing_carried_out = False # --------------------------------------------------- if smoothing_carried_out: new_nodes = [Point2d(c[0], c[1]) for c in coords[1:-1]] new_nodes_full = [self.nodes[0]] + new_nodes + [self.nodes[-1]] new_lines = [sl2d(new_nodes_full[i].x, new_nodes_full[i].y, new_nodes_full[i+1].x, new_nodes_full[i+1].y) for i in range(len(new_nodes_full)-1)] # --------------------------------------------------- self.lines = new_lines self.nodes = new_nodes_full
[docs] class ring2d(): """ Ring structure in 2D—closed polyline representation from multiple segments. This class manages a collection of connected MSline2d (multi-straight-line) segments that form a closed ring/loop structure. It handles segment ordering, flipping, spatial continuity checks, and polygon conversion. Attributes ---------- segments : list Collection of MSline2d objects forming the ring. segids : list Segment identifiers. segflips : list Boolean flags indicating if segments were flipped for continuity. nsegs : int Number of segments in the ring. closed : bool Whether ring is topologically closed. conn0 : bool First-order connectivity status (start-end closure). conn1 : dict Second-order connectivity status (inter-segment continuity). Notes ----- ``ring2d`` is in active development. Closure enforcement, reordering, and polygon generation are implemented; polygon meshing is not yet available. Name collision: :class:`upxo.geoEntities.ring2d.ring2d` is a separate, unrelated, currently-unused class (built on ``muledge2d`` rather than ``MSline2d`` segments). Mind the module path when importing. Examples -------- .. code-block:: python from upxo.geoEntities.mulsline2d import MSline2d, ring2d from upxo.geoEntities.sline2d import Sline2d as sl2d msl1 = MSline2d.from_lines( [sl2d(0.0,0.0,1.0,1.0), sl2d(1.0,1.0,1.5,1.5), sl2d(1.5,1.5,2.5,2.5)], close=False) msl2 = MSline2d.from_lines( [sl2d(2.5,2.5,4.0,4.0), sl2d(4.0,4.0,4.0,6.0)], close=False) msl3 = MSline2d.from_lines( [sl2d(4.0,6.0,4.0,8.0), sl2d(4.0,8.0,10.0,10.0)], close=False) msl4 = MSline2d.from_lines( [sl2d(0,0,20,10), sl2d(20,10,10,10)], close=False) R = ring2d([msl1, msl2, msl3, msl4]) R.assess_spatial_continuity() """ __slots__ = ('segments', 'segids', 'segflips', 'nsegs', 'coords', 'closed', 'conn0', 'conn1') EPS_coord_coincide = 1E-8
[docs] def clone(self, seg_clones): """ Return a copy of this ring that uses cloned segments. Parameters ---------- seg_clones : dict ``{id(original segment): cloned segment}``. Segments missing from it are cloned and added, so a segment shared by several rings is cloned once and stays shared among the copies. Notes ----- ``coords``, ``conn0`` and ``conn1`` are not populated anywhere in the current geometrification/meshing pipeline before a ring is cloned, so this has had no observed effect; they are still copied by value rather than by reference so that ``connectivity1()`` or ``set_coords()`` called later on the original or the clone cannot silently corrupt the other, matching the "independent copy" this method promises. """ new = ring2d.__new__(ring2d) for slot in ring2d.__slots__: if not hasattr(self, slot): continue val = getattr(self, slot) if slot in ('conn0', 'conn1') and isinstance(val, dict): val = dict(val) elif slot == 'coords' and isinstance(val, np.ndarray): val = val.copy() setattr(new, slot, val) segments = [] for seg in self.segments: if id(seg) not in seg_clones: seg_clones[id(seg)] = seg.clone() segments.append(seg_clones[id(seg)]) new.segments = segments new.segids = list(self.segids) new.segflips = list(self.segflips) return new
[docs] def translate(self, dx, dy, seen=None): """ Translate every point of this ring by ``(dx, dy)``, in place. Walks every Sline2d endpoint by object identity -- junction points and consecutive-line nodes are shared across segments -- so a point shared by several segments/lines is moved exactly once, then resyncs each Sline2d's cached x0/y0/x1/y1 to match. Parameters ---------- seen : set, optional ``{id(point): ...}`` of points already translated. Pass the same set across several ``translate`` calls (e.g. one per sibling ring in the same cluster) when their rings may share Point2d objects at a common wall, so a shared point is not translated once per ring that references it. """ if seen is None: seen = set() for seg in self.segments: for line in seg.lines: for pnt in (line.pnta, line.pntb): if id(pnt) not in seen: pnt.x += dx pnt.y += dy seen.add(id(pnt)) line.reset_coords_to_points() return seen
@staticmethod def _translated_segment(seg, dx, dy): """A new ``MSline2d`` with the same layout as ``seg`` and every point new and shifted by ``(dx, dy)``; nothing is shared with ``seg``.""" pts = [Point2d(x + dx, y + dy) for x, y in seg.get_node_coords()] new = seg.clone() new.nodes = pts new.lines = [sl2d(pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y) for i in range(len(pts) - 1)] return new
[docs] def translated_copy(self, dx, dy, seg_copies): """ Return a copy of this ring shifted by ``(dx, dy)`` that shares no point or line with it; the original is not changed. ``MSline2d.clone`` shares its point objects with the original, so ``clone`` followed by :meth:`translate` would move the original too. Parameters ---------- seg_copies : dict ``{id(original segment): its translated copy}``. Pass one dict for rings that may share walls, so a wall shared by several rings is copied once and stays shared among the copies. """ new = ring2d.__new__(ring2d) for slot in ring2d.__slots__: if not hasattr(self, slot): continue val = getattr(self, slot) if slot in ('conn0', 'conn1') and isinstance(val, dict): val = dict(val) elif slot == 'coords' and isinstance(val, np.ndarray): val = val + np.array([dx, dy]) setattr(new, slot, val) segments = [] for seg in self.segments: if id(seg) not in seg_copies: seg_copies[id(seg)] = ring2d._translated_segment(seg, dx, dy) segments.append(seg_copies[id(seg)]) new.segments = segments new.segids = list(self.segids) new.segflips = list(self.segflips) return new
def __init__(self, segments=None, segids=None, segflips=None): """ Initialize ring2d from segment collection. Parameters ---------- segments : list, optional List of MSline2d objects forming the ring. segids : list, optional Segment identifiers. segflips : list, optional Boolean flags indicating segment flip status. """ self.segments = segments self.segids = segids self.segflips = segflips self.nsegs = len(self.segments) #self.set_coords() # ----------------------- #_closure_level_0_ = self.connectivity0() #self.conn0, last_seg_flipped, flip_possible = _closure_level_0_ ## ----------------------- #if self.conn0 or flip_possible: # self.connectivity1() # ----------------------- # . . . . . . . ''' overlaps = self.assess_segment_point_overlaps(segments) # False: Proceed continuities, flips = self.assess_spatial_continuity(segments) if not all(continuities): if self.assess_possibility_of_continuity(segments): self.get_continuity_enforcement_indices() self.set_spatial_continuity(enforce_indices) else: raise ValueError('Invalid segments morphology passed.')''' # -------------------------------------- # NOw that validations have been done, we will proceed. def __repr__(self): """ Return string representation of ring2d object. Returns ------- str Brief representation with segment count and object ID. """ return f'UPXO ring. nseg={len(self.segments)}. MID: {id(self)}'
[docs] def add_segment_unsafe(self, segment): """ Add segment to ring without validation. Parameters ---------- segment : MSline2d Segment to append (no continuity checks). """ self.segments.append(segment)
[docs] def add_segid(self, segid): """ Add segment identifier to tracking list. Parameters ---------- segid : hashable Segment identifier to track. """ self.segids.append(segid)
[docs] def add_segflip(self, segflip): """ Add segment flip status flag. Parameters ---------- segflip : bool True if segment was flipped, False otherwise. """ self.segflips.append(segflip)
[docs] def check_closed(self): """ Check if ring is topologically closed. Returns ------- bool True if end node of last segment connects to start node of first segment (within EPS_coord_coincide tolerance). """ if self.segflips[self.segids.index(max(self.segids))]: START = self.segments[0].nodes[0] END = self.segments[max(self.segids)].nodes[-1] else: START = self.segments[0].nodes[0] END = self.segments[max(self.segids)].nodes[0] return START.eq_fast(END)[0] '''startseg, endseg = min(self.segids), max(self.segids) segflip = self.segflips[self.segids.index(endseg)] # ------------------------ startnode = self.segments[startseg].nodes[0] if segflip: endnode = self.segments[endseg].nodes[-1] else: endnode = self.segments[endseg].nodes[0] return startnode.eq_fast(endnode)'''
[docs] def close(self): """ Enforce topological closure by connecting end to start. Notes ----- Adds first segment's start node as last segment's end node and updates internal node list. """ self.segments[-1].nodes.append(self.segments[0].nodes[0]) self.segments[-1].update_nodes() self.closed = True
[docs] def connectivity0(self, flip_if_possible=True): """ Assess closure of 1st & last segment with option to close if possible. Parameters ---------- flip_if_possible : bool, optional Whether to flip the last segment when doing so would close the ring. Returns ------- closed : bool ``True`` if the ring is already closed or has been closed. last_seg_flipped : bool ``True`` if the last segment was flipped to achieve closure. flip_possible : bool ``True`` when closure is possible by flipping the last segment. """ closed, last_seg_flipped, flip_possible = False, False, False if self.segments[0].nodes[0].eq_fast(self.segments[-1].nodes[-1])[0]: closed = True elif self.segments[0].nodes[0].eq_fast(self.segments[-1].nodes[0])[0]: flip_possible = True if flip_if_possible: self.segments[-1].flip() closed, last_seg_flipped = True, True return closed, last_seg_flipped, flip_possible
[docs] def connectivity1(self): """ Assess closure of all intermediate segments. Returns ------- None Updates ``self.conn1`` with pairwise segment-continuity flags. """ self.conn1 = {} for i in range(self.nsegs): if i < self.nsegs-1: c = self.segments[i].nodes[-1].eq_fast(self.segments[i+1].nodes[0])[0] self.conn1[(i, i+1)] = c else: c = self.segments[i].nodes[-1].eq_fast(self.segments[0].nodes[0])[0] self.conn1[(i, 0)] = c
[docs] def assess_segment_point_overlaps(self, line_check=False): """ Check all segments for duplicate/overlapping nodes. Parameters ---------- line_check : bool, optional If True, also check for overlapping lines (not yet operational). Returns ------- list Boolean list for each segment indicating overlap presence. """ ol_points = [seg.check_overlaping_points() for seg in self.segments] # --------------------------- line_check=False # Currently not operational. if line_check: ol_lines = [seg.check_overlaping_lines() for seg in self.segments]
[docs] def assess_reorder_requirement(self): """ Check if reordering or flipping of segments is needed. Notes ----- To be developed. Currently evaluates closure status and potential flips needed to enforce continuity. """ if self.check_closed(): self.close() # NO re-ordering needed self.check_sorted() pass else: # Flips may be needed pass
[docs] def set_coords(self): """ Build and cache coordinate array from all segments. Notes ----- Combines node coordinates of all segments, skipping duplicate nodes at segment junctions. """ self.coords = self.segments[0].get_node_coords() for seg in self.segments[1:]: self.coords = np.vstack((self.coords, seg.get_node_coords()[1:]))
[docs] def get_coords(self): """ Extract and return full coordinate array from ring. Returns ------- numpy.ndarray Coordinates of all ring nodes. """ return self.create_coords_from_segments()
@property def centroid(self): """ Compute centroid of the ring shape. Returns ------- numpy.ndarray Mean (x, y) coordinate of all ring nodes. """ return np.mean(self.get_coords(), axis=0)
[docs] def create_coords_from_segments(self, force_close=False): """ Build coordinate array from all segments, respecting flips. Parameters ---------- force_close : bool, optional If True, append first coordinate to end to ensure closure. Returns ------- numpy.ndarray Coordinates from all segments, concatenated in order. """ # segments = gbsegs coords = self.segments[0].get_node_coords() for i, seg in enumerate(self.segments[1:], start=1): if self.segflips[i]: thissegcoords = np.flip(seg.get_node_coords(), axis=0) coords = np.vstack((coords, thissegcoords[1:])) else: coords = np.vstack((coords, seg.get_node_coords()[1:])) if force_close: coords = self.force_close_coordinates(coords, assess_first=True) return coords
[docs] def force_close_coordinates(self, coord, assess_first=True): """ Ensure coordinate array is closed (end connects to start). Parameters ---------- coord : numpy.ndarray Input coordinate array. assess_first : bool, optional If True, check if already closed before forcing. Returns ------- numpy.ndarray Closed coordinate array. Notes ----- Unsafe. Not intended for user. Uses EPS_coord_coincide tolerance. """ if assess_first: if abs((coord[0]-coord[-1]).sum()) > self.EPS_coord_coincide: print('Coord not closed. Force closing.') coord = np.vstack((coord, coord[0])) else: pass else: coord = np.vstack((coord, coord[0])) return coord
[docs] def create_polygon_from_segments(self): """ Convert ring to Shapely Polygon object. Returns ------- shapely.geometry.Polygon Polygon representation of the ring. """ coords = self.create_coords_from_segments() return Polygon(self.create_coords_from_segments())
[docs] def create_polygon_from_coords(self): """ Create Shapely Polygon from coordinates. Returns ------- shapely.geometry.Polygon Polygon from extracted ring coordinates. """ return Polygon(self.create_coords_from_segments())
@property def area(self): """ Compute area enclosed by ring polygon. Returns ------- float Area of ring polygon. """ return self.create_polygon_from_segments().area @property def perimeter(self): """ Compute perimeter (boundary length) of ring. Returns ------- float Total perimeter of ring polygon. """ return self.create_polygon_from_segments().length @property def is_closed(self): """ Check if ring is topologically closed. Returns ------- bool True if ring forms a closed loop. """ return self.check_closed() @property def nsegments(self): """ Return number of segments in ring. Returns ------- int Count of constituent MSline2d segments. """ return self.nsegs @property def ncoords(self): """ Return total number of nodes in ring. Returns ------- int Count of unique coordinate nodes. """ return self.create_coords_from_segments().shape[0] @property def tree(self): """ Build spatial KD-tree index for ring coordinates. Returns ------- scipy.spatial.cKDTree KD-tree for neighbor/distance queries on ring nodes. """ return cKDTree(self.create_coords_from_segments())
[docs] def assess_spatial_continuity(self): """ From list of multisline2d, multislines2d, do all (i+1)^th multisline2d follow i^th multisline2d? with or without the need for flips. Returns ------- continuity : bool ``True`` when all segments form a chain, irrespective of whether flips are needed. flip_needed : bool ``True`` if any multi-line object needs flipping to ensure spatial continuity. i_precede_chain : list ``do_i_precede`` results for every ``i:i+1`` pair. """ i_precede_chain = [] for i in range(len(self.segments)-1): if i == 0: i_precede_chain.append(self.segments[i].do_i_precede(self.segments[i+1])) else: thismsl = self.segments[i] nextmsl = self.segments[i+1] i_precede_chain.append(thismsl.do_i_precede(nextmsl)) continuity = all([flag[0] for flag in i_precede_chain]) flip_needed = any([flag[1] for flag in i_precede_chain]) return continuity, flip_needed, i_precede_chain
[docs] def plot_segs(self, plot_centroid=False, centroid_text='', plot_coord_order=False, visualize_flip_req=False): """ Visualize ring segments with optional annotations. Parameters ---------- plot_centroid : bool, optional If True, plot ring centroid marker and label. centroid_text : str, optional Label text for centroid. plot_coord_order : bool, optional If True, plot coordinate connectivity with dashed line. visualize_flip_req : bool, optional If True, use dashed lines for flipped segments. Returns ------- matplotlib.axes.Axes Axes object with plotted segments (color-coded). """ fig, ax = plt.subplots() FS = 8 for gbsegcount, gbseg in enumerate(self.segments, start=0): coords = gbseg.get_node_coords() color = np.random.random(3) if not visualize_flip_req: ax.plot(coords[:, 0], coords[:, 1], color=color, linewidth=3) if visualize_flip_req: if self.segflips[gbsegcount]: ax.plot(coords[:, 0], coords[:, 1], color=color, linewidth=3, linestyle='--') else: ax.plot(coords[:, 0], coords[:, 1], color=color, linewidth=3) centroid = gbseg.centroid ax.plot(centroid[0], centroid[1], 'kx') ax.text(centroid[0], centroid[1], gbsegcount, color='red', fontsize=12, fontweight='bold') fs = FS + (gbsegcount % 2)*2 offset = 0.1*(gbsegcount % 2) for coord_count, coord in enumerate(coords, start=0): ax.text(coord[0]+offset, coord[1]+offset, coord_count, color=color, fontsize=fs) if plot_centroid: centroid = self.centroid plt.plot(centroid[0], centroid[1], 'k+', ms=15, mfc='black', mew=2, alpha=1.0) plt.text(centroid[0], centroid[1], str(centroid_text), fontsize=15, color='orange', fontweight='bold') if plot_coord_order: C = self.create_coords_from_segments() ax.plot(C[:,0], C[:,1], '-.k', linewidth=0.75) return ax
[docs] def get_coords_newdef(self): """ Extract coordinates in new definition format. Returns ------- tuple (segment_coords_list, combined_coords) where: - segment_coords_list: List of coordinate arrays per segment - combined_coords: Concatenated coordinate array from all segments """ coordinates = [] for gbsegcount, gbseg in enumerate(self.segments, start=0): coords = gbseg.get_node_coords() coordinates.append(coords) C = self.create_coords_from_segments() return coordinates, C
[docs] class mulring2d(): """ Collection of 2D ring objects with shared interface bookkeeping. Parameters ---------- rings : list Ring objects to be grouped into the multi-ring container. Attributes ---------- rings : list Individual ring objects supplied at construction. jp : object Junction-point storage placeholder. ip : object Interface-point storage placeholder. Notes ----- The implementation is currently a placeholder for future multi-ring workflows. Existing notes below describe the intended data model and are retained for development continuity. """ __slots__ = ('rings', 'jp', 'ip') """ Explanations ------------ 'rings': individual ring objects 'jp': Junction points 'ip': All interface points 'ippure': Pure interface points = {ip} - {jp} # Requirements 1. Provide a self-sustained """ def __init__(self, rings): """ Initialise from a list of ring objects. Parameters ---------- rings : list Ring objects to store in the multi-ring container. """ self.rings = rings
[docs] def build_points_list(self): """ Build a flat list of all points from the ring collection. Raises ------ NotImplementedError Always raised because point-list construction is not implemented. """ raise NotImplementedError("build_points_list is not yet implemented.")
[docs] def set_coords(self): """ Set coordinate arrays from the ring collection. Raises ------ NotImplementedError Always raised because multi-ring coordinate caching is not implemented. """ raise NotImplementedError("set_coords is not yet implemented.")
[docs] def export_abaqus_for_meshing(self): """ Export the geometry in Abaqus mesh-input format. Raises ------ NotImplementedError Always raised because Abaqus export is not implemented. """ raise NotImplementedError("export_abaqus_for_meshing is not yet implemented.")
[docs] def mesh(self): """ Generate a mesh for the ring collection. Raises ------ NotImplementedError Always raised because multi-ring meshing is not implemented. """ raise NotImplementedError("mesh is not yet implemented.")