Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b4903d4
Added new __init__ multimethod of Plane
Joschua-Conrad Oct 9, 2025
6195872
Changed type hints of exisitng Plane.__init__
Joschua-Conrad Oct 9, 2025
8fe6267
Introduced test of new Plane.__init__
Joschua-Conrad Oct 9, 2025
871fd11
Comments on introduced test
Joschua-Conrad Oct 9, 2025
aed6fb1
Test now has only a single initial Plane to start conversiosn from
Joschua-Conrad Oct 9, 2025
ba6abe4
Tests pass now, after enforcing that tested xDir and normal are ortho…
Joschua-Conrad Oct 9, 2025
95608fe
New constructor is now also available as property
Joschua-Conrad Oct 9, 2025
c531964
Removed obsolete test code lines
Joschua-Conrad Oct 9, 2025
86b4d40
Removed redundant test parametrizations
Joschua-Conrad Oct 9, 2025
d4b3b2d
Gave new Plane.__init__ a more elaborate logic.
Joschua-Conrad Oct 9, 2025
c94bd36
Fixed rotation order in new Plane.__init__
Joschua-Conrad Oct 9, 2025
371655b
Added more test parametrizations
Joschua-Conrad Oct 9, 2025
f102c36
Comment
Joschua-Conrad Oct 9, 2025
f3437f7
Resolved all flake8 linting warnings: whitespace only
Joschua-Conrad Oct 9, 2025
afc7e1f
Ran black
Joschua-Conrad Oct 9, 2025
fc84aca
Again supporting keyword arguments on Plane.__init__
Joschua-Conrad Oct 9, 2025
f53e871
Tried adding multimethod support to docs, but output still looks bad
Joschua-Conrad Oct 9, 2025
69ed038
Reverted changes made to support multidispatch in sphinx
Joschua-Conrad Oct 9, 2025
b5df9cf
Turned new Plane.__init__ into a class method
Joschua-Conrad Oct 9, 2025
607bb83
More assertions on expected Location properites
Joschua-Conrad Oct 10, 2025
1043dcf
Re-ran black
Joschua-Conrad Oct 10, 2025
7513047
Ran black, but this time with the correct CQ-custom black fork
Joschua-Conrad Oct 10, 2025
198e32e
Fixed mypy error
Joschua-Conrad Oct 10, 2025
cd99615
Changed new constructor from classmethod to multimethod
Joschua-Conrad Oct 21, 2025
9a1e326
Fix typo in comment
Joschua-Conrad Oct 22, 2025
8d8c2f5
Fix typo in comment
Joschua-Conrad Oct 22, 2025
e5b9c30
Changes as requested by PR review of @lorenzncode
Joschua-Conrad Oct 28, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 59 additions & 11 deletions cadquery/occ_impl/geom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
137 changes: 137 additions & 0 deletions tests/test_geom.py
Original file line number Diff line number Diff line change
@@ -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])