import typing
import networkx
import numpy as np
from pymead.core.param import LengthParam
from pymead.core.pymead_obj import PymeadObj
[docs]
class Point(PymeadObj):
"""
The ``Point`` is the lowest-level geometry object in `pymead`. All curves in `pymead` are tied directly to ``Point``
objects. For example, instances of this class are used to define the endpoints of finite lines and control points of
Bézier curves.
"""
[docs]
def __init__(self, x: float, y: float, name: str or None = None, relative_airfoil_name: str = None,
setting_from_geo_col: bool = False):
super().__init__(sub_container="points")
self._x = None
self._y = None
self.relative_airfoil_name = relative_airfoil_name
self.relative_airfoil = None
self.gcs = None
self.root = False
self.rotation_handle = False
self.rotation_param = None
self.geo_cons = []
self.dims = []
self.curves = []
self.setting_from_geo_col = setting_from_geo_col
name = "Point-1" if name is None else name
self.set_name(name)
self.set_x(x)
self.set_y(y)
[docs]
def x(self):
"""
Getter for the point's ``x`` parameter.
Returns
-------
LengthParam
The point's ``x`` parameter
"""
return self._x
[docs]
def y(self):
"""
Getter for the point's ``y`` parameter.
Returns
-------
LengthParam
The point's ``y`` parameter
"""
return self._y
def set_x(self, x: LengthParam or float):
self._x = x if isinstance(x, LengthParam) else LengthParam(
value=x, name=self.name() + ".x", setting_from_geo_col=self.setting_from_geo_col, point=self)
if self not in self._x.geo_objs:
self._x.geo_objs.append(self)
self._x.point = self
def set_y(self, y: LengthParam or float):
self._y = y if isinstance(y, LengthParam) else LengthParam(
value=y, name=self.name() + ".y", setting_from_geo_col=self.setting_from_geo_col, point=self)
if self not in self._y.geo_objs:
self._y.geo_objs.append(self)
self._y.point = self
[docs]
def set_name(self, name: str):
"""
Extends the base ``set_name()`` by also renaming the point's ``x`` and ``y`` parameters.
Parameters
----------
name: str
Name for the point
"""
# Rename the x and y parameters of the Point
if self.x() is not None:
self.x().set_name(f"{name}.x")
if self.y() is not None:
self.y().set_name(f"{name}.y")
super().set_name(name)
[docs]
def as_array(self):
"""
Gives a one-dimensional, two-element array representation of the point (:math:`x` and :math:`y` values)
Returns
-------
np.ndarray
One-dimensional array containing the point's :math:`x` and :math:`y` values
"""
return np.array([self.x().value(), self.y().value()])
@classmethod
def generate_from_array(cls, arr: np.ndarray, name: str or None = None):
if arr.ndim != 1:
raise ValueError("Points can only be generated from 1-dimensional arrays")
return cls(x=arr[0], y=arr[1], name=name)
[docs]
def measure_distance(self, other: "Point") -> float:
"""
Measures the distance from this point to another point.
Parameters
----------
other: Point
Other point (the endpoint of the line whose distance is measured)
Returns
-------
float
The distance between ``self`` and ``other``
"""
# Note: the quotes around "Point" are necessary here because this type hint is a forward reference.
# This means that an object of type <Class> is specified as a hint somewhere inside the definition for <Class>
return np.hypot(other.x().value() - self.x().value(), other.y().value() - self.y().value())
[docs]
def measure_angle(self, other: "Point"):
"""
Measures the angle (in radians) of the line starting at this point and ending at ``other``
Parameters
----------
other: Point
Other point (the endpoint of the line whose angle is measured)
Returns
-------
float
The angle of the line connecting ``self`` and ``other``
"""
return np.arctan2(other.y().value() - self.y().value(), other.x().value() - self.x().value())
[docs]
def is_coincident(self, other: "Point", rtol: float = 1.0e-14) -> bool:
"""
Determines whether this point is coincident with another point (within a tight tolerance)
Parameters
----------
other: Point
Other point to test for coincidence with this point
rtol: float
Relative tolerance used for the coincidence test. Default: ``1e-14``
Returns
-------
bool
Whether the points are coincident
"""
dist = self.measure_distance(other)
if np.isclose(dist, 0.0, rtol=rtol):
return True
return False
def _is_symmetry_123_and_no_edges(self) -> list or bool:
"""
Checks if this point is a member of a symmetry constraint (but not the target of the symmetry constraint) and
has no attached edges in the constraint graph. Used to determine if the point is allowed to move in
``request_move()``.
Returns
-------
list or bool
The list of symmetry constraints this point is a member of if the above conditions are met,
``False`` otherwise
"""
if self.gcs is None:
return False
if len([edge for edge in self.gcs.in_edges(nbunch=self)]) != 0:
return False
if len([edge for edge in self.gcs.out_edges(nbunch=self)]) != 0:
return False
symmetry_constraints = []
for geo_con in self.geo_cons:
if geo_con.__class__.__name__ == "SymmetryConstraint":
symmetry_constraints.append(geo_con)
for symmetry_constraint in symmetry_constraints:
if self is symmetry_constraint.p4:
return False
return symmetry_constraints
def _is_symmetry_target_and_has_edges(self) -> bool:
if self.gcs is None:
return False
if (not [edge for edge in self.gcs.in_edges(nbunch=self)] and
not [edge for edge in self.gcs.out_edges(nbunch=self)]):
return False
symmetry_constraints = []
for geo_con in self.geo_cons:
if geo_con.__class__.__name__ == "SymmetryConstraint":
symmetry_constraints.append(geo_con)
if not symmetry_constraints:
return False
if not any([self is symmetry_constraint.p4 for symmetry_constraint in symmetry_constraints]):
return False
return True
[docs]
def is_movement_allowed(self) -> bool:
"""
This method determines if movement is allowed for the point. Movement is allowed in these cases:
- Where the constraint solver has not been set or there are no geometric constraints attached
- Where the point is a root or rotation handle of a constraint cluster. If a rotation handle, movement is
allowed, but the movement gets accepted as a rotation about the root point with a fixed distance to the
root point
- Where the point is one of the first three out of the four points in a symmetry constraint, and no edges are
attached to this point in the constraint graph
Returns
-------
bool
``True`` if movement is allowed for this point, ``False`` otherwise
"""
if any([curve.__class__.__name__ == "PolyLine" for curve in self.curves]):
return False
if self.gcs is None:
return True
if self.gcs is not None and len(self.geo_cons) == 0:
return True
if self.gcs is not None and self.root:
return True
if self.gcs is not None and self.rotation_handle:
# In this case, movement is allowed, but movements get translated to a rotation about the root point with
# a fixed distance to the root point
return True
if self._is_symmetry_123_and_no_edges():
return True
return False
[docs]
def request_move(self, xp: float, yp: float, force: bool = False):
"""
Updates the location of the point and updates any curves and canvas items associated with the point movement.
Parameters
----------
xp: float
New :math:`x`-value for the point
yp: float
New :math:`y`-value for the point
force: bool
Force the movement of this point. Overrides ``is_movement_allowed``.
Warning
-------
The ``force`` keyword argument should **never** be called directly from the API, or unexpected behavior
may result. This argument is used in the backend code for the constraint solver in the symmetry and curvature
constraints.
"""
if not self.is_movement_allowed() and not force:
return
old_point_vals = {k: v.as_array() for k, v in self.geo_col.container()["points"].items()} \
if self.geo_col is not None else {}
if self.root:
# Bounds checks
if (self.x().lower() is not None and self.x().upper() is not None and
not self.x().lower() <= xp <= self.x().upper()):
return
if (self.y().lower() is not None and self.y().upper() is not None and
not self.y().lower() <= yp <= self.y().upper()):
return
points_to_update = self.gcs.translate_cluster(self, dx=xp - self.x().value(), dy=yp - self.y().value())
constraints_to_update = []
for point in networkx.dfs_preorder_nodes(self.gcs, source=self):
for geo_con in point.geo_cons:
if geo_con not in constraints_to_update:
constraints_to_update.append(geo_con)
for geo_con in constraints_to_update:
if geo_con.canvas_item is not None:
geo_con.canvas_item.update()
elif self.rotation_handle:
if self.rotation_param is None:
return
points_to_update = self.rotation_param.set_value(
self.geo_col.units.convert_angle_from_base(
self.rotation_param.root.measure_angle(Point(xp, yp)),
self.geo_col.units.current_angle_unit()
),
from_request_move=True
)
else:
self.x().set_value(xp, direct_user_request=False)
self.y().set_value(yp, direct_user_request=False)
points_to_update = [self]
symmetry_constraints = self._is_symmetry_123_and_no_edges()
if symmetry_constraints:
for symmetry_constraint in symmetry_constraints:
self.gcs.solve_symmetry_constraint(symmetry_constraint)
points_to_update.extend(symmetry_constraint.child_nodes)
points_to_update = list(set(points_to_update)) # Get only the unique points
for symmetry_constraint in symmetry_constraints:
if symmetry_constraint.canvas_item is not None:
symmetry_constraint.canvas_item.update()
if self._is_symmetry_target_and_has_edges():
points_solved = []
for gc in self.geo_cons:
points_solved.extend(self.gcs.solve(gc))
self.gcs.update_canvas_items(list(set(points_solved)))
# Update the GUI object, if there is one
if self.canvas_item is not None:
self.canvas_item.updateCanvasItem(self.x().value(), self.y().value())
if points_to_update is None:
return
curves_to_update = []
for point in points_to_update:
for curve in point.curves:
if curve not in curves_to_update:
curves_to_update.append(curve)
airfoils_to_update = []
for curve in curves_to_update:
if curve.airfoil is not None and curve.airfoil not in airfoils_to_update:
airfoils_to_update.append(curve.airfoil)
# Update airfoil-relative points
if not self.relative_airfoil:
for airfoil in airfoils_to_update:
airfoil.update_relative_points(old_point_vals)
# Visual updates to geometric objects
for point in points_to_update:
if point.canvas_item is not None:
point.canvas_item.updateCanvasItem(point.x().value(), point.y().value())
for curve in curves_to_update:
curve.update()
for airfoil in airfoils_to_update:
airfoil.update_coords()
if airfoil.canvas_item is not None:
airfoil.canvas_item.generatePicture()
def __repr__(self):
return f"Point {self.name()}<x={self.x().value():.6f}, y={self.y().value():.6f}>"
[docs]
def get_dict_rep(self):
return {"x": float(self.x().value()), "y": float(self.y().value()),
"relative_airfoil_name": self.relative_airfoil_name}
def __add__(self, other: "Point"):
return Point(x=self.x().value() + other.x().value(), y=self.y().value() + other.y().value())
def __sub__(self, other: "Point"):
return Point(x=self.x().value() - other.x().value(), y=self.y().value() - other.y().value())
def __mul__(self, other: float):
if isinstance(other, float):
return Point(x=self.x().value() * other, y=self.y().value() * other)
else:
raise ValueError("Only multiplication between points and scalars is currently supported")
def __rmul__(self, other: float):
return self.__mul__(other)
[docs]
class PointSequence:
[docs]
def __init__(self, points: typing.List[Point]):
self._points = None
self.set_points(points)
def __getitem__(self, idx):
if isinstance(idx, slice):
return self.generate_from_slice(self, idx)
else:
return self.points()[idx]
def __setitem__(self, idx, val):
self.points()[idx] = val
def __len__(self):
return len(self.points())
@classmethod
def generate_from_slice(cls, original_point_seq, s):
return cls(points=original_point_seq.points()[s].copy())
@classmethod
def generate_from_array(cls, arr: np.ndarray):
if arr.shape[1] != 2:
raise ValueError(f"Array must have two columns, x and y. Found {arr.shape[1]} columns.")
return cls(points=[Point(x=x, y=y, name=f"PointFromArray-Index{idx}")
for idx, (x, y) in enumerate(zip(arr[:, 0], arr[:, 1]))])
def points(self):
return self._points
def point_idx_from_ref(self, point: Point):
return self.points().index(point)
def reverse(self):
self.points().reverse()
def set_points(self, points: typing.List[Point]):
self._points = points
def insert_point(self, idx: int, point: Point):
self._points.insert(idx, point)
def append_point(self, point: Point):
self._points.append(point)
def remove_point(self, idx: int):
self._points.pop(idx)
def as_array(self):
return np.array([[p.x().value(), p.y().value()] for p in self.points()])
def extract_subsequence(self, indices: list):
return PointSequence(points=[self.points()[idx] for idx in indices])