diff --git a/cadquery/occ_impl/geom.py b/cadquery/occ_impl/geom.py index b519d97b4..a9e3e9dff 100644 --- a/cadquery/occ_impl/geom.py +++ b/cadquery/occ_impl/geom.py @@ -26,6 +26,8 @@ from OCP.TopLoc import TopLoc_Location from OCP.BinTools import BinTools_LocationSet +from multimethod import multidispatch + from ..types import Real from ..utils import multimethod @@ -576,20 +578,17 @@ def bottom(cls, origin=(0, 0, 0), xDir=Vector(1, 0, 0)): plane._setPlaneDir(xDir) return plane + # Prefer multidispatch over multimethod, as that supports keyword + # arguments. These are in use, since Plane.__init__ has not always + # been a multimethod. + @multidispatch def __init__( self, - origin: Union[Tuple[float, float, float], Vector], - xDir: Optional[Union[Tuple[float, float, float], Vector]] = None, - normal: Union[Tuple[float, float, float], Vector] = (0, 0, 1), + origin: Union[Tuple[Real, Real, Real], Vector], + xDir: Optional[Union[Tuple[Real, Real, Real], Vector]] = None, + normal: Union[Tuple[Real, Real, Real], Vector] = (0, 0, 1), ): - """ - Create a Plane with an arbitrary orientation - - :param origin: the origin in global coordinates - :param xDir: an optional vector representing the xDirection. - :param normal: the normal direction for the plane - :raises ValueError: if the specified xDir is not orthogonal to the provided normal - """ + """Create a Plane from origin in global coordinates, vector xDir, and normal direction for the plane.""" zDir = Vector(normal) if zDir.Length == 0.0: raise ValueError("normal should be non null") @@ -607,6 +606,50 @@ def __init__( self._setPlaneDir(xDir) self.origin = Vector(origin) + @__init__.register + def __init__( + self, loc: "Location", + ): + """Create a Plane from Location loc.""" + + # Ask location for its information + origin, rotations = loc.toTuple() + + # Origin is easy, but the rotational angles of the location need to be + # turned into xDir and normal vectors. + # This is done by multiplying a standard cooridnate system by the given + # angles. + # Rotation of vectors is done by a transformation matrix. + # The order in which rotational angles are introduced is crucial: + # If u is our vector, Rx is rotation around x axis etc, we want the + # following: + # u' = Rz * Ry * Rx * u = R * u + # That way, all rotational angles refer to a global coordinate system, + # and e.g. Ry does not refer to a rotation direction, which already + # was rotated around Rx. + # This definition in the global system is called extrinsic, and it is + # how the Location class wants it to be done. + # And this is why we introduce the rotations from left to right + # and from Z to X. + transformation = Matrix() + transformation.rotateZ(rotations[2] * pi / 180.0) + transformation.rotateY(rotations[1] * pi / 180.0) + transformation.rotateX(rotations[0] * pi / 180.0) + + # Apply rotation on vectors of the global plane + # These vectors are already unit vectors and require no .normalized() + globaldirs = ((1, 0, 0), (0, 0, 1)) + localdirs = (Vector(*i).transform(transformation) for i in globaldirs) + + # Unpack vectors + xDir, normal = localdirs + + # Apply attributes as in other constructor. + # Rememeber to set zDir before calling _setPlaneDir. + self.zDir = normal + self._setPlaneDir(xDir) + self.origin = origin + def _eq_iter(self, other): """Iterator to successively test equality""" cls = type(self) @@ -1108,6 +1151,11 @@ def toTuple(self) -> Tuple[Tuple[float, float, float], Tuple[float, float, float return rv_trans, (degrees(rx), degrees(ry), degrees(rz)) + @property + def plane(self) -> "Plane": + + return Plane(self) + def __getstate__(self) -> BytesIO: rv = BytesIO() diff --git a/tests/test_geom.py b/tests/test_geom.py new file mode 100644 index 000000000..24fbdfbf8 --- /dev/null +++ b/tests/test_geom.py @@ -0,0 +1,137 @@ +from cadquery.occ_impl.geom import Location, Plane, Vector +import pytest +import itertools + + +# Conversion can be triggered from explicit constructor, or from property +@pytest.mark.parametrize( + ["useproperty",], [(False,), (True,),], +) +# Create different test cases from different initial plane. +# Testing the different components is mainly useful for debugging if things +# do not work. +# Arguments to Plane.__init__ along with expected rotations in a converted +# Location object are given here. +@pytest.mark.parametrize( + ["plargs", "expectedrot"], + [ + # Default plane + (((3, 5, 6),), (0, 0, 0),), + # Just xDir specified, but never parallel to the default normal + (((3, 5, 6), (1, 0, 0),), (0, 0, 0),), + (((3, 5, 6), (0, 1, 0),), (0, 0, 90),), + # xDir and normal specified. + # Omit normals, that were included as default, and ones which + # have no component orthogonal to xDir + (((3, 5, 6), (1, 0, 0), (0, 1, 0),), (-90, 0, 0),), + (((3, 5, 6), (0, 1, 0), (1, 0, 0),), (90, 0, 90),), + # Just xDir, but with multiple vector components + (((3, 5, 6), (1, 1, 0),), (0, 0, 45),), + (((3, 5, 6), (1, 0, 1),), (0, -45, 0),), + (((3, 5, 6), (0, 1, 1),), (0, -45, 90),), + # Multiple components in xdir and normal + # Starting from here, there are no known golden Location rotations, + # as normal is made orthogonal to xDir and as rotational angles + # are non-trivial. + (((3, 5, 6), (1, 1, 0), (1, 0, 1),), None,), + (((3, 5, 6), (1, 1, 0), (0, 1, 1),), None,), + (((3, 5, 6), (1, 0, 1), (1, 1, 0),), None,), + (((3, 5, 6), (1, 0, 1), (0, 1, 1),), None,), + (((3, 5, 6), (0, 1, 1), (1, 1, 0),), None,), + (((3, 5, 6), (0, 1, 1), (1, 0, 1),), None,), + # Same, but introduce negative directions + (((3, 5, 6), (-1, 1, 0), (-1, 0, -1),), None,), + (((3, 5, 6), (1, -1, 0), (0, -1, -1),), None,), + (((3, 5, 6), (1, 0, -1), (1, -1, 0),), None,), + (((3, 5, 6), (1, 0, -1), (0, -1, 1),), None,), + (((3, 5, 6), (0, -1, -1), (-1, 1, 0),), None,), + (((3, 5, 6), (0, -1, -1), (1, 0, -1),), None,), + # Vectors with random non-trivial directions + (((3, 5, 6), (2, 4, 7), (9, 8, 1),), None,), + ], +) +def test_Plane_from_Location(plargs, expectedrot, useproperty): + # Test conversion between Plane and Location by converting multiple + # times between them, such that two Plane and two Location can be + # compared respectively. + + # If there are three things in plargs, ensure that xDir and normal are + # orthogonal. That should be ensured by an exception in Plane.__init__. + # This here makes the normal orthogonal to xDir by subtracting its + # projection on xDir. + # If no normal is given, the default normal is assumed. + # Packed and unpacked arguments to Plane are kept the same. + if len(plargs) == 1: + (origin,) = plargs + elif len(plargs) == 2: + plargs = ( + *plargs, + (0, 0, 1), + ) + # If len(plargs) was 2, it is now 3, and the normal still needs to be + # made orthogonal to xDir. + if len(plargs) == 3: + origin, xDir, normal = plargs + xDir = Vector(xDir) + normal = Vector(normal) + normal -= normal.projectToLine(xDir) + xDir = xDir.toTuple() + normal = normal.toTuple() + plargs = ( + origin, + xDir, + normal, + ) + + # Start from random Plane with classical __init__ + # Use keyword arguments on purpose, as they still need to work after + # having @multidispatch added to that __init__. + # Test that on cases, where plargs has three elements and was unpacked. + if len(plargs) == 3: + originalpl = Plane(origin=origin, xDir=xDir, normal=normal) + else: + originalpl = Plane(*plargs) + + # Convert back and forth, such that comparable pairs are created. + # Depending on test fixture, call constructor directly or use properties + if useproperty: + locforth = originalpl.location + plback = locforth.plane + locback = plback.location + else: + locforth = Location(originalpl) + plback = Plane(locforth) + locback = Location(plback) + + # Create raw locations, which are flat tuples of raw numbers, suitable for + # assertion with pytest.approx + locraws = list() + for loc in (locforth, locback): + loc = loc.toTuple() + loc = tuple(itertools.chain(*loc)) + locraws.append(loc) + + # Same for planes + plraws = list() + for pl in (originalpl, plback): + pl = ( + pl.origin.toTuple(), + pl.xDir.toTuple(), + pl.yDir.toTuple(), + pl.zDir.toTuple(), + ) + pl = tuple(itertools.chain(*pl)) + plraws.append(pl) + + # Assert the properties of the location object. + # Asserting on one Location is enough, as equality to the other one is + # asserted below. + # First, its origin shall be the same + assert locraws[0][0:3] == pytest.approx(origin) + # Then rotations are asserted from manual values + if expectedrot is not None: + assert locraws[0][3:6] == pytest.approx(expectedrot) + + # Assert that pairs of Plane or Location are equal after conversion + assert locraws[0] == pytest.approx(locraws[1]) + assert plraws[0] == pytest.approx(plraws[1])