"""
2D multi-point collection.
This module provides a container class for managing ordered collections of
2D points in UPXO. It supports construction from coordinate arrays, individual
Point2d objects, rectangular grids, and intersection results, and exposes
numerical, geometric, and spatial-query operations over the full point set.
Usage
-----
from upxo.geoEntities.mulpoint2d import MPoint2d
Recommended alias imports:
--------------------------
from upxo.geoEntities.mulpoint2d import MPoint2d as mp2d
Metadata
--------
* Module: upxo.geoEntities.mulpoint2d
* Package: upxo
* License: GPL-3.0-only
* Author: Dr. Sunil Anandatheertha
* Email: vaasu.anandatheertha@ukaea.uk
* Status: Active development
* Last updated: 2026-03-12
Applications
------------
* Storing and manipulating polycrystal grain-boundary vertex sets
* Batch spatial queries (distances, centroid proximity, containment)
* Rectangular-grid point generation for structured-domain discretization
* Clustering-based synthetic point-cloud generation
* Intersection-point collection from multi-line geometry operations
Classes
-------
* MPoint2d - ordered 2D multi-point collection backed by a (N x 2) NumPy array
Definitions
-----------
coords : np.ndarray, shape (N, 2)
Array of (x, y) coordinate pairs for all points in the collection.
points : list of Point2d
Corresponding list of UPXO Point2d objects.
n : int
Number of points in the collection (read-only property).
centroid : np.ndarray, shape (2,)
Arithmetic mean of all (x, y) coordinates.
"""
import math
import numpy as np
from copy import deepcopy
# from icecream import ic
import itertools
from scipy.spatial import cKDTree
import vtk
from shapely.geometry import Point as ShPnt, Polygon as ShPol
from shapely.geometry import LineString
from functools import wraps
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
import upxo._sup.dataTypeHandlers as dth
from upxo.geoEntities.bases import UPXO_Point, UPXO_Edge
# from upxo._sup.validation_values import find_pnt_spec_type_2d
np.seterr(divide='ignore')
from upxo.geoEntities.featmake import make_p2d, make_p3d
from upxo._sup.validation_values import find_spec_of_points
from upxo._sup.validation_values import isinstance_many
import upxo.geoEntities.featmake as fmake
from upxo.geoEntities.point2d import Point2d
from upxo.geoEntities.point3d import Point3d
from upxo._sup.validation_values import val_point_and_get_coord, val_points_and_get_coords
[docs]
class MPoint2d():
"""
UPXO core class. Collection of 2D points. Offers wide spectrucm of operations.
Stores points both as a NumPy array (``coords``) for vectorised numerical
operations and as a list of UPXO ``Point2d`` objects (``points``) for
object-level access. Supports construction from raw coordinates, existing
``Point2d`` instances, rectangular grids, clustering distributions, and
multi-line intersection results.
Parameters
----------
coords : array-like of shape (N, 2), optional
Sequence of (x, y) coordinate pairs. If provided, ``points`` must be
None; individual ``Point2d`` objects are created automatically.
points : list of Point2d, optional
Pre-constructed UPXO ``Point2d`` objects. If provided, ``coords``
must be None; the coordinate array is derived from the points.
Standard data format
--------------------
coords: np.array([[0, 0], [1, 1], [2, 3], [4, 5]])
Usage
-----
from upxo.geoEntities.mulpoint2d import MPoint2d
from upxo.geoEntities.mulpoint2d import MPoint2d as mp2d
"""
EPS = 1E-8
__slots__ = ('coords', 'points')
def __init__(self, coords=None, points=None):
"""Initialise from raw coordinate array or a list of ``Point2d`` objects."""
if coords is not None and points is None:
self.coords = np.array(coords)
self.points = [Point2d(xy[0], xy[1]) for xy in coords]
if coords is None and points is not None:
self.points = points
self.coords = np.array([[p.x, p.y] for p in points])
def __repr__(self):
"""Return ``UPXO-mp2d. n=<N>.`` summary string."""
return f'UPXO-mp2d. n={self.n}.'
def __iter__(self):
"""
Return an iterable of point coordinates in self.
Examples
--------
.. code-block:: python
from upxo.geoEntities.mulpoint2d import MPoint2d as mp2d
mulpoint2d = mp2d.from_coords(np.random.random((10,2)))
for coord in mulpoint2d:
print(coord)
"""
return iter(self.coords)
def __getitem__(self, i):
"""
Make self indexable. i: index location.
Examples
--------
.. code-block:: python
from upxo.geoEntities.mulpoint2d import MPoint2d as mp2d
mulpoint2d = mp2d.from_coords(np.random.random((10,2)))
mulpoint2d[9]
mulpoint2d[10]
"""
if i >= self.n:
raise ValueError('Index exceeds maximum number of coordinates.')
return self.coords[i]
[docs]
def add(self, toadd=None, operation='add'):
"""
Add toadd to self.coords.
Examples
--------
**Example 1** — add a scalar:
.. code-block:: python
from upxo.geoEntities.mulpoint2d import MPoint2d as mp2d
mulpoint2d = mp2d.from_coords(np.random.random((10,2)))
mulpoint2d.coords
mulpoint2d.add(toadd=10, operation='add')
mulpoint2d.coords
**Example 2** — add a 2-element list:
.. code-block:: python
from upxo.geoEntities.mulpoint2d import MPoint2d as mp2d
mulpoint2d = mp2d.from_coords(np.random.random((10,2)))
mulpoint2d.coords
mulpoint2d.add(toadd=[-10, 20], operation='add')
mulpoint2d.coords
**Example 3** — add an (N, 2) array:
.. code-block:: python
from upxo.geoEntities.mulpoint2d import MPoint2d as mp2d
mulpoint2d = mp2d.from_coords(np.random.random((10,2)))
mulpoint2d.coords
mulpoint2d.add(toadd=np.random.random((mulpoint2d.n, 2)), operation='add')
mulpoint2d.coords
**Example 4** — add a transposed (2, N) array:
.. code-block:: python
from upxo.geoEntities.mulpoint2d import MPoint2d as mp2d
mulpoint2d = mp2d.from_coords(np.random.random((10,2)))
mulpoint2d.coords
mulpoint2d.add(toadd=np.random.random((mulpoint2d.n, 2)).T, operation='add')
mulpoint2d.coords
**Example 5** — append rows:
.. code-block:: python
from upxo.geoEntities.mulpoint2d import MPoint2d as mp2d
mulpoint2d = mp2d.from_coords(np.random.random((10,2)))
mulpoint2d.coords
mulpoint2d.add(toadd=np.random.random((10,2)), operation='append')
mulpoint2d.coords
"""
if toadd is None:
return
else:
if operation == 'add':
if type(toadd) in dth.dt.NUMBERS:
self.coords += toadd
if type(toadd) in dth.dt.ITERABLES:
if find_spec_of_points(toadd) == 'type-[1,2]':
'''
toadd = [0, 0]
find_spec_of_points(toadd)
'''
self.coords += np.array(toadd)
if find_spec_of_points(toadd) == 'type-[[1,2]]':
'''
toadd = [[0, 0]]
find_spec_of_points(toadd)
'''
self.coords += np.array(toadd[0])
if find_spec_of_points(toadd) == 'type-[[1,2],[4,5],[7,8]]':
'''
toadd = [[1,2],[4,5],[7,8]]
find_spec_of_points(toadd)
'''
if len(toadd) == self.n:
self.coords += np.array(toadd)
else:
raise ValueError('Invalid length of toadd.')
if find_spec_of_points(toadd) == 'type-[[1,2,3,4],[5,6,7,8]]':
'''
toadd = [[1,2,3,4],[5,6,7,8]]
find_spec_of_points(toadd)
'''
if len(toadd[0]) == self.n:
self.coords += np.array(toadd).T
else:
raise ValueError('Invalid length of toadd.')
elif operation == 'append':
if type(toadd) in dth.dt.ITERABLES:
if find_spec_of_points(toadd) == 'type-[1,2,3]':
'''
toadd = [0, 0, 0]
find_spec_of_points(toadd)
'''
self.coords = np.array(list(self.coords) + list(toadd))
if find_spec_of_points(toadd) == 'type-[[1,2,3]]':
'''
toadd = [[0, 0, 0]]
find_spec_of_points(toadd)
'''
self.coords = np.array(list(self.coords) + list(toadd[0]))
if find_spec_of_points(toadd) == 'type-[[1,2,3],[4,5,6],[7,8,9]]':
'''
toadd = [[1,2,3],[4,5,6],[7,8,9],[7,8,9]]
find_spec_of_points(toadd)
'''
toadd = [list(ta) for ta in toadd]
coords = [list(coord) for coord in self.coords]
self.coords = np.array(coords+toadd)
if find_spec_of_points(toadd) == 'type-[[1,2,3,4],[1,2,3,4],[1,2,3,4]]':
'''
toadd = [[1,2,3,4],[1,2,3,4],[1,2,3,4]]
find_spec_of_points(toadd)
'''
toadd = [list(ta) for ta in np.array(toadd).T]
coords = [list(coord) for coord in self.coords]
self.coords += np.array(toadd).T
[docs]
@classmethod
def from_coords(cls, point_coords):
"""
Instantiate mulpoint2d using list of point coordinates.
Examples
--------
.. code-block:: python
from upxo.geoEntities.mulpoint2d import MPoint2d as mp2d
point_coords = np.array([[0, 0], [1, 1], [2, 3], [4, 5]])
MULPOINT2D = mp2d.from_coords(point_coords)
MULPOINT2D.coords
MULPOINT2D.points
"""
# Validations
return cls(coords=np.array(point_coords))
[docs]
@classmethod
def from_xy(cls, xy):
"""
Instantiate mulpoint2d using array of x, y and z coordinate lists.
Examples
--------
.. code-block:: python
from upxo.geoEntities.mulpoint2d import MPoint2d as mp2d
xy = np.array([[0, 0], [1, 1], [2, 3], [4, 5]]).T
MULPOINT2D = mp2d.from_xy(xy)
MULPOINT2D.coords
"""
# Validations
return cls(coords = xy.T)
[docs]
@classmethod
def from_upxo_points2d(cls, points, zloc=0.0):
"""Construct from a list of ``Point2d`` objects."""
return cls(coords=None, points=points)
[docs]
@classmethod
def from_mulpoint2d(cls, mp2d, zloc=0.0):
"""Construct from an existing ``MPoint2d`` instance. Not yet implemented."""
raise NotImplementedError("from_mulpoint2d is not yet implemented.")
[docs]
@classmethod
def from_rect_grid(cls, xstart, xinc, xend, ystart, yinc, yend):
"""
Instantiate a rectangular grid of 2D points.
Examples
--------
.. code-block:: python
from upxo.geoEntities.mulpoint2d import MPoint2d
mp = MPoint2d.from_rect_grid(0, 1, 5, 0, 1, 3)
"""
# Validations
# ------------------------------
x, y = np.meshgrid(np.arange(xstart, xend, xinc),
np.arange(ystart, yend, yinc))
coords = np.c_[x.ravel(), y.ravel()]
return cls(coords=coords)
[docs]
@classmethod
def from_clustering_around_centroid(cls, centroid, n=10, r=1,
distribution='urand', dmin=None):
"""
Build a point cluster uniformly distributed around a centroid.
Examples
--------
Single cluster at (-5, -10) with 1000 points, radius 1:
.. code-block:: python
import matplotlib.pyplot as plt
from upxo.geoEntities.mulpoint2d import MPoint2d
MP2D = MPoint2d.from_clustering_around_centroid(
(-5, -10), n=1000, r=1, distribution='urand', dmin=0.1)
plt.scatter(MP2D.coords[:, 0], MP2D.coords[:, 1])
Multiple clusters along the x-axis:
.. code-block:: python
cenx, ceny = [0, 1, 2, 3, 4], [0, 0, 0, 0, 0]
for cx, cy in zip(cenx, ceny):
MP2D = MPoint2d.from_clustering_around_centroid(
(cx, cy), n=250, r=0.25, distribution='urand', dmin=0.1)
plt.scatter(MP2D.coords[:, 0], MP2D.coords[:, 1])
"""
# Validations
centroid = val_point_and_get_coord(centroid, return_type='upxo',
safe_exit=False)
# --------------------------------------
return cls(centroid.array_by_clustering(n=n, r=r,
return_type='coords_2d'))
[docs]
@classmethod
def from_intersection_linesA_linesB(cls, La, Lb,
return_ordered_points=True,
plot=False):
"""
Create mulpoint from intersection of many lines.
Checks lines for intersections and creates UPXO point object at
every intersection.NOTE: This method can potentially cause a
bottleneck in case of large lines array size.
Parameters
----------
La : list of Sline2d
Line objects for the first group.
Lb : list of Sline2d
Line objects for the second group.
return_ordered_points : bool, optional
If True, ordered points will also be returned. Default is True.
plot : bool, optional
If True, lines and intersection points will be visualized.
Returns
-------
cls : MPoint2d
Collection of intersection points.
points : list of list of Point2d
Ordered intersection points; ``len(Lb)`` rows by ``len(La)`` columns.
Examples
--------
.. code-block:: python
from upxo.geoEntities.sline2d import Sline2d
from upxo.geoEntities.point2d import Point2d
from upxo.geoEntities.mulpoint2d import MPoint2d
na, nb = 5, 5
R = np.random.rand
La = [Sline2d.by_coord([-10-R(),-10+R()], [10+R(),10-R()])
for _ in range(na)]
Lb = [Sline2d.by_coord([-10+R(),-10-R()], [10-R(),10+R()])
for _ in range(nb)]
MPoint2d.from_intersection_linesA_linesB(La, Lb,
return_ordered_points=True,
plot=True)
MP2D = MPoint2d.from_intersection_linesA_linesB(La, Lb,
return_ordered_points=False,
plot=False)
MP2D.points, MP2D.coords
"""
# Validations
'''Validation: La and Lb must be non-empty Iterables.'''
# ----------------------------------------------
na, nb = len(La), len(Lb)
points = [[None for _ in range(na)] for __ in range(nb)]
for lai, la in enumerate(La):
for lbi, lb in enumerate(Lb):
points[lbi][lai] = Point2d.from_intersection_two_lines(la, lb,
tool='upxo',
return_type='upxo')
# ----------------------------------------------
points_all = []
for lai in range(na):
for lbi in range(nb):
for pnts in points[lbi][lai]:
points_all.append(pnts)
# ----------------------------------------------
if plot:
La[0].plot(p2d=points_all, sl2d=La[1:]+Lb)
# return points, points_all
if return_ordered_points:
return cls([[pnt.x, pnt.y] for pnt in points_all]), points
else:
return cls([[pnt.x, pnt.y] for pnt in points_all])
@property
def n(self):
"""Number of points in the collection."""
return len(self.coords)
@property
def centroid(self):
"""Mean coordinate of all points (``numpy.ndarray`` of shape ``(2,)``)."""
return np.mean(self.coords, axis=0)
@property
def get_points(self):
"""Return a list of ``Point2d`` objects built from ``self.coords``."""
return [Point2d(x, y) for x, y in self.coords]
@property
def x(self):
"""x-coordinates of all points as a 1-D array."""
return self.coords[:, 0]
@property
def y(self):
"""y-coordinates of all points as a 1-D array."""
return self.coords[:, 1]
def __contains__(self, point, validate=False):
"""
Check whether ``point`` is among the stored points.
Examples
--------
.. code-block:: python
from upxo.geoEntities.mulpoint2d import MPoint2d as mp2d
xy = np.array([[0, 0], [1, 1], [2, 3], [4, 5]]).T
MULPOINT2D = mp2d.from_xy(xy)
[0, 0] in MULPOINT2D
"""
return any(self.squared_distances_to_point(point,
validate=validate) <= self.EPS)
[docs]
def squared_distances_to_point(self, point, validate=True):
"""
When validate is True, then point can be any of the permitted forms.
When validate is False, then point must be in UPXO point format.
"""
if validate:
point = val_point_and_get_coord(point, return_type='coord',
safe_exit=False)
return (self.x-point[0])**2 + (self.y-point[1])**2
else:
return (self.x-point.x)**2 + (self.y-point.y)**2
[docs]
def distances_to_point(self, point):
"""Return Euclidean distances from all points to ``point``."""
return np.sqrt(self.squared_distances_to_point(point))
[docs]
def squared_distance_to_centroid(self, points,
validate_points=True,
points_type='numpy'):
"""
Calculates squared distances between self.centroid and other 2D points.
Parameters
----------
points : list of Point2d or numpy.ndarray
Points to compute distances from.
validate_points : bool, optional
If True, validation will be used. When confident that
points are provided as a numpy array of coordinate pairs, it is
advised to keep this False. When unknown, keep it True. True may
increase computation time depending on the number of points.
points_type : str, optional
If validate_points is False, then points_type must be
``'numpy'``. You could also use ``'coord'`` but this would include an
additional overhead of conversion from coord to numpy array.
Examples
--------
.. code-block:: python
from upxo.geoEntities.mulpoint2d import MPoint2d
from upxo.geoEntities.point2d import Point2d
MULPOINT2D = MPoint2d.from_coords(np.random.random((10, 2)))
POINTS = make_p2d(2+np.random.random((10, 2)), return_type='p2d')
MULPOINT2D.squared_distance_to_centroid(POINTS, validate_points=True)
POINTS = 2+np.random.random((10, 2))
MULPOINT2D.squared_distance_to_centroid(POINTS, validate_points=False,
points_type='numpy')
"""
cen = self.centroid
if validate_points:
pnts = val_points_and_get_coords(points,
return_type='numpy',
safe_exit=False)
else:
if points_type in ('upxo', 'shapely'):
pnts = val_points_and_get_coords(points,
return_type='numpy',
safe_exit=False)
elif points_type in ('coord', 'coord_pair'):
pnts = val_points_and_get_coords(np.array(points),
return_type='numpy',
safe_exit=False)
elif points_type in ('np', 'numpy'):
pnts = points
return (pnts[:, 0]-cen[0])**2 + (pnts[:, 1]-cen[1])**2
[docs]
def distance_to_centroid(self, points, validate_points=True,
points_type='numpy'):
"""
Calculates distances between self.centroid and other 2D points.
Examples
--------
.. code-block:: python
from upxo.geoEntities.mulpoint2d import MPoint2d
from upxo.geoEntities.point2d import Point2d
MULPOINT2D = MPoint2d.from_coords(np.random.random((10, 2)))
POINTS = make_p2d(2+np.random.random((10, 2)), return_type='p2d')
MULPOINT2D.squared_distance_to_centroid(POINTS, validate_points=True)
POINTS = 2+np.random.random((10, 2))
MULPOINT2D.distance_to_centroid(POINTS, validate_points=False,
points_type='numpy')
"""
return np.sqrt(self.squared_distance_to_centroid(points,
validate_points=validate_points,
points_type=points_type))
[docs]
def linreg(self):
"""Fit a linear regression line through all points. Not yet implemented."""
raise NotImplementedError("linreg is not yet implemented.")
[docs]
def relax(self):
"""Relax point positions (e.g., Lloyd iteration). Not yet implemented."""
raise NotImplementedError("relax is not yet implemented.")
[docs]
def convex_hull(self):
"""Compute the convex hull of the point set. Not yet implemented."""
raise NotImplementedError("convex_hull is not yet implemented.")
[docs]
def find_boundary(self, boundary_type='chull'):
"""
Use convex hull and find boundaries
"""
raise NotImplementedError("find_boundary is not yet implemented.")
[docs]
def bbox(self):
"""
Return bounding box of the mulpoint.
Returns
-------
bbox : dict
Keys ``'bl'``, ``'br'``, ``'tr'``, ``'tl'`` for bottom-left,
bottom-right, top-right, and top-left corners.
Examples
--------
.. code-block:: python
from upxo.geoEntities.mulpoint2d import MPoint2d as mp2d
MP2D = mp2d.from_coords(np.random.random((10,2)))
MP2D.bbox()
"""
x, y = self.x, self.y
bl, br = [x.min(), y.min()], [x.max(), y.min()]
tr, tl = [x.max(), y.max()], [x.min(), y.max()]
return {'bl': bl, 'br': br, 'tr': tr, 'tl': tl}
[docs]
def maketree(self, treeType='ckdtree'):
"""
Use tree structure to deal with a very large system of points.
Examples
--------
.. code-block:: python
from upxo.geoEntities.mulpoint2d import MPoint2d as mp2d
mulpoint2d = mp2d.from_coords(np.random.random((25, 3)))
mulpoint2d.coords
from scipy.spatial import cKDTree as ckdt
a = ckdt(mulpoint2d.coords, copy_data=False, balanced_tree=True)
a.data
"""
if treeType in ('ckdtree', 'kdtree'):
# Scipy ckdtree
from scipy.spatial import cKDTree as ckdt
# Make the tree data-structure
return ckdt(self.coords, copy_data=False, balanced_tree=True)
[docs]
def plot(self, points=None, primary_ms=None, secondary_ms=None):
"""
Scatter plot points and choose to overlay over specifried points.
Parameters
----------
points: List of secondary points
primary_ms: marker size to use for primary list of points
secondary_ms: marker size to use for secondary list of points
Examples
--------
.. code-block:: python
from upxo.geoEntities.mulpoint2d import MPoint2d as mp2d
mulpoint2d = mp2d.from_coords(np.random.random((25, 3)))
MULPOINT2D = mp2d.from_mulpoint2d(mulpoint2d=mulpoint2d,
dxy=[0.0, 0.0],
translate_ref=mulpoint2d.centroid,
rot=[10, 0.0, 0.0],
rot_ref=mulpoint2d.centroid,
degree=True)
mulpoint2d.plot(MULPOINT2D.coords, primary_ms=50)
"""
fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')
# -----------------------------
# PRIMARY POINT SET
if primary_ms is None:
primary_ms = 100
ax.scatter(self.coords[:, 0], self.coords[:, 1], self.coords[:, 2],
c='b', marker='o', alpha=0.2, s=primary_ms)
# -----------------------------
if points is not None:
# SECONDARY POINT SET
if secondary_ms is None:
secondary_ms = 50
ax.scatter(points[:, 0], points[:, 1], points[:, 2],
c='r', marker='x', s=50)
# -----------------------------
ax.set_xlabel('X')
ax.set_ylabel('Y')
ax.set_zlabel('Z')
ax.set_title('3D Scatter Plot')
plt.show()