From 4daead1f47a9c621e754cbeed8af00c3e9e014cd Mon Sep 17 00:00:00 2001 From: Carlos Adir Date: Wed, 29 Oct 2025 21:53:16 +0100 Subject: [PATCH 1/3] make most geometric and bool2d classes as immutable --- docs/source/rst/primitive.rst | 6 +- src/shapepy/bool2d/boolean.py | 2 +- src/shapepy/bool2d/curve.py | 10 +- src/shapepy/bool2d/lazy.py | 33 ++-- src/shapepy/bool2d/point.py | 9 +- src/shapepy/bool2d/shape.py | 218 ++++++-------------------- src/shapepy/geometry/base.py | 69 +------- src/shapepy/geometry/jordancurve.py | 24 +-- src/shapepy/geometry/piecewise.py | 15 -- src/shapepy/geometry/point.py | 12 +- src/shapepy/geometry/segment.py | 22 +-- src/shapepy/geometry/transform.py | 113 +++++++++++++ src/shapepy/geometry/unparam.py | 15 +- src/shapepy/plot/plot.py | 2 +- tests/bool2d/test_shape.py | 10 +- tests/bool2d/test_transform.py | 18 +-- tests/geometry/test_jordan_polygon.py | 11 +- tests/geometry/test_point.py | 20 +-- tests/geometry/test_usegment.py | 7 +- tests/plot/test_plot.py | 3 +- 20 files changed, 226 insertions(+), 393 deletions(-) create mode 100644 src/shapepy/geometry/transform.py diff --git a/docs/source/rst/primitive.rst b/docs/source/rst/primitive.rst index 4ea04f9b..13fbc564 100644 --- a/docs/source/rst/primitive.rst +++ b/docs/source/rst/primitive.rst @@ -192,7 +192,7 @@ It's possible to invert the orientation of a shape. # Create any shape, positive at counter-clockwise circle = Primitive.circle() # Change orientation to clockwise, negative - circle.invert() + -circle |pic1| |pic2| @@ -203,10 +203,6 @@ It's possible to invert the orientation of a shape. .. |pic2| image:: ../img/primitive/negative_circle.svg :width: 49 % -.. note:: - - The ``invert`` function is available only in ``SimpleShape``. Use ``~shape`` for a inversion as general - ------------------------------------------------------------------------------------------ ------------------ diff --git a/src/shapepy/bool2d/boolean.py b/src/shapepy/bool2d/boolean.py index b398f50b..5d034e6e 100644 --- a/src/shapepy/bool2d/boolean.py +++ b/src/shapepy/bool2d/boolean.py @@ -162,7 +162,7 @@ def clean_bool2d_not(subset: LazyNot) -> SubSetR2: if Is.instance(inverted, SimpleShape): return SimpleShape(~inverted.jordan, True) if Is.instance(inverted, ConnectedShape): - return DisjointShape((~s).clean() for s in inverted.subshapes) + return DisjointShape((~s).clean() for s in inverted) if Is.instance(inverted, DisjointShape): return shape_from_jordans(~jordan for jordan in inverted.jordans) raise NotImplementedError(f"Missing typo: {type(inverted)}") diff --git a/src/shapepy/bool2d/curve.py b/src/shapepy/bool2d/curve.py index adfa4fb5..7bcac9f6 100644 --- a/src/shapepy/bool2d/curve.py +++ b/src/shapepy/bool2d/curve.py @@ -10,6 +10,7 @@ from ..geometry.base import IGeometricCurve from ..geometry.point import Point2D +from ..geometry.transform import move, rotate, scale from ..loggers import debug from ..scalar.angle import Angle from ..scalar.reals import Real @@ -64,16 +65,13 @@ def __hash__(self): return hash(self.internal.length) def move(self, vector: Point2D) -> SingleCurve: - self.__curve = self.__curve.move(vector) - return self + return SingleCurve(move(self.__curve, vector)) def scale(self, amount: Union[Real, Tuple[Real, Real]]) -> SingleCurve: - self.__curve = self.__curve.scale(amount) - return self + return SingleCurve(scale(self.__curve, amount)) def rotate(self, angle: Angle) -> SingleCurve: - self.__curve = self.__curve.rotate(angle) - return self + return SingleCurve(rotate(self.__curve, angle)) def density(self, center: Point2D) -> Density: return Density.zero diff --git a/src/shapepy/bool2d/lazy.py b/src/shapepy/bool2d/lazy.py index 0279db10..cf5983b1 100644 --- a/src/shapepy/bool2d/lazy.py +++ b/src/shapepy/bool2d/lazy.py @@ -124,16 +124,13 @@ def __eq__(self, other): ) def move(self, vector): - self.__internal.move(vector) - return self + return LazyNot(self.__internal.move(vector)) def scale(self, amount): - self.__internal.scale(amount) - return self + return LazyNot(self.__internal.scale(amount)) def rotate(self, angle): - self.__internal.rotate(angle) - return self + return LazyNot(self.__internal.rotate(angle)) def density(self, center): return ~self.__internal.density(center) @@ -178,19 +175,13 @@ def __eq__(self, other): ) def move(self, vector): - for subset in self: - subset.move(vector) - return self + return LazyOr(sub.move(vector) for sub in self) def scale(self, amount): - for subset in self: - subset.scale(amount) - return self + return LazyOr(sub.scale(amount) for sub in self) def rotate(self, angle): - for subset in self: - subset.rotate(angle) - return self + return LazyOr(sub.rotate(angle) for sub in self) def density(self, center): return unite_densities(sub.density(center) for sub in self) @@ -235,19 +226,13 @@ def __eq__(self, other): ) def move(self, vector): - for subset in self: - subset.move(vector) - return self + return LazyAnd(sub.move(vector) for sub in self) def scale(self, amount): - for subset in self: - subset.scale(amount) - return self + return LazyAnd(sub.scale(amount) for sub in self) def rotate(self, angle): - for subset in self: - subset.rotate(angle) - return self + return LazyAnd(sub.rotate(angle) for sub in self) def density(self, center): return intersect_densities(sub.density(center) for sub in self) diff --git a/src/shapepy/bool2d/point.py b/src/shapepy/bool2d/point.py index 0a68587e..1b8bd9f8 100644 --- a/src/shapepy/bool2d/point.py +++ b/src/shapepy/bool2d/point.py @@ -68,16 +68,13 @@ def __hash__(self): return hash((self.internal.xcoord, self.internal.ycoord)) def move(self, vector: Point2D) -> SinglePoint: - self.__point = self.__point.move(vector) - return self + return SinglePoint(copy(self.__point).move(vector)) def scale(self, amount: Union[Real, Tuple[Real, Real]]) -> SinglePoint: - self.__point = self.__point.scale(amount) - return self + return SinglePoint(copy(self.__point).scale(amount)) def rotate(self, angle: Angle) -> SinglePoint: - self.__point = self.__point.rotate(angle) - return self + return SinglePoint(copy(self.__point).rotate(angle)) def density(self, center: Point2D) -> Density: return Density.zero diff --git a/src/shapepy/bool2d/shape.py b/src/shapepy/bool2d/shape.py index e8ecf304..69f13178 100644 --- a/src/shapepy/bool2d/shape.py +++ b/src/shapepy/bool2d/shape.py @@ -10,11 +10,12 @@ from __future__ import annotations from copy import copy -from typing import Iterable, Iterator, Set, Tuple, Union +from typing import Iterable, Iterator, Tuple, Union from ..geometry.box import Box from ..geometry.jordancurve import JordanCurve from ..geometry.point import Point2D +from ..geometry.transform import move, rotate, scale from ..loggers import debug from ..scalar.angle import Angle from ..scalar.reals import Real @@ -41,9 +42,10 @@ class SimpleShape(SubSetR2): """ def __init__(self, jordancurve: JordanCurve, boundary: bool = True): - assert Is.jordan(jordancurve) + if not Is.instance(jordancurve, JordanCurve): + raise TypeError self.__jordancurve = jordancurve - self.boundary = boundary + self.__boundary = bool(boundary) def __copy__(self) -> SimpleShape: return self.__deepcopy__(None) @@ -79,10 +81,6 @@ def boundary(self) -> bool: """The flag that informs if the boundary is inside the Shape""" return self.__boundary - @boundary.setter - def boundary(self, value: bool): - self.__boundary = bool(value) - @property def jordan(self) -> JordanCurve: """Gives the jordan curve that defines the boundary""" @@ -102,37 +100,6 @@ def area(self) -> Real: def __hash__(self): return hash(self.area) - def invert(self) -> SimpleShape: - """ - Inverts the region of simple shape. - - Parameters - ---------- - - :return: The same instance - :rtype: SimpleShape - - Example use - ----------- - >>> from shapepy import Primitive - >>> square = Primitive.square() - >>> print(square) - Simple Shape of area 1.00 with vertices: - [[ 0.5 0.5] - [-0.5 0.5] - [-0.5 -0.5] - [ 0.5 -0.5]] - >>> square.invert() - Simple Shape of area -1.00 with vertices: - [[ 0.5 0.5] - [ 0.5 -0.5] - [-0.5 -0.5] - [-0.5 0.5]] - - """ - self.__jordancurve.invert() - return self - @debug("shapepy.bool2d.shape") def __contains__(self, other: SubSetR2) -> bool: if Is.instance(other, SinglePoint): @@ -182,17 +149,14 @@ def __contains_simple(self, other: SimpleShape) -> bool: # may happens error here return True - def move(self, vector: Point2D) -> JordanCurve: - self.__jordancurve = self.__jordancurve.move(vector) - return self + def move(self, vector: Point2D) -> SimpleShape: + return SimpleShape(move(self.jordan, vector), self.boundary) - def scale(self, amount: Union[Real, Tuple[Real, Real]]) -> JordanCurve: - self.__jordancurve = self.__jordancurve.scale(amount) - return self + def scale(self, amount: Union[Real, Tuple[Real, Real]]) -> SimpleShape: + return SimpleShape(scale(self.jordan, amount), self.boundary) - def rotate(self, angle: Angle) -> JordanCurve: - self.__jordancurve = self.__jordancurve.rotate(angle) - return self + def rotate(self, angle: Angle) -> SimpleShape: + return SimpleShape(rotate(self.jordan, angle), self.boundary) def box(self) -> Box: """ @@ -222,23 +186,24 @@ class ConnectedShape(SubSetR2): ConnectedShape Class A shape defined by intersection of two or more SimpleShapes - """ def __init__(self, subshapes: Iterable[SimpleShape]): - self.subshapes = subshapes + subshapes = frozenset(subshapes) + if not all(Is.instance(simple, SimpleShape) for simple in subshapes): + raise TypeError(f"Invalid typos: {tuple(map(type, subshapes))}") + self.__subshapes = subshapes def __copy__(self) -> ConnectedShape: return self.__deepcopy__(None) def __deepcopy__(self, memo) -> ConnectedShape: - simples = tuple(map(copy, self.subshapes)) - return ConnectedShape(simples) + return ConnectedShape(map(copy, self)) @property def area(self) -> Real: """The internal area that is enclosed by the shape""" - return sum(simple.area for simple in self.subshapes) + return sum(simple.area for simple in self) def __str__(self) -> str: # pragma: no cover # For debug return f"Connected shape total area {self.area}" @@ -249,7 +214,7 @@ def __eq__(self, other: SubSetR2) -> bool: Is.instance(other, ConnectedShape) and hash(self) == hash(other) and self.area == other.area - and self.subshapes == other.subshapes + and frozenset(self) == frozenset(other) ) @debug("shapepy.bool2d.shape") @@ -257,7 +222,7 @@ def __hash__(self): return hash(self.area) def __iter__(self) -> Iterator[SimpleShape]: - yield from self.subshapes + yield from self.__subshapes @property def jordans(self) -> Tuple[JordanCurve, ...]: @@ -266,62 +231,18 @@ def jordans(self) -> Tuple[JordanCurve, ...]: :getter: Returns a set of jordan curves :type: tuple[JordanCurve] """ - return tuple(shape.jordan for shape in self.subshapes) + return tuple(shape.jordan for shape in self) - @property - def subshapes(self) -> Set[SimpleShape]: - """ - Subshapes that defines the connected shape - - :getter: Subshapes that defines connected shape - :setter: Subshapes that defines connected shape - :type: tuple[SimpleShape] - - Example use - ----------- - >>> from shapepy import Primitive - >>> big_square = Primitive.square(side = 2) - >>> small_square = Primitive.square(side = 1) - >>> shape = big_square - small_square - >>> for subshape in shape.subshapes: - print(subshape) - Simple Shape of area 4.00 with vertices: - [[ 1. 1.] - [-1. 1.] - [-1. -1.] - [ 1. -1.]] - Simple Shape of area -1.00 with vertices: - [[ 0.5 0.5] - [ 0.5 -0.5] - [-0.5 -0.5] - [-0.5 0.5]] - - """ - return self.__subshapes - - @subshapes.setter - def subshapes(self, simples: Iterable[SimpleShape]): - simples = frozenset(simples) - if not all(Is.instance(simple, SimpleShape) for simple in simples): - raise TypeError(f"Invalid typos: {tuple(map(type, simples))}") - self.__subshapes = simples - - def move(self, vector: Point2D) -> JordanCurve: + def move(self, vector: Point2D) -> ConnectedShape: vector = To.point(vector) - for subshape in self.subshapes: - subshape.move(vector) - return self + return ConnectedShape(sub.move(vector) for sub in self) - def scale(self, amount: Union[Real, Tuple[Real, Real]]) -> JordanCurve: - for subshape in self.subshapes: - subshape.scale(amount) - return self + def scale(self, amount: Union[Real, Tuple[Real, Real]]) -> ConnectedShape: + return ConnectedShape(sub.scale(amount) for sub in self) - def rotate(self, angle: Angle) -> JordanCurve: + def rotate(self, angle: Angle) -> ConnectedShape: angle = To.angle(angle) - for subshape in self.subshapes: - subshape.rotate(angle) - return self + return ConnectedShape(sub.rotate(angle) for sub in self) def box(self) -> Box: """ @@ -341,13 +262,13 @@ def box(self) -> Box: Box with vertices (-1.0, -1.0) and (1., 1.0) """ box = None - for sub in self.subshapes: + for sub in self: box |= sub.jordan.box() return box def density(self, center: Point2D) -> Density: center = To.point(center) - densities = (sub.density(center) for sub in self.subshapes) + densities = (sub.density(center) for sub in self) return intersect_densities(densities) @@ -362,22 +283,26 @@ class DisjointShape(SubSetR2): def __init__( self, subshapes: Iterable[Union[SimpleShape, ConnectedShape]] ): - self.subshapes = subshapes + subshapes = frozenset(subshapes) + if not all( + Is.instance(s, (SimpleShape, ConnectedShape)) for s in subshapes + ): + raise ValueError(f"Invalid typos: {tuple(map(type, subshapes))}") + self.__subshapes = subshapes def __copy__(self) -> ConnectedShape: return self.__deepcopy__(None) def __deepcopy__(self, memo): - subshapes = tuple(map(copy, self.subshapes)) - return DisjointShape(subshapes) + return DisjointShape(map(copy, self)) def __iter__(self) -> Iterator[Union[SimpleShape, ConnectedShape]]: - yield from self.subshapes + yield from self.__subshapes @property def area(self) -> Real: """The internal area that is enclosed by the shape""" - return sum(sub.area for sub in self.subshapes) + return sum(sub.area for sub in self) @property def jordans(self) -> Tuple[JordanCurve, ...]: @@ -387,7 +312,7 @@ def jordans(self) -> Tuple[JordanCurve, ...]: :type: tuple[JordanCurve] """ jordans = [] - for subshape in self.subshapes: + for subshape in self: jordans += list(subshape.jordans) return tuple(jordans) @@ -397,74 +322,28 @@ def __eq__(self, other: SubSetR2): Is.instance(other, DisjointShape) and hash(self) == hash(other) and self.area == other.area - and self.subshapes == other.subshapes + and frozenset(self) == frozenset(other) ) def __str__(self) -> str: # pragma: no cover # For debug msg = f"Disjoint shape with total area {self.area} and " - msg += f"{len(self.subshapes)} subshapes" + msg += f"{len(self.__subshapes)} subshapes" return msg @debug("shapepy.bool2d.shape") def __hash__(self): return hash(self.area) - @property - def subshapes(self) -> Set[Union[SimpleShape, ConnectedShape]]: - """ - Subshapes that defines the disjoint shape - - :getter: Subshapes that defines disjoint shape - :setter: Subshapes that defines disjoint shape - :type: tuple[SimpleShape | ConnectedShape] - - Example use - ----------- - >>> from shapepy import Primitive - >>> left = Primitive.square(center=(-2, 0)) - >>> right = Primitive.square(center = (2, 0)) - >>> shape = left | right - >>> for subshape in shape.subshapes: - print(subshape) - Simple Shape of area 1.00 with vertices: - [[-1.5 0.5] - [-2.5 0.5] - [-2.5 -0.5] - [-1.5 -0.5]] - Simple Shape of area 1.00 with vertices: - [[ 2.5 0.5] - [ 1.5 0.5] - [ 1.5 -0.5] - [ 2.5 -0.5]] - - """ - return self.__subshapes - - @subshapes.setter - def subshapes(self, values: Iterable[SubSetR2]): - values = frozenset(values) - if not all( - Is.instance(sub, (SimpleShape, ConnectedShape)) for sub in values - ): - raise ValueError(f"Invalid typos: {tuple(map(type, values))}") - self.__subshapes = values - - def move(self, vector: Point2D) -> JordanCurve: + def move(self, vector: Point2D) -> DisjointShape: vector = To.point(vector) - for subshape in self.subshapes: - subshape.move(vector) - return self + return DisjointShape(sub.move(vector) for sub in self) - def scale(self, amount: Union[Real, Tuple[Real, Real]]) -> JordanCurve: - for subshape in self.subshapes: - subshape.scale(amount) - return self + def scale(self, amount: Union[Real, Tuple[Real, Real]]) -> DisjointShape: + return DisjointShape(sub.scale(amount) for sub in self) - def rotate(self, angle: Angle) -> JordanCurve: + def rotate(self, angle: Angle) -> DisjointShape: angle = To.angle(angle) - for subshape in self.subshapes: - subshape.rotate(angle) - return self + return DisjointShape(sub.rotate(angle) for sub in self) def box(self) -> Box: """ @@ -484,11 +363,10 @@ def box(self) -> Box: Box with vertices (-1.0, -1.0) and (1., 1.0) """ box = None - for sub in self.subshapes: + for sub in self: box |= sub.box() return box def density(self, center: Point2D) -> Real: center = To.point(center) - densities = (sub.density(center) for sub in self.subshapes) - return unite_densities(densities) + return unite_densities((sub.density(center) for sub in self)) diff --git a/src/shapepy/geometry/base.py b/src/shapepy/geometry/base.py index 817fc939..fc1dc92a 100644 --- a/src/shapepy/geometry/base.py +++ b/src/shapepy/geometry/base.py @@ -5,9 +5,8 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import Iterable, Tuple, Union +from typing import Iterable, Tuple -from ..scalar.angle import Angle from ..scalar.reals import Real from .box import Box from .point import Point2D @@ -67,72 +66,6 @@ def parametrize(self) -> IParametrizedCurve: def __or__(self, other: IGeometricCurve) -> IGeometricCurve: return Future.concatenate((self, other)) - def move(self, vector: Point2D) -> IGeometricCurve: - """ - Moves/translate entire shape by an amount - - Parameters - ---------- - - point : Point2D - The amount to move - - :return: The same instance - :rtype: SubSetR2 - - Example use - ----------- - >>> from shapepy import Primitive - >>> circle = Primitive.circle() - >>> circle.move(1, 2) - - """ - raise NotImplementedError - - def scale(self, amount: Union[Real, Tuple[Real, Real]]) -> IGeometricCurve: - """ - Scales entire subset by an amount - - Parameters - ---------- - - amount : Real | Tuple[Real, Real] - The amount to scale in horizontal and vertical direction - - :return: The same instance - :rtype: SubSetR2 - - Example use - ----------- - >>> from shapepy import Primitive - >>> circle = Primitive.circle() - >>> circle.scale(2, 3) - - """ - raise NotImplementedError - - def rotate(self, angle: Angle) -> IGeometricCurve: - """ - Rotates entire shape around the origin by an amount - - Parameters - ---------- - - angle : Angle - The amount to rotate around origin - - :return: The same instance - :rtype: SubSetR2 - - Example use - ----------- - >>> from shapepy import Primitive - >>> circle = Primitive.circle() - >>> circle.rotate(degrees(90)) - - """ - raise NotImplementedError - class IParametrizedCurve(IGeometricCurve): """ diff --git a/src/shapepy/geometry/jordancurve.py b/src/shapepy/geometry/jordancurve.py index 4e9f13d2..b27ed0e6 100644 --- a/src/shapepy/geometry/jordancurve.py +++ b/src/shapepy/geometry/jordancurve.py @@ -7,16 +7,16 @@ from collections import deque from copy import copy -from typing import Iterable, Iterator, Tuple, Union +from typing import Iterable, Iterator from ..loggers import debug -from ..scalar.angle import Angle from ..scalar.reals import Real from ..tools import CyclicContainer, Is, pairs, reverse from .base import IGeometricCurve from .box import Box from .piecewise import PiecewiseCurve from .point import Point2D +from .segment import Segment from .unparam import UPiecewiseCurve, USegment, clean_usegment, self_intersect @@ -37,21 +37,6 @@ def __deepcopy__(self, memo) -> JordanCurve: """Returns a deep copy of the jordan curve""" return self.__class__(map(copy, self.usegments)) - @debug("shapepy.geometry.jordancurve") - def move(self, vector: Point2D) -> JordanCurve: - self.__usegments = tuple(useg.move(vector) for useg in self.usegments) - return self - - @debug("shapepy.geometry.jordancurve") - def scale(self, amount: Union[Real, Tuple[Real, Real]]) -> JordanCurve: - self.__usegments = tuple(useg.scale(amount) for useg in self.usegments) - return self - - @debug("shapepy.geometry.jordancurve") - def rotate(self, angle: Angle) -> JordanCurve: - self.__usegments = tuple(useg.rotate(angle) for useg in self.usegments) - return self - def box(self) -> Box: """The box which encloses the jordan curve @@ -147,8 +132,11 @@ def vertices(self) -> Iterator[Point2D]: @usegments.setter def usegments(self, other: Iterable[USegment]): usegments = tuple(other) - if not all(Is.instance(u, USegment) for u in usegments): + if not all(Is.instance(u, (USegment, Segment)) for u in usegments): raise ValueError(f"Invalid usegments: {tuple(map(type, other))}") + usegments = tuple( + USegment(s) if Is.instance(s, Segment) else s for s in usegments + ) if any(map(self_intersect, usegments)): raise ValueError("Segment must not self intersect") for usegi, usegj in pairs(usegments, cyclic=True): diff --git a/src/shapepy/geometry/piecewise.py b/src/shapepy/geometry/piecewise.py index 89587031..094a30ba 100644 --- a/src/shapepy/geometry/piecewise.py +++ b/src/shapepy/geometry/piecewise.py @@ -8,7 +8,6 @@ from typing import Iterable, Tuple, Union from ..loggers import debug -from ..scalar.angle import Angle from ..scalar.reals import Real from ..tools import Is, To, vectorize from .base import IParametrizedCurve @@ -159,20 +158,6 @@ def __contains__(self, point: Point2D) -> bool: """Tells if the point is on the boundary""" return any(point in bezier for bezier in self) - def move(self, vector: Point2D) -> PiecewiseCurve: - vector = To.point(vector) - self.__segments = tuple(seg.move(vector) for seg in self) - return self - - def scale(self, amount: Union[Real, Tuple[Real, Real]]) -> Segment: - self.__segments = tuple(seg.scale(amount) for seg in self) - return self - - def rotate(self, angle: Angle) -> Segment: - angle = To.angle(angle) - self.__segments = tuple(seg.rotate(angle) for seg in self) - return self - def is_piecewise(obj: object) -> bool: """ diff --git a/src/shapepy/geometry/point.py b/src/shapepy/geometry/point.py index 102e972d..0ef3d130 100644 --- a/src/shapepy/geometry/point.py +++ b/src/shapepy/geometry/point.py @@ -167,9 +167,7 @@ def move(self, vector: tuple[Real, Real]) -> Point2D: The moved point """ vector = To.point(vector) - self.__xcoord += vector[0] - self.__ycoord += vector[1] - return self + return cartesian(self.xcoord + vector[0], self.ycoord + vector[1]) def scale(self, amount: Union[Real, Tuple[Real, Real]]) -> Point2D: """ @@ -188,9 +186,7 @@ def scale(self, amount: Union[Real, Tuple[Real, Real]]) -> Point2D: The scaled point """ xscale, yscale = (amount, amount) if Is.real(amount) else amount - self.__xcoord *= xscale - self.__ycoord *= yscale - return self + return cartesian(xscale * self.xcoord, yscale * self.ycoord) def rotate(self, angle: Angle) -> Point2D: """ @@ -211,9 +207,7 @@ def rotate(self, angle: Angle) -> Point2D: sin_angle = angle.sin() x_new = self[0] * cos_angle - self[1] * sin_angle y_new = self[0] * sin_angle + self[1] * cos_angle - self.__xcoord = x_new - self.__ycoord = y_new - return self + return cartesian(x_new, y_new) def inner(pointa: Point2D, pointb: Point2D) -> Real: diff --git a/src/shapepy/geometry/segment.py b/src/shapepy/geometry/segment.py index 4156f30b..9d888eb8 100644 --- a/src/shapepy/geometry/segment.py +++ b/src/shapepy/geometry/segment.py @@ -13,13 +13,12 @@ from __future__ import annotations from copy import copy -from typing import Iterable, Optional, Tuple, Union +from typing import Iterable, Optional, Tuple from ..analytic.base import IAnalytic from ..analytic.tools import find_minimum from ..loggers import debug from ..rbool import IntervalR1, from_any -from ..scalar.angle import Angle from ..scalar.quadrature import AdaptativeIntegrator, IntegratorFactory from ..scalar.reals import Math, Real from ..tools import Is, To, pairs, vectorize @@ -162,25 +161,6 @@ def extract(self, interval: IntervalR1) -> Segment: nyfunc = copy(self.yfunc).shift(-knota).scale(denom) return Segment(nxfunc, nyfunc) - def move(self, vector: Point2D) -> Segment: - vector = To.point(vector) - self.__xfunc += vector.xcoord - self.__yfunc += vector.ycoord - return self - - def scale(self, amount: Union[Real, Tuple[Real, Real]]) -> Segment: - self.__xfunc *= amount if Is.real(amount) else amount[0] - self.__yfunc *= amount if Is.real(amount) else amount[1] - return self - - def rotate(self, angle: Angle) -> Segment: - angle = To.angle(angle) - cos, sin = angle.cos(), angle.sin() - xfunc, yfunc = self.xfunc, self.yfunc - self.__xfunc = xfunc * cos - yfunc * sin - self.__yfunc = xfunc * sin + yfunc * cos - return self - @debug("shapepy.geometry.segment") def compute_length(segment: Segment) -> Real: diff --git a/src/shapepy/geometry/transform.py b/src/shapepy/geometry/transform.py new file mode 100644 index 00000000..21e09742 --- /dev/null +++ b/src/shapepy/geometry/transform.py @@ -0,0 +1,113 @@ +"""Contains some functions that are able to transform the curves, +doing operations such as moving, scaling or rotating""" + +from typing import Tuple, Union + +from ..scalar.angle import Angle +from ..scalar.reals import Real +from ..tools import Is, NotExpectedError, To +from .base import IGeometricCurve, IParametrizedCurve +from .piecewise import PiecewiseCurve +from .point import Point2D +from .segment import Segment + + +def move(curve: IGeometricCurve, vector: Point2D) -> IGeometricCurve: + """ + Moves/translate entire shape by an amount + + Parameters + ---------- + + point : Point2D + The amount to move + + :return: The same instance + :rtype: SubSetR2 + + Example use + ----------- + >>> from shapepy import Primitive + >>> circle = Primitive.circle() + >>> circle.move(1, 2) + + """ + vector = To.point(vector) + if Is.instance(curve, Segment): + newxfunc = curve.xfunc + vector.xcoord + newyfunc = curve.yfunc + vector.ycoord + return Segment(newxfunc, newyfunc) + if Is.instance(curve, PiecewiseCurve): + newsegs = (move(seg, vector) for seg in curve) + return PiecewiseCurve(newsegs, curve.knots) + if not Is.instance(curve, IParametrizedCurve): + return curve.__class__(move(curve.parametrize(), vector)) + raise NotExpectedError(f"Invalid typo: {type(curve)}") + + +def scale( + curve: IGeometricCurve, amount: Union[Real, Tuple[Real, Real]] +) -> IGeometricCurve: + """ + Scales entire subset by an amount + + Parameters + ---------- + + amount : Real | Tuple[Real, Real] + The amount to scale in horizontal and vertical direction + + :return: The same instance + :rtype: SubSetR2 + + Example use + ----------- + >>> from shapepy import Primitive + >>> circle = Primitive.circle() + >>> circle.scale(2, 3) + + """ + if Is.instance(curve, Segment): + newxfunc = curve.xfunc * (amount if Is.real(amount) else amount[0]) + newyfunc = curve.yfunc * (amount if Is.real(amount) else amount[1]) + return Segment(newxfunc, newyfunc) + if Is.instance(curve, PiecewiseCurve): + newsegs = (scale(seg, amount) for seg in curve) + return PiecewiseCurve(newsegs, curve.knots) + if not Is.instance(curve, IParametrizedCurve): + return curve.__class__(scale(curve.parametrize(), amount)) + raise NotExpectedError(f"Invalid typo: {type(curve)}") + + +def rotate(curve: IGeometricCurve, angle: Angle) -> IGeometricCurve: + """ + Rotates entire curve around the origin by given angle + + Parameters + ---------- + + angle : Angle + The amount to rotate around origin + + :return: The same instance + :rtype: SubSetR2 + + Example use + ----------- + >>> from shapepy import Primitive + >>> circle = Primitive.circle() + >>> circle.rotate(degrees(90)) + + """ + angle = To.angle(angle) + if Is.instance(curve, Segment): + cos, sin = angle.cos(), angle.sin() + newxfunc = cos * curve.xfunc - sin * curve.yfunc + newyfunc = sin * curve.xfunc + cos * curve.yfunc + return Segment(newxfunc, newyfunc) + if Is.instance(curve, PiecewiseCurve): + newsegs = (rotate(seg, angle) for seg in curve) + return PiecewiseCurve(newsegs, curve.knots) + if not Is.instance(curve, IParametrizedCurve): + return curve.__class__(rotate(curve.parametrize(), angle)) + raise NotExpectedError(f"Invalid typo: {type(curve)}") diff --git a/src/shapepy/geometry/unparam.py b/src/shapepy/geometry/unparam.py index 6a34c41a..ff04792c 100644 --- a/src/shapepy/geometry/unparam.py +++ b/src/shapepy/geometry/unparam.py @@ -5,9 +5,8 @@ from __future__ import annotations from copy import copy -from typing import Iterable, Tuple, Union +from typing import Iterable -from ..scalar.angle import Angle from ..scalar.reals import Real from ..tools import Is from .base import IGeometricCurve @@ -77,18 +76,6 @@ def invert(self) -> USegment: self.__segment = self.__segment.invert() return self - def move(self, vector: Point2D) -> Segment: - self.__segment.move(vector) - return self - - def scale(self, amount: Union[Real, Tuple[Real, Real]]) -> Segment: - self.__segment.scale(amount) - return self - - def rotate(self, angle: Angle) -> Segment: - self.__segment.rotate(angle) - return self - class UPiecewiseCurve(IGeometricCurve): """Equivalent to PiecewiseCurve, but ignores the parametrization""" diff --git a/src/shapepy/plot/plot.py b/src/shapepy/plot/plot.py index 7876797f..f0472db9 100644 --- a/src/shapepy/plot/plot.py +++ b/src/shapepy/plot/plot.py @@ -165,7 +165,7 @@ def plot_subset(self, shape: SubSetR2, *, kwargs): alpha = kwargs.pop("alpha") marker = kwargs.pop("marker") connecteds = ( - shape.subshapes if Is.instance(shape, DisjointShape) else [shape] + list(shape) if Is.instance(shape, DisjointShape) else [shape] ) for connected in connecteds: path = path_shape(connected) diff --git a/tests/bool2d/test_shape.py b/tests/bool2d/test_shape.py index 3dd9e70a..e56cc90a 100644 --- a/tests/bool2d/test_shape.py +++ b/tests/bool2d/test_shape.py @@ -38,8 +38,7 @@ def test_begin(self): def test_centered_rectangular(self): width, height = 6, 10 # sides of rectangle nx, ny = 5, 5 # Max exponential - rectangular = Primitive.square() - rectangular.scale((width, height)) + rectangular = Primitive.square().scale((width, height)) for expx in range(nx): for expy in range(ny): test = IntegrateJordan.polynomial( @@ -66,9 +65,7 @@ def test_noncenter_rectangular(self): width, height = 6, 10 # sides of rectangle center = 7, -3 # Center of shape nx, ny = 5, 5 # Max exponential - rectangular = Primitive.square() - rectangular.scale((width, height)) - rectangular.move(center) + rectangular = Primitive.square().scale((width, height)).move(center) for expx in range(nx): for expy in range(ny): test = IntegrateJordan.polynomial( @@ -94,8 +91,7 @@ def test_noncenter_rectangular(self): ) def test_centered_rombo(self): width, height = 3, 5 - rombo = Primitive.regular_polygon(4) - rombo.scale((width, height)) + rombo = Primitive.regular_polygon(4).scale((width, height)) nx, ny = 5, 5 for expx in range(nx): for expy in range(ny): diff --git a/tests/bool2d/test_transform.py b/tests/bool2d/test_transform.py index cc5fb2ff..cd26e599 100644 --- a/tests/bool2d/test_transform.py +++ b/tests/bool2d/test_transform.py @@ -26,7 +26,7 @@ def test_move_simple(): square = Primitive.square(2) assert square.box() == Box((-1, -1), (1, 1)) - square.move((1, 1)) + square = square.move((1, 1)) assert square.box() == Box((0, 0), (2, 2)) @@ -36,7 +36,7 @@ def test_scale_simple(): square = Primitive.square(2) assert square.box() == Box((-1, -1), (1, 1)) - square.scale((3, 4)) + square = square.scale((3, 4)) assert square.box() == Box((-3, -4), (3, 4)) @@ -47,7 +47,7 @@ def test_rotate_simple(): assert square.box() == Box((-1, -1), (1, 1)) angle = degrees(90) - square.rotate(angle) + square = square.rotate(angle) assert square.box() == Box((-1, -1), (1, 1)) @@ -59,7 +59,7 @@ def test_move_connected(): connected = ConnectedShape([big_square, -small_square]) assert connected.box() == Box((-2, -2), (2, 2)) - connected.move((2, 2)) + connected = connected.move((2, 2)) assert connected.box() == Box((0, 0), (4, 4)) @@ -71,7 +71,7 @@ def test_scale_connected(): connected = ConnectedShape([big_square, -small_square]) assert connected.box() == Box((-2, -2), (2, 2)) - connected.scale((3, 4)) + connected = connected.scale((3, 4)) assert connected.box() == Box((-6, -8), (6, 8)) @@ -84,7 +84,7 @@ def test_rotate_connected(): assert connected.box() == Box((-2, -2), (2, 2)) angle = degrees(90) - connected.rotate(angle) + connected = connected.rotate(angle) assert connected.box() == Box((-2, -2), (2, 2)) @@ -96,7 +96,7 @@ def test_move_disjoint(): disjoint = DisjointShape([left_square, right_square]) assert disjoint.box() == Box((-3, -1), (3, 1)) - disjoint.move((2, 2)) + disjoint = disjoint.move((2, 2)) assert disjoint.box() == Box((-1, 1), (5, 3)) @@ -108,7 +108,7 @@ def test_scale_disjoint(): disjoint = DisjointShape([left_square, right_square]) assert disjoint.box() == Box((-3, -1), (3, 1)) - disjoint.scale((3, 4)) + disjoint = disjoint.scale((3, 4)) assert disjoint.box() == Box((-9, -4), (9, 4)) @@ -121,7 +121,7 @@ def test_rotate_disjoint(): assert disjoint.box() == Box((-3, -1), (3, 1)) angle = degrees(90) - disjoint.rotate(angle) + disjoint = disjoint.rotate(angle) assert disjoint.box() == Box((-1, -3), (1, 3)) diff --git a/tests/geometry/test_jordan_polygon.py b/tests/geometry/test_jordan_polygon.py index 27fbc29c..b5ddbd58 100644 --- a/tests/geometry/test_jordan_polygon.py +++ b/tests/geometry/test_jordan_polygon.py @@ -9,6 +9,7 @@ from shapepy.geometry.factory import FactoryJordan from shapepy.geometry.jordancurve import clean_jordan +from shapepy.geometry.transform import move, rotate, scale from shapepy.scalar.angle import degrees, radians @@ -235,8 +236,8 @@ def test_move(self): test_square_pts = [(0, 0), (1, 0), (1, 1), (0, 1)] test_square = FactoryJordan.polygon(test_square_pts) - test_square.move((1, 2)) - test_square.move((1, 2)) + test_square = move(test_square, (1, 2)) + test_square = move(test_square, (1, 2)) assert test_square == good_square @@ -253,7 +254,7 @@ def test_scale(self): good_rectangle = FactoryJordan.polygon(good_rectangle_pts) test_rectangle_pts = [(0, 0), (1, 0), (1, 1), (0, 1)] test_rectangle = FactoryJordan.polygon(test_rectangle_pts) - test_rectangle.scale((2, 3)) + test_rectangle = scale(test_rectangle, (2, 3)) assert test_rectangle == good_rectangle @pytest.mark.order(15) @@ -272,9 +273,9 @@ def test_rotate(self): test_square = FactoryJordan.polygon(test_square_pts) assert test_square == good_square - test_square.rotate(radians(np.pi / 6)) # 30 degrees + test_square = rotate(test_square, radians(np.pi / 6)) # 30 degrees assert test_square != good_square - test_square.rotate(degrees(60)) + test_square = rotate(test_square, degrees(60)) assert test_square == good_square @pytest.mark.order(15) diff --git a/tests/geometry/test_point.py b/tests/geometry/test_point.py index a45dfef9..d034bb7e 100644 --- a/tests/geometry/test_point.py +++ b/tests/geometry/test_point.py @@ -267,20 +267,22 @@ def test_addsub(): def test_transformations(): pointa = cartesian(0, 0) pointb = pointa.move((1, 3)) - assert id(pointb) == id(pointa) - assert pointa == (1, 3) + assert id(pointb) != id(pointa) + assert pointb == (1, 3) + pointa = cartesian(1, 3) pointb = pointa.scale(2) - assert id(pointb) == id(pointa) - assert pointa == (2, 6) - pointb = pointa.scale((5, 3)) - assert id(pointb) == id(pointa) - assert pointa == (10, 18) + assert id(pointb) != id(pointa) + assert pointb == (2, 6) + pointc = pointb.scale((5, 3)) + assert id(pointc) != id(pointb) + assert pointc == (10, 18) angle = degrees(90) + pointa = cartesian(10, 18) pointb = pointa.rotate(angle) - assert id(pointb) == id(pointa) - assert pointa == (-18, 10) + assert id(pointb) != id(pointa) + assert pointb == (-18, 10) @pytest.mark.order(11) diff --git a/tests/geometry/test_usegment.py b/tests/geometry/test_usegment.py index a20b089c..e3222a37 100644 --- a/tests/geometry/test_usegment.py +++ b/tests/geometry/test_usegment.py @@ -6,6 +6,7 @@ from shapepy.geometry.box import Box from shapepy.geometry.factory import FactorySegment +from shapepy.geometry.transform import move, rotate, scale from shapepy.geometry.unparam import USegment from shapepy.scalar.angle import degrees @@ -54,7 +55,7 @@ def test_box(): def test_move(): segment = FactorySegment.bezier([(0, 0), (3, 4)]) usegment = USegment(segment) - usegment.move((1, 2)) + usegment = move(usegment, (1, 2)) good = FactorySegment.bezier([(1, 2), (4, 6)]) assert usegment.parametrize() == good @@ -66,7 +67,7 @@ def test_move(): def test_scale(): segment = FactorySegment.bezier([(0, 0), (3, 4)]) usegment = USegment(segment) - usegment.scale(4) + usegment = scale(usegment, 4) good = FactorySegment.bezier([(0, 0), (12, 16)]) assert usegment.parametrize() == good @@ -78,7 +79,7 @@ def test_scale(): def test_rotate(): segment = FactorySegment.bezier([(0, 0), (3, 4)]) usegment = USegment(segment) - usegment.rotate(degrees(90)) + usegment = rotate(usegment, degrees(90)) good = FactorySegment.bezier([(0, 0), (-4, 3)]) assert usegment.parametrize() == good diff --git a/tests/plot/test_plot.py b/tests/plot/test_plot.py index d9371f4a..ef02478a 100644 --- a/tests/plot/test_plot.py +++ b/tests/plot/test_plot.py @@ -54,10 +54,9 @@ def test_simple(self): plt.plot(circle, fill_color="cyan") plt.plot(square, fill_color="yellow") - circle.invert() fig, ax = pyplot.subplots() plt = ShapePloter(fig=fig, ax=ax) - plt.plot(circle) + plt.plot(-circle) # plt.show() From 42e4b977d7e08d78130675e58432f718401b297f Mon Sep 17 00:00:00 2001 From: Carlos Adir Date: Wed, 29 Oct 2025 22:15:48 +0100 Subject: [PATCH 2/3] make jordan curve immutable --- src/shapepy/geometry/integral.py | 4 +- src/shapepy/geometry/jordancurve.py | 114 +++++++++----------------- src/shapepy/geometry/segment.py | 8 +- src/shapepy/geometry/unparam.py | 17 ++-- src/shapepy/plot/plot.py | 4 +- tests/geometry/test_jordan_polygon.py | 4 +- 6 files changed, 58 insertions(+), 93 deletions(-) diff --git a/src/shapepy/geometry/integral.py b/src/shapepy/geometry/integral.py index 8ed81ba9..20830b25 100644 --- a/src/shapepy/geometry/integral.py +++ b/src/shapepy/geometry/integral.py @@ -87,7 +87,7 @@ def polynomial(jordan: JordanCurve, expx: int, expy: int): assert Is.jordan(jordan) return sum( IntegrateSegment.polynomial(usegment.parametrize(), expx, expy) - for usegment in jordan.usegments + for usegment in jordan ) @staticmethod @@ -107,7 +107,7 @@ def turns(jordan: JordanCurve, point: Point2D) -> float: It happens when the functions is discontinuous """ result = 0 - for usegment in jordan.usegments: + for usegment in jordan: seg = usegment.parametrize() delta_result = IntegrateSegment.turns(seg, point) if delta_result == 0.5: diff --git a/src/shapepy/geometry/jordancurve.py b/src/shapepy/geometry/jordancurve.py index b27ed0e6..4c96f4d3 100644 --- a/src/shapepy/geometry/jordancurve.py +++ b/src/shapepy/geometry/jordancurve.py @@ -27,15 +27,27 @@ class JordanCurve(IGeometricCurve): """ def __init__(self, usegments: Iterable[USegment]): + usegments = tuple(usegments) + if not all(Is.instance(u, (USegment, Segment)) for u in usegments): + raise ValueError(f"Invalid tipos: {tuple(map(type, usegments))}") + usegments = tuple( + USegment(s) if Is.instance(s, Segment) else s for s in usegments + ) + if any(map(self_intersect, usegments)): + raise ValueError("Segment must not self intersect") + for usegi, usegj in pairs(usegments, cyclic=True): + if usegi.end_point != usegj.start_point: + raise ValueError("The segments are not continuous") + self.__usegments = CyclicContainer(usegments) + self.__piecewise = UPiecewiseCurve(self.__usegments).parametrize() self.__area = None - self.usegments = usegments def __copy__(self) -> JordanCurve: return self.__deepcopy__(None) def __deepcopy__(self, memo) -> JordanCurve: """Returns a deep copy of the jordan curve""" - return self.__class__(map(copy, self.usegments)) + return self.__class__(map(copy, self)) def box(self) -> Box: """The box which encloses the jordan curve @@ -54,14 +66,14 @@ def box(self) -> Box: """ box = None - for usegment in self.usegments: + for usegment in self: box |= usegment.box() return box @property def length(self) -> Real: """The length of the curve""" - return sum(useg.length for useg in self.usegments) + return sum(useg.length for useg in self) @property def area(self) -> Real: @@ -83,8 +95,7 @@ def piecewise(self) -> PiecewiseCurve: """ return self.__piecewise - @property - def usegments(self) -> CyclicContainer[USegment]: + def __iter__(self) -> Iterator[USegment]: """Unparametrized Segments When setting, it checks if the points are the same between @@ -100,13 +111,13 @@ def usegments(self) -> CyclicContainer[USegment]: >>> from shapepy import JordanCurve >>> vertices = [(0, 0), (4, 0), (0, 3)] >>> jordan = FactoryJordan.polygon(vertices) - >>> print(jordan.usegments) + >>> print(tuple(jordan)) (Segment (deg 1), Segment (deg 1), Segment (deg 1)) - >>> print(jordan.usegments[0]) + >>> print(tuple(jordan)[0]) Planar curve of degree 1 and control points ((0, 0), (4, 0)) """ - return self.__usegments + yield from self.__usegments def vertices(self) -> Iterator[Point2D]: """Vertices @@ -127,77 +138,33 @@ def vertices(self) -> Iterator[Point2D]: ((0, 0), (4, 0), (0, 3)) """ - yield from (useg.start_point for useg in self.usegments) - - @usegments.setter - def usegments(self, other: Iterable[USegment]): - usegments = tuple(other) - if not all(Is.instance(u, (USegment, Segment)) for u in usegments): - raise ValueError(f"Invalid usegments: {tuple(map(type, other))}") - usegments = tuple( - USegment(s) if Is.instance(s, Segment) else s for s in usegments - ) - if any(map(self_intersect, usegments)): - raise ValueError("Segment must not self intersect") - for usegi, usegj in pairs(usegments, cyclic=True): - if usegi.end_point != usegj.start_point: - raise ValueError("The segments are not continuous") - self.__usegments = CyclicContainer(usegments) - upiece = UPiecewiseCurve(self.usegments) - self.__piecewise = upiece.parametrize() - self.__area = None + yield from (useg.start_point for useg in self) def __str__(self) -> str: - msg = ( - f"Jordan Curve with {len(self.usegments)} segments and vertices\n" - ) - msg += str(self.vertices) + nsegs = len(self.__usegments) + msg = f"Jordan Curve with {nsegs} segments and vertices\n" + msg += str(self.vertices()) return msg def __repr__(self) -> str: + nsegs = len(self.__usegments) box = self.box() - return f"JC[{len(self.usegments)}:{box.lowpt},{box.toppt}]" + return f"JC[{nsegs}:{box.lowpt},{box.toppt}]" def __eq__(self, other: JordanCurve) -> bool: - if not Is.jordan(other): - raise ValueError - if ( - self.box() != other.box() - or self.length != other.length - or not all(point in self for point in other.vertices()) - ): - return False - return copy(self).clean().usegments == copy(other).clean().usegments - - @debug("shapepy.geometry.jordancurve") - def invert(self) -> JordanCurve: - """Invert the current curve's orientation, doesn't create a copy - - :return: The same curve - :rtype: JordanCurve - - Example use - ----------- - - >>> from matplotlib import pyplot as plt - >>> from shapepy import JordanCurve - >>> vertices = [(0, 0), (4, 0), (0, 3)] - >>> jordan = FactoryJordan.polygon(vertices) - >>> jordan.invert([0, 2], [1/2, 2/3]) - Jordan Curve of degree 1 and vertices - ((0, 0), (0, 3), (4, 0)) - >>> print(jordan) - Jordan Curve of degree 1 and vertices - ((0, 0), (0, 3), (4, 0)) - - """ - self.usegments = reverse(useg.invert() for useg in self.usegments) - return self + return ( + Is.instance(other, JordanCurve) + and self.box() == other.box() + and self.length == other.length + and all(point in self for point in other.vertices()) + and all(point in other for point in self.vertices()) + and CyclicContainer(self.clean()) == CyclicContainer(other.clean()) + ) @debug("shapepy.geometry.jordancurve") def clean(self) -> JordanCurve: """Cleans the jordan curve""" - usegments = list(map(clean_usegment, self.usegments)) + usegments = list(map(clean_usegment, self)) index = 0 while index + 1 < len(usegments): union = usegments[index] | usegments[index + 1] @@ -212,15 +179,14 @@ def clean(self) -> JordanCurve: break usegments.pop(0) usegments[-1] = union - self.usegments = usegments - return self + return JordanCurve(usegments) def __invert__(self) -> JordanCurve: - return copy(self).invert() + return JordanCurve(reverse(~useg for useg in self)) def __contains__(self, point: Point2D) -> bool: """Tells if the point is on the boundary""" - return any(point in useg for useg in self.usegments) + return any(point in useg for useg in self) @debug("shapepy.geometry.jordancurve") @@ -231,7 +197,7 @@ def compute_area(jordan: JordanCurve) -> Real: If jordan is clockwise, then the area is negative """ total = 0 - for usegment in jordan.usegments: + for usegment in jordan: segment = usegment.parametrize() xfunc = segment.xfunc yfunc = segment.yfunc @@ -262,7 +228,7 @@ def clean_jordan(jordan: JordanCurve) -> JordanCurve: ((0, 0), (4, 0), (0, 3)) """ - usegments = deque(map(clean_usegment, jordan.usegments)) + usegments = deque(map(clean_usegment, jordan)) for _ in range(len(usegments) + 1): union = usegments[0] | usegments[1] if Is.instance(union, USegment): diff --git a/src/shapepy/geometry/segment.py b/src/shapepy/geometry/segment.py index 9d888eb8..9f852452 100644 --- a/src/shapepy/geometry/segment.py +++ b/src/shapepy/geometry/segment.py @@ -132,15 +132,15 @@ def __copy__(self) -> Segment: def __deepcopy__(self, memo) -> Segment: return Segment(copy(self.xfunc), copy(self.yfunc)) - def invert(self) -> Segment: + def __invert__(self) -> Segment: """ Inverts the direction of the curve. If the curve is clockwise, it becomes counterclockwise """ half = To.rational(1, 2) - self.__xfunc = self.__xfunc.shift(-half).scale(-1).shift(half) - self.__yfunc = self.__yfunc.shift(-half).scale(-1).shift(half) - return self + xfunc = self.__xfunc.shift(-half).scale(-1).shift(half) + yfunc = self.__yfunc.shift(-half).scale(-1).shift(half) + return Segment(xfunc, yfunc) def split(self, nodes: Iterable[Real]) -> Tuple[Segment, ...]: """ diff --git a/src/shapepy/geometry/unparam.py b/src/shapepy/geometry/unparam.py index ff04792c..734ce143 100644 --- a/src/shapepy/geometry/unparam.py +++ b/src/shapepy/geometry/unparam.py @@ -32,6 +32,14 @@ def __deepcopy__(self, _) -> USegment: def __contains__(self, other) -> bool: return other in self.__segment + def __invert__(self) -> USegment: + """Invert the current curve's orientation, doesn't create a copy + + :return: The same curve + :rtype: USegment + """ + return USegment(~self.__segment) + @property def length(self) -> Real: """ @@ -67,15 +75,6 @@ def __eq__(self, other: IGeometricCurve) -> bool: segj = other.parametrize() return segi(0) == segj(0) and segi(1) == segj(1) - def invert(self) -> USegment: - """Invert the current curve's orientation, doesn't create a copy - - :return: The same curve - :rtype: USegment - """ - self.__segment = self.__segment.invert() - return self - class UPiecewiseCurve(IGeometricCurve): """Equivalent to PiecewiseCurve, but ignores the parametrization""" diff --git a/src/shapepy/plot/plot.py b/src/shapepy/plot/plot.py index f0472db9..81b1535f 100644 --- a/src/shapepy/plot/plot.py +++ b/src/shapepy/plot/plot.py @@ -49,7 +49,7 @@ def path_shape(connected: ConnectedShape) -> Path: vertices = [] commands = [] for jordan in connected.jordans: - segments = tuple(useg.parametrize() for useg in jordan.usegments) + segments = tuple(useg.parametrize() for useg in jordan) vertices.append(segments[0](0)) commands.append(Path.MOVETO) for segment in segments: @@ -66,7 +66,7 @@ def path_jordan(jordan: JordanCurve) -> Path: """ Creates the commands for matplotlib to plot the jordan curve """ - segments = tuple(useg.parametrize() for useg in jordan.usegments) + segments = tuple(useg.parametrize() for useg in jordan) vertices = [segments[0](0)] commands = [Path.MOVETO] for segment in segments: diff --git a/tests/geometry/test_jordan_polygon.py b/tests/geometry/test_jordan_polygon.py index b5ddbd58..d7ad4383 100644 --- a/tests/geometry/test_jordan_polygon.py +++ b/tests/geometry/test_jordan_polygon.py @@ -299,10 +299,10 @@ def test_invert(self): assert inve_square != orig_square assert test_square == orig_square assert test_square != inve_square - test_square.invert() + test_square = ~test_square assert test_square != orig_square assert test_square == inve_square - test_square.invert() + test_square = ~test_square assert test_square == orig_square assert test_square != inve_square From 1d5dbe8e73393942a6074723793cc2e3f8ca69e3 Mon Sep 17 00:00:00 2001 From: Carlos Adir Date: Wed, 29 Oct 2025 22:44:24 +0100 Subject: [PATCH 3/3] remove `move`, `scale` and `rotate` from Point2D --- src/shapepy/bool2d/point.py | 8 +- src/shapepy/common.py | 14 --- src/shapepy/geometry/box.py | 8 +- src/shapepy/geometry/factory.py | 7 +- src/shapepy/geometry/point.py | 147 +++++++++++++------------------- tests/geometry/test_point.py | 18 ++-- 6 files changed, 82 insertions(+), 120 deletions(-) diff --git a/src/shapepy/bool2d/point.py b/src/shapepy/bool2d/point.py index 1b8bd9f8..0223bc6a 100644 --- a/src/shapepy/bool2d/point.py +++ b/src/shapepy/bool2d/point.py @@ -8,7 +8,7 @@ from copy import copy from typing import Tuple, Union -from ..geometry.point import Point2D +from ..geometry.point import Point2D, move, rotate, scale from ..loggers import debug from ..scalar.angle import Angle from ..scalar.reals import Real @@ -68,13 +68,13 @@ def __hash__(self): return hash((self.internal.xcoord, self.internal.ycoord)) def move(self, vector: Point2D) -> SinglePoint: - return SinglePoint(copy(self.__point).move(vector)) + return SinglePoint(move(self.__point, vector)) def scale(self, amount: Union[Real, Tuple[Real, Real]]) -> SinglePoint: - return SinglePoint(copy(self.__point).scale(amount)) + return SinglePoint(scale(self.__point, amount)) def rotate(self, angle: Angle) -> SinglePoint: - return SinglePoint(copy(self.__point).rotate(angle)) + return SinglePoint(rotate(self.__point, angle)) def density(self, center: Point2D) -> Density: return Density.zero diff --git a/src/shapepy/common.py b/src/shapepy/common.py index e91c3895..66cadfb8 100644 --- a/src/shapepy/common.py +++ b/src/shapepy/common.py @@ -78,20 +78,6 @@ def rotate(obj: Any, angle: Angle) -> Any: return deepcopy(obj).rotate(angle) -def clean(obj: Any) -> Any: - """ - Cleans the object, removing the unecessary data - """ - return deepcopy(obj).clean() - - -def derivate(obj: Any) -> Any: - """ - Derivates the analytic function or the curve - """ - return deepcopy(obj).derivate() - - def lebesgue_density(subset: SubSetR2, center: Tuple[Real, Real]) -> Density: """ Calcules the density of given subset around given point diff --git a/src/shapepy/geometry/box.py b/src/shapepy/geometry/box.py index ef911122..577529df 100644 --- a/src/shapepy/geometry/box.py +++ b/src/shapepy/geometry/box.py @@ -35,18 +35,12 @@ def __eq__(self, other: Box) -> Box: raise TypeError return self.lowpt == other.lowpt and self.toppt == other.toppt - def __str__(self) -> str: + def __str__(self) -> str: # pragma: no cover return f"Box with vertices {self.lowpt} and {self.toppt}" def __bool__(self) -> bool: return True - def __float__(self) -> float: - """Returns the area of the box""" - return (self.toppt[0] - self.lowpt[0]) * ( - self.toppt[1] - self.lowpt[1] - ) - def __contains__(self, point: Point2D) -> bool: point = To.point(point) if point[0] < self.lowpt[0] - self.dx: diff --git a/src/shapepy/geometry/factory.py b/src/shapepy/geometry/factory.py index b3aeb52a..e1334e8b 100644 --- a/src/shapepy/geometry/factory.py +++ b/src/shapepy/geometry/factory.py @@ -5,7 +5,6 @@ from __future__ import annotations import math -from copy import copy from typing import Iterable, Tuple import numpy as np @@ -14,7 +13,7 @@ from ..loggers import debug from ..tools import To from .jordancurve import JordanCurve -from .point import Point2D, cartesian +from .point import Point2D, cartesian, rotate from .segment import Segment from .unparam import USegment @@ -121,10 +120,10 @@ def circle(ndivangle: int): middle_point = cartesian(1, height) all_ctrlpoints = [] for _ in range(ndivangle - 1): - end_point = copy(start_point).rotate(angle) + end_point = rotate(start_point, angle) all_ctrlpoints.append([start_point, middle_point, end_point]) start_point = end_point - middle_point = copy(middle_point).rotate(angle) + middle_point = rotate(middle_point, angle) end_point = all_ctrlpoints[0][0] all_ctrlpoints.append([start_point, middle_point, end_point]) return JordanCurve( diff --git a/src/shapepy/geometry/point.py b/src/shapepy/geometry/point.py index 0ef3d130..20132611 100644 --- a/src/shapepy/geometry/point.py +++ b/src/shapepy/geometry/point.py @@ -125,9 +125,6 @@ def __pos__(self) -> Point2D: self.xcoord, self.ycoord, self.radius, self.angle ) - def __iadd__(self, other: Point2D) -> Point2D: - return self.move(other) - def __add__(self, other: Point2D) -> Point2D: other = To.point(other) return cartesian(self[0] + other[0], self[1] + other[1]) @@ -137,11 +134,9 @@ def __sub__(self, other: Point2D) -> Point2D: return cartesian(self[0] - other[0], self[1] - other[1]) def __mul__(self, other: float) -> Point2D: - if Is.point(other): - return inner(self, other) if not Is.finite(other): raise TypeError(f"Multiplication with non-real number: {other}") - return cartesian(self[0] * other, self[1] * other) + return cartesian(other * self.xcoord, other * self.ycoord) def __rmul__(self, other: float) -> Point2D: return self.__mul__(other) @@ -150,64 +145,66 @@ def __abs__(self) -> float: """Returns the norm of the point, the distance to the origin""" return self.radius - def move(self, vector: tuple[Real, Real]) -> Point2D: - """ - Moves the point by the given deltas - - Parameters - ---------- - dx : float - The delta to move the x coordinate - dy : float - The delta to move the y coordinate - - Returns - ------- - Point2D - The moved point - """ - vector = To.point(vector) - return cartesian(self.xcoord + vector[0], self.ycoord + vector[1]) - - def scale(self, amount: Union[Real, Tuple[Real, Real]]) -> Point2D: - """ - Scales the point by the given factors - - Parameters - ---------- - xscale : float - The factor to scale the x coordinate - yscale : float - The factor to scale the y coordinate - - Returns - ------- - Point2D - The scaled point - """ - xscale, yscale = (amount, amount) if Is.real(amount) else amount - return cartesian(xscale * self.xcoord, yscale * self.ycoord) - - def rotate(self, angle: Angle) -> Point2D: - """ - Rotates the point around the origin by the given angle - - Parameters - ---------- - angle : float - The angle in radians to rotate the point - - Returns - ------- - Point2D - The rotated point - """ - angle = To.angle(angle) - cos_angle = angle.cos() - sin_angle = angle.sin() - x_new = self[0] * cos_angle - self[1] * sin_angle - y_new = self[0] * sin_angle + self[1] * cos_angle - return cartesian(x_new, y_new) + +def move(point: Point2D, vector: tuple[Real, Real]) -> Point2D: + """ + Moves the point by the given deltas + + Parameters + ---------- + dx : float + The delta to move the x coordinate + dy : float + The delta to move the y coordinate + + Returns + ------- + Point2D + The moved point + """ + vector = To.point(vector) + return cartesian(point.xcoord + vector[0], point.ycoord + vector[1]) + + +def scale(point: Point2D, amount: Union[Real, Tuple[Real, Real]]) -> Point2D: + """ + Scales the point by the given factors + + Parameters + ---------- + xscale : float + The factor to scale the x coordinate + yscale : float + The factor to scale the y coordinate + + Returns + ------- + Point2D + The scaled point + """ + xscale, yscale = (amount, amount) if Is.real(amount) else amount + return cartesian(xscale * point.xcoord, yscale * point.ycoord) + + +def rotate(point: Point2D, angle: Angle) -> Point2D: + """ + Rotates the point around the origin by the given angle + + Parameters + ---------- + angle : float + The angle in radians to rotate the point + + Returns + ------- + Point2D + The rotated point + """ + angle = To.angle(angle) + sin, cos = angle.sin(), angle.cos() + newx = cos * point.xcoord - sin * point.ycoord + newy = sin * point.xcoord + cos * point.ycoord + return cartesian(newx, newy) def inner(pointa: Point2D, pointb: Point2D) -> Real: @@ -241,26 +238,4 @@ def to_point(point: Point2D | tuple[Real, Real]) -> Point2D: return cartesian(xcoord, ycoord) -def is_point(point: Point2D | tuple[Real, Real]) -> bool: - """ - Checks if the given point is a Point2D object or a tuple of two reals - - Parameters - ---------- - point : Point2D or tuple of two reals - The point to be checked - - Returns - ------- - bool - True if the point is a Point2D or a tuple of two reals, False otherwise - """ - return Is.instance(point, Point2D) or ( - Is.instance(point, tuple) - and len(point) == 2 - and all(Is.finite(coord) for coord in point) - ) - - To.point = to_point -Is.point = is_point diff --git a/tests/geometry/test_point.py b/tests/geometry/test_point.py index d034bb7e..984dbf56 100644 --- a/tests/geometry/test_point.py +++ b/tests/geometry/test_point.py @@ -6,7 +6,15 @@ import pytest -from shapepy.geometry.point import cartesian, cross, inner, polar +from shapepy.geometry.point import ( + cartesian, + cross, + inner, + move, + polar, + rotate, + scale, +) from shapepy.scalar.angle import degrees @@ -266,21 +274,21 @@ def test_addsub(): ) def test_transformations(): pointa = cartesian(0, 0) - pointb = pointa.move((1, 3)) + pointb = move(pointa, (1, 3)) assert id(pointb) != id(pointa) assert pointb == (1, 3) pointa = cartesian(1, 3) - pointb = pointa.scale(2) + pointb = scale(pointa, 2) assert id(pointb) != id(pointa) assert pointb == (2, 6) - pointc = pointb.scale((5, 3)) + pointc = scale(pointb, (5, 3)) assert id(pointc) != id(pointb) assert pointc == (10, 18) angle = degrees(90) pointa = cartesian(10, 18) - pointb = pointa.rotate(angle) + pointb = rotate(pointa, angle) assert id(pointb) != id(pointa) assert pointb == (-18, 10)