diff --git a/pymead/core/gcs.py b/pymead/core/gcs.py index 941aa612..905d4d48 100644 --- a/pymead/core/gcs.py +++ b/pymead/core/gcs.py @@ -31,6 +31,7 @@ def __init__(self): super().__init__() self.points = {} self.roots = [] + self.geo_col = None def _add_point(self, point: Point): """ @@ -109,6 +110,9 @@ def _set_edge_as_root(self, u: Point, v: Point): v.rotation_handle = True if u not in [edge[0] for edge in self.roots]: self.roots.append((u, v)) + param = self.geo_col.add_param(value=u.measure_angle(v), name="ClusterAngle-1", unit_type="angle", root=u, + rotation_handle=v) + param.gcs = self def _delete_root_status(self, root_node: Point, rotation_handle_node: Point = None): """ @@ -768,12 +772,17 @@ def translate_cluster(self, root: Point, dx: float, dy: float): self.solve_other_constraints(points_solved) return points_solved - def rotate_cluster(self, rotation_handle: Point, new_rotation_handle_x: float, new_rotation_handle_y: float): + def rotate_cluster(self, rotation_handle: Point, new_rotation_handle_x: float = None, + new_rotation_handle_y: float = None, + new_rotation_angle: float = None): root = self._identify_root_from_rotation_handle(rotation_handle) if not root.root: raise ValueError("Cannot move a point that is not a root of a constraint cluster") old_rotation_handle_angle = root.measure_angle(rotation_handle) - new_rotation_handle_angle = root.measure_angle(Point(new_rotation_handle_x, new_rotation_handle_y)) + if new_rotation_angle is None: + new_rotation_handle_angle = root.measure_angle(Point(new_rotation_handle_x, new_rotation_handle_y)) + else: + new_rotation_handle_angle = new_rotation_angle delta_angle = new_rotation_handle_angle - old_rotation_handle_angle root_x = root.x().value() root_y = root.y().value() diff --git a/pymead/core/geometry_collection.py b/pymead/core/geometry_collection.py index f1c33630..a36a0bb6 100644 --- a/pymead/core/geometry_collection.py +++ b/pymead/core/geometry_collection.py @@ -40,6 +40,7 @@ def __init__(self, gui_obj=None): "dims": {}, } self.gcs = GCS() + self.gcs.geo_col = self self.gui_obj = gui_obj self.canvas = None self.tree = None @@ -174,7 +175,7 @@ def remove_from_subcontainer(self, pymead_obj: PymeadObj): def add_param(self, value: float, name: str or None = None, lower: float or None = None, upper: float or None = None, unit_type: str or None = None, assign_unique_name: bool = True, - point: Point = None): + point: Point = None, root: Point = None, rotation_handle: Point = None): """ Adds a parameter to the geometry collection sub-container ``"params"``, and modifies the name to make it unique if necessary. @@ -201,7 +202,8 @@ def add_param(self, value: float, name: str or None = None, lower: float or None Param The generated parameter """ - kwargs = dict(value=value, name=name, lower=lower, upper=upper, setting_from_geo_col=True, point=point) + kwargs = dict(value=value, name=name, lower=lower, upper=upper, setting_from_geo_col=True, point=point, + root=root, rotation_handle=rotation_handle) if unit_type is None: param = Param(**kwargs) elif unit_type == "length": @@ -347,7 +349,8 @@ def add_pymead_obj_by_ref(self, pymead_obj: PymeadObj, assign_unique_name: bool return pymead_obj - def remove_pymead_obj(self, pymead_obj: PymeadObj, promotion_demotion: bool = False): + def remove_pymead_obj(self, pymead_obj: PymeadObj, promotion_demotion: bool = False, + constraint_removal: bool = False): """ Removes a pymead object from the geometry collection. @@ -359,12 +362,28 @@ def remove_pymead_obj(self, pymead_obj: PymeadObj, promotion_demotion: bool = Fa promotion_demotion: bool When this flag is set to ``True``, the ``ValueError`` normally raised when directly deleting a ``Param`` associated with a ``GeoCon`` is ignored. Default: ``False`` + + constraint_removal: bool + When this flag is set to ``True``, the ``ValueError`` normally raise when directly deleting a ``Param`` + associated with a constraint cluster rotation is ignored. Default: ``False`` """ # Type-specific actions if isinstance(pymead_obj, Param): if len(pymead_obj.geo_cons) != 0 and not promotion_demotion: - raise ValueError(f"Please delete each constraint associated with this parameter ({pymead_obj.geo_cons})" - f" before deleting this parameter") + error_message = (f"Please delete each constraint associated with this parameter ({pymead_obj.geo_cons}) " + f"before deleting this parameter") + if self.gui_obj is None: + raise ValueError(error_message) + else: + self.gui_obj.disp_message_box(error_message, message_mode="error") + return + if pymead_obj.rotation_handle and not constraint_removal and not promotion_demotion: + error_message = f"This parameter can only be removed by deleting its associated constraint cluster" + if self.gui_obj is None: + raise ValueError(error_message) + else: + self.gui_obj.disp_message_box(error_message, message_mode="error") + return elif isinstance(pymead_obj, Bezier) or isinstance(pymead_obj, LineSegment): # Remove all the references to this curve in each of the curve's points @@ -380,8 +399,13 @@ def remove_pymead_obj(self, pymead_obj: PymeadObj, promotion_demotion: bool = Fa elif isinstance(pymead_obj, Point): if len(pymead_obj.geo_cons) != 0: - raise ValueError(f"Please delete each constraint associated with this point ({pymead_obj.geo_cons})" + error_message = (f"Please delete each constraint associated with this point ({pymead_obj.geo_cons})" f" before deleting this point") + if self.gui_obj is None: + raise ValueError(error_message) + else: + self.gui_obj.disp_message_box(error_message, message_mode="error") + return # Loop through the curves associated with this point to see which ones need to be deleted if one point # is removed from their point sequence @@ -432,6 +456,11 @@ def remove_pymead_obj(self, pymead_obj: PymeadObj, promotion_demotion: bool = Fa if len(pymead_obj.param().geo_cons) == 0: self.remove_pymead_obj(pymead_obj.param()) + if (isinstance(pymead_obj, DistanceConstraint) or isinstance(pymead_obj, RelAngle3Constraint) or + isinstance(pymead_obj, Perp3Constraint) or isinstance(pymead_obj, AntiParallel3Constraint)): + if pymead_obj.p2.rotation_handle: + self.remove_pymead_obj(pymead_obj.p2.rotation_param, constraint_removal=True) + # Remove the constraint from the ConstraintGraph self.gcs.remove_constraint(pymead_obj) @@ -489,7 +518,8 @@ def add_line(self, point_sequence: PointSequence, name: str or None = None, assi return self.add_pymead_obj_by_ref(line, assign_unique_name=assign_unique_name) def add_desvar(self, value: float, name: str, lower: float or None = None, upper: float or None = None, - unit_type: str or None = None, assign_unique_name: bool = True, point: Point = None): + unit_type: str or None = None, assign_unique_name: bool = True, point: Point = None, + root: Point = None, rotation_handle: Point = None): """ Directly adds a design variable value to the geometry collection. @@ -517,7 +547,8 @@ def add_desvar(self, value: float, name: str, lower: float or None = None, upper DesVar The generated design variable """ - kwargs = dict(value=value, name=name, lower=lower, upper=upper, setting_from_geo_col=True, point=point) + kwargs = dict(value=value, name=name, lower=lower, upper=upper, setting_from_geo_col=True, point=point, + root=root, rotation_handle=rotation_handle) if unit_type is None: desvar = DesVar(**kwargs) elif unit_type == "length": @@ -583,7 +614,8 @@ def promote_param_to_desvar(self, param: Param or str, lower: float or None = No unit_type = None desvar = self.add_desvar(value=param.value(), name=param.name(), lower=lower, upper=upper, unit_type=unit_type, - point=copy(param.point)) + point=copy(param.point), root=param.root, + rotation_handle=param.rotation_handle) # Replace the corresponding x() or y() in parameter with the new design variable self.replace_geo_objs(tool=param, target=desvar) @@ -630,7 +662,8 @@ def demote_desvar_to_param(self, desvar: DesVar): else: unit_type = None - param = self.add_param(value=desvar.value(), name=desvar.name(), unit_type=unit_type, point=copy(desvar.point)) + param = self.add_param(value=desvar.value(), name=desvar.name(), unit_type=unit_type, point=copy(desvar.point), + root=desvar.root, rotation_handle=desvar.rotation_handle) # Replace the corresponding x() or y() in parameter with the new parameter self.replace_geo_objs(tool=desvar, target=param) diff --git a/pymead/core/param.py b/pymead/core/param.py index a7cb292f..f8ff9481 100644 --- a/pymead/core/param.py +++ b/pymead/core/param.py @@ -1,5 +1,6 @@ import typing +import networkx import numpy as np from pymead.core import UNITS @@ -13,7 +14,8 @@ class Param(PymeadObj): """ def __init__(self, value: float or int, name: str, lower: float or None = None, upper: float or None = None, - sub_container: str = "params", setting_from_geo_col: bool = False, point=None): + sub_container: str = "params", setting_from_geo_col: bool = False, point=None, root=None, + rotation_handle=None): """ Parameters ========== @@ -37,6 +39,10 @@ def __init__(self, value: float or int, name: str, lower: float or None = None, self._upper = None self.at_boundary = False self.point = point + self.root = root + self.rotation_handle = rotation_handle + if rotation_handle is not None: + self.rotation_handle.rotation_param = self self.geo_objs = [] self.gcs = None self.geo_cons = [] @@ -105,6 +111,40 @@ def set_value(self, value: float or int, updated_objs: typing.List[PymeadObj] = 1 if the value is equal to the upper bound, or a float between 0.0 and 1.0 if the value is somewhere between the bounds). Default: ``False`` """ + def rotate_cluster(new_v): + if self.gcs is None: + return + points_to_update, root = self.gcs.rotate_cluster(self.rotation_handle, new_rotation_angle=new_v) + constraints_to_update = [] + for point in networkx.dfs_preorder_nodes(self.gcs, source=root): + 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() + + curves_to_update = [] + 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 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) + curve.update() + + for airfoil in airfoils_to_update: + airfoil.update_coords() + if airfoil.canvas_item is not None: + airfoil.canvas_item.generatePicture() + if bounds_normalized: if self.lower() is None or self.upper() is None: raise ValueError("Lower and upper bounds must be set to assign a bounds-normalized value.") @@ -122,6 +162,9 @@ def set_value(self, value: float or int, updated_objs: typing.List[PymeadObj] = self._value = value self.at_boundary = False + if self.rotation_handle is not None: + rotate_cluster(self._value) + if self.at_boundary: return @@ -255,13 +298,13 @@ def __pow__(self, power, modulo=None): class LengthParam(Param): def __init__(self, value: float, name: str, lower: float or None = None, upper: float or None = None, sub_container: str = "params", - setting_from_geo_col: bool = False, point=None): + setting_from_geo_col: bool = False, point=None, root=None, rotation_handle=None): self._unit = None self.set_unit(UNITS.current_length_unit()) name = "Length-1" if name is None else name super().__init__(value=value, name=name, lower=lower, upper=upper, sub_container=sub_container, setting_from_geo_col=setting_from_geo_col, - point=point) + point=point, root=root, rotation_handle=rotation_handle) def unit(self): return self._unit @@ -311,12 +354,13 @@ def get_dict_rep(self): class AngleParam(Param): def __init__(self, value: float, name: str, lower: float or None = None, upper: float or None = None, sub_container: str = "params", - setting_from_geo_col: bool = False, point=None): + setting_from_geo_col: bool = False, point=None, root=None, rotation_handle=None): self._unit = None self.set_unit(UNITS.current_angle_unit()) name = "Angle-1" if name is None else name super().__init__(value=value, name=name, lower=lower, upper=upper, sub_container=sub_container, - setting_from_geo_col=setting_from_geo_col, point=point) + setting_from_geo_col=setting_from_geo_col, point=point, root=root, + rotation_handle=rotation_handle) def unit(self): return self._unit @@ -426,7 +470,8 @@ class DesVar(Param): Design variable class; subclasses the base-level Param. Adds lower and upper bound default behavior. """ def __init__(self, value: float, name: str, lower: float or None = None, upper: float or None = None, - sub_container: str = "desvar", setting_from_geo_col: bool = False, point=None): + sub_container: str = "desvar", setting_from_geo_col: bool = False, point=None, root=None, + rotation_handle=None): """ Parameters ========== @@ -455,7 +500,8 @@ def __init__(self, value: float, name: str, lower: float or None = None, upper: upper = default_upper(value) super().__init__(value=value, name=name, lower=lower, upper=upper, sub_container=sub_container, - setting_from_geo_col=setting_from_geo_col, point=point) + setting_from_geo_col=setting_from_geo_col, point=point, root=root, + rotation_handle=rotation_handle) class LengthDesVar(LengthParam): @@ -464,7 +510,7 @@ class LengthDesVar(LengthParam): default behavior. """ def __init__(self, value: float, name: str, lower: float or None = None, upper: float or None = None, - setting_from_geo_col: bool = False, point=None): + setting_from_geo_col: bool = False, point=None, root=None, rotation_handle=None): """ Parameters ========== @@ -493,7 +539,7 @@ def __init__(self, value: float, name: str, lower: float or None = None, upper: upper = default_upper(value) super().__init__(value=value, name=name, lower=lower, upper=upper, setting_from_geo_col=setting_from_geo_col, - sub_container="desvar", point=point) + sub_container="desvar", point=point, root=root, rotation_handle=rotation_handle) def get_dict_rep(self): return {"value": float(self.value()), "lower": self.lower(), "upper": self.upper(), @@ -506,7 +552,7 @@ class AngleDesVar(AngleParam): default behavior. """ def __init__(self, value: float, name: str, lower: float or None = None, upper: float or None = None, - setting_from_geo_col: bool = False, point=None): + setting_from_geo_col: bool = False, point=None, root=None, rotation_handle=None): """ Parameters ========== @@ -535,7 +581,18 @@ def __init__(self, value: float, name: str, lower: float or None = None, upper: upper = default_upper(value) super().__init__(value=value, name=name, lower=lower, upper=upper, sub_container="desvar", - setting_from_geo_col=setting_from_geo_col, point=point) + setting_from_geo_col=setting_from_geo_col, point=point, root=root, + rotation_handle=rotation_handle) + + def set_value(self, value: float, updated_objs: typing.List[PymeadObj] = None, bounds_normalized: bool = False): + r""" + In this special case of ``set_value`` for an ``AngleDesVar``, we skip over the call to the ``set_value`` + method in ``AngleParam`` and directly call the ``set_value`` method in ``Param`` (the grandparent class). + The reason for this is that ``AngleParam`` always keeps the angle between 0 and :math:`2 \pi`, which is not + logical behavior for a bounded variable. This method eliminates that restriction. + """ + new_value = UNITS.convert_angle_to_base(value, self.unit()) + return Param.set_value(self, new_value, updated_objs=updated_objs, bounds_normalized=bounds_normalized) def get_dict_rep(self): return {"value": float(self.value()), "lower": self.lower(), "upper": self.upper(), diff --git a/pymead/core/point.py b/pymead/core/point.py index 1551c0a4..5feb6dfd 100644 --- a/pymead/core/point.py +++ b/pymead/core/point.py @@ -21,6 +21,7 @@ def __init__(self, x: float, y: float, name: str or None = None, setting_from_ge self.gcs = None self.root = False self.rotation_handle = False + self.rotation_param = None self.geo_cons = [] self.dims = [] self.curves = [] @@ -216,6 +217,7 @@ def request_move(self, xp: float, yp: float, force: bool = False): if not self.is_movement_allowed() and not force: return + points_to_update = None if self.root: points_to_update = self.gcs.translate_cluster(self, dx=xp - self.x().value(), dy=yp - self.y().value()) constraints_to_update = [] @@ -228,16 +230,7 @@ def request_move(self, xp: float, yp: float, force: bool = False): if geo_con.canvas_item is not None: geo_con.canvas_item.update() elif self.rotation_handle: - points_to_update, root = self.gcs.rotate_cluster(self, xp, yp) - constraints_to_update = [] - for point in networkx.dfs_preorder_nodes(self.gcs, source=root): - 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() + self.rotation_param.set_value(self.rotation_param.root.measure_angle(Point(xp, yp))) else: self.x().set_value(xp) self.y().set_value(yp) @@ -256,6 +249,9 @@ def request_move(self, xp: float, yp: float, force: bool = False): 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: if point.canvas_item is not None: