From 38ec2a62613ea6e8157aa8550f33163e9bc6b5e4 Mon Sep 17 00:00:00 2001 From: Marcus Boyd Date: Sat, 3 Apr 2021 16:24:03 +1030 Subject: [PATCH 01/10] minor formatting --- cadquery/assembly.py | 51 ++++++++++++++++++++----------------- cadquery/occ_impl/solver.py | 3 +++ 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/cadquery/assembly.py b/cadquery/assembly.py index cfe3537c8..ed308bfe9 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -79,7 +79,7 @@ def __init__( ): """ Construct a constraint. - + :param objects: object names refernced in the constraint :param args: subshapes (e.g. faces or edges) of the objects :param sublocs: locations of the objects (only relevant if the objects are nested in a sub-assembly) @@ -162,17 +162,17 @@ def __init__( :param name: unique name of the root object (default: None, reasulting in an UUID being generated) :param color: color of the added object (default: None) :return: An Assembly object. - - + + To create an empty assembly use:: - + assy = Assembly(None) - + To create one constraint a root object:: - + b = Workplane().box(1,1,1) assy = Assembly(b, Location(Vector(0,0,1)), name="root") - + """ self.obj = obj @@ -211,12 +211,15 @@ def add( color: Optional[Color] = None, ) -> "Assembly": """ - add a subassembly to the current assembly. - + Add a subassembly to the current assembly. + :param obj: subassembly to be added - :param loc: location of the root object (deafault: None, resulting in the location stored in the subassembly being used) - :param name: unique name of the root object (default: None, resulting in the name stored in the subassembly being used) - :param color: color of the added object (default: None, resulting in the color stored in the subassembly being used) + :param loc: location of the root object (deafault: None, resulting in the location stored in + the subassembly being used) + :param name: unique name of the root object (default: None, resulting in the name stored in + the subassembly being used) + :param color: color of the added object (default: None, resulting in the color stored in the + subassembly being used) """ ... @@ -229,18 +232,20 @@ def add( color: Optional[Color] = None, ) -> "Assembly": """ - add a subassembly to the current assembly with explicit location and name - + Add a subassembly to the current assembly with explicit location and name. + :param obj: object to be added as a subassembly - :param loc: location of the root object (deafault: None, interpreted as identity transformation) - :param name: unique name of the root object (default: None, resulting in an UUID being generated) + :param loc: location of the root object (deafault: None, interpreted as identity + transformation) + :param name: unique name of the root object (default: None, resulting in an UUID being + generated) :param color: color of the added object (default: None) """ ... def add(self, arg, **kwargs): """ - add a subassembly to the current assembly. + Add a subassembly to the current assembly. """ if isinstance(arg, Assembly): @@ -272,16 +277,16 @@ def _query(self, q: str) -> Tuple[str, Optional[Shape]]: """ Execute a selector query on the assembly. The query is expected to be in the following format: - + name[?tag][@kind@args] - + valid example include: - + obj_name @ faces @ >Z - obj_name?tag1@faces@>Z + obj_name?tag1@faces@>Z obj_name ? tag obj_name - + """ tmp: Workplane @@ -311,7 +316,7 @@ def _query(self, q: str) -> Tuple[str, Optional[Shape]]: def _subloc(self, name: str) -> Tuple[Location, str]: """ Calculate relative location of an object in a subassembly. - + Returns the relative posiitons as well as the name of the top assembly. """ diff --git a/cadquery/occ_impl/solver.py b/cadquery/occ_impl/solver.py index 66961c06d..115f686cb 100644 --- a/cadquery/occ_impl/solver.py +++ b/cadquery/occ_impl/solver.py @@ -118,6 +118,9 @@ def dir_cost( ) def f(x): + """ + Function to be minimized + """ constraints = self.constraints ne = self.ne From 372c58f242dbffc1fa286aa46b47b2bdf50eeac3 Mon Sep 17 00:00:00 2001 From: Marcus Boyd Date: Sat, 3 Apr 2021 21:24:29 +1030 Subject: [PATCH 02/10] Allow Plane init with no specific x dir and add Plane.toPln --- cadquery/occ_impl/geom.py | 56 ++++++++++++++++++++++++++------------- tests/test_cadquery.py | 21 +++++++++++++++ 2 files changed, 59 insertions(+), 18 deletions(-) diff --git a/cadquery/occ_impl/geom.py b/cadquery/occ_impl/geom.py index 018d46b35..a1334f850 100644 --- a/cadquery/occ_impl/geom.py +++ b/cadquery/occ_impl/geom.py @@ -2,7 +2,18 @@ from typing import overload, Sequence, Union, Tuple, Type, Optional -from OCP.gp import gp_Vec, gp_Ax1, gp_Ax3, gp_Pnt, gp_Dir, gp_Trsf, gp_GTrsf, gp, gp_XYZ +from OCP.gp import ( + gp_Vec, + gp_Ax1, + gp_Ax3, + gp_Pnt, + gp_Dir, + gp_Pln, + gp_Trsf, + gp_GTrsf, + gp_XYZ, + gp, +) from OCP.Bnd import Bnd_Box from OCP.BRepBndLib import BRepBndLib from OCP.BRepMesh import BRepMesh_IncrementalMesh @@ -500,30 +511,35 @@ def bottom(cls, origin=(0, 0, 0), xDir=Vector(1, 0, 0)): plane._setPlaneDir(xDir) return plane - def __init__(self, origin, xDir, normal): - """Create a Plane with an arbitrary orientation - - TODO: project x and y vectors so they work even if not orthogonal - :param origin: the origin - :type origin: a three-tuple of the origin, in global coordinates - :param xDir: a vector representing the xDirection. - :type xDir: a three-tuple representing a vector, or a FreeCAD Vector - :param normal: the normal direction for the new plane - :type normal: a FreeCAD Vector - :raises: ValueError if the specified xDir is not orthogonal to the provided normal. - :return: a plane in the global space, with the xDirection of the plane in the specified direction. + 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), + ): + """ + 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 """ zDir = Vector(normal) if zDir.Length == 0.0: raise ValueError("normal should be non null") - xDir = Vector(xDir) - if xDir.Length == 0.0: - raise ValueError("xDir should be non null") - self.zDir = zDir.normalized() + + if xDir is None: + ax3 = gp_Ax3(Vector(origin).toPnt(), Vector(normal).toDir()) + xDir = Vector(ax3.XDirection()) + else: + xDir = Vector(xDir) + if xDir.Length == 0.0: + raise ValueError("xDir should be non null") self._setPlaneDir(xDir) - self.origin = origin + self.origin = Vector(origin) def _eq_iter(self, other): """Iterator to successively test equality""" @@ -725,6 +741,10 @@ def location(self) -> "Location": return Location(self) + def toPln(self) -> gp_Pln: + + return gp_Pln(gp_Ax3(self.origin.toPnt(), self.zDir.toDir(), self.xDir.toDir())) + class BoundBox(object): """A BoundingBox for an object or set of objects. Wraps the OCP one""" diff --git a/tests/test_cadquery.py b/tests/test_cadquery.py index 1c5480adb..55f3b7091 100644 --- a/tests/test_cadquery.py +++ b/tests/test_cadquery.py @@ -378,6 +378,27 @@ def testPlaneRotateConcatRandom(self): assert before[direction] == approx(after[direction]) assert plane.origin.toTuple() == origin + def testPlaneNoXDir(self): + """ + Plane should pick an arbitrary x direction if None is passed in. + """ + for z_dir in [(0, 0, 1), (1, 0, 0), (-1, 0, 0), Vector(-1, 0, 0)]: + result = Plane(origin=(1, 2, 3), xDir=None, normal=z_dir) + assert result.zDir == Vector(z_dir) + assert result.xDir.Length == approx(1) + assert result.origin == Vector(1, 2, 3) + + # unspecified xDir should be the same as xDir=None + result2 = Plane(origin=(1, 2, 3), normal=z_dir) + assert result2 == result + + def testPlaneToPln(self): + plane = Plane(origin=(1, 2, 3), xDir=(-1, 0, 0), normal=(0, 1, 0)) + gppln = plane.toPln() + assert Vector(gppln.XAxis().Direction()) == Vector(-1, 0, 0) + assert Vector(gppln.YAxis().Direction()) == plane.yDir + assert Vector(gppln.Axis().Direction()) == plane.zDir + def testRect(self): x = 10 y = 11 From 0e2f7bf60c4124197bad40a6ca9b8c955ebe7a08 Mon Sep 17 00:00:00 2001 From: Marcus Boyd Date: Sat, 3 Apr 2021 21:25:38 +1030 Subject: [PATCH 03/10] InPlane constraint --- cadquery/assembly.py | 17 +++++++- cadquery/occ_impl/solver.py | 32 ++++++++++++++- tests/test_assembly.py | 80 +++++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 3 deletions(-) diff --git a/cadquery/assembly.py b/cadquery/assembly.py index ed308bfe9..979417f82 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -5,7 +5,7 @@ from .cq import Workplane from .occ_impl.shapes import Shape, Face, Edge -from .occ_impl.geom import Location, Vector +from .occ_impl.geom import Location, Vector, Plane from .occ_impl.assembly import Color from .occ_impl.solver import ( ConstraintSolver, @@ -106,6 +106,14 @@ def _getAxis(self, arg: Shape) -> Vector: return rv + def _getPlane(self, arg: Shape) -> Plane: + + # This is very duck typing. Might want to restrict types later to + # prevent confusion eg. making a plane from an spline Edge. + normal = self._getAxis(arg) + origin = arg.Center() + return Plane(origin, normal=normal) + def toPOD(self) -> ConstraintPOD: """ Convert the constraint to a representation used by the solver. @@ -113,7 +121,7 @@ def toPOD(self) -> ConstraintPOD: rv: List[Tuple[ConstraintMarker, ...]] = [] - for arg, loc in zip(self.args, self.sublocs): + for idx, (arg, loc) in enumerate(zip(self.args, self.sublocs)): arg = arg.located(loc * arg.location()) @@ -123,6 +131,11 @@ def toPOD(self) -> ConstraintPOD: rv.append((arg.Center().toPnt(),)) elif self.kind == "Plane": rv.append((self._getAxis(arg).toDir(), arg.Center().toPnt())) + elif self.kind == "InPlane": + if idx == 0: + rv.append((self._getPlane(arg).toPln(),)) + else: + rv.append((arg.Center().toPnt(),)) else: raise ValueError(f"Unknown constraint kind {self.kind}") diff --git a/cadquery/occ_impl/solver.py b/cadquery/occ_impl/solver.py index 115f686cb..a87d628f8 100644 --- a/cadquery/occ_impl/solver.py +++ b/cadquery/occ_impl/solver.py @@ -4,7 +4,7 @@ from numpy import array, eye, zeros, pi from scipy.optimize import minimize -from OCP.gp import gp_Vec, gp_Dir, gp_Pnt, gp_Trsf, gp_Quaternion +from OCP.gp import gp_Vec, gp_Pln, gp_Lin, gp_Dir, gp_Pnt, gp_Trsf, gp_Quaternion from .geom import Location @@ -117,6 +117,18 @@ def dir_cost( DIR_SCALING * (val - m1.Transformed(t1).Angle(m2.Transformed(t2))) ** 2 ) + def pln_pnt_cost( + m1: gp_Pln, + m2: gp_Pnt, + t1: gp_Trsf, + t2: gp_Trsf, + val: Optional[float] = None, + ) -> float: + + val = 0 if val is None else val + + return (val - (m1.Transformed(t1).Distance(m2.Transformed(t2)))) ** 2 + def f(x): """ Function to be minimized @@ -140,6 +152,8 @@ def f(x): rv += pt_cost(m1, m2, t1, t2, d) elif isinstance(m1, gp_Dir): rv += dir_cost(m1, m2, t1, t2, d) + elif isinstance(m1, gp_Pln): + rv += pln_pnt_cost(m1, m2, t1, t2, d) else: raise NotImplementedError(f"{m1,m2}") @@ -200,6 +214,22 @@ def jac(x): if k2 not in self.locked: tmp2 = dir_cost(m1, m2, t1, t2j, d) rv[k2 * NDOF + j] += (tmp2 - tmp) / DIFF_EPS + + elif isinstance(m1, gp_Pln): + tmp = pln_pnt_cost(m1, m2, t1, t2, d) + + for j in range(NDOF): + + t1j = transforms_delta[k1 * NDOF + j] + t2j = transforms_delta[k2 * NDOF + j] + + if k1 not in self.locked: + tmp1 = pln_pnt_cost(m1, m2, t1j, t2, d) + rv[k1 * NDOF + j] += (tmp1 - tmp) / DIFF_EPS + + if k2 not in self.locked: + tmp2 = pln_pnt_cost(m1, m2, t1, t2j, d) + rv[k2 * NDOF + j] += (tmp2 - tmp) / DIFF_EPS else: raise NotImplementedError(f"{m1,m2}") diff --git a/tests/test_assembly.py b/tests/test_assembly.py index f88c23c8d..7c057198b 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -43,6 +43,17 @@ def nested_assy(): return assy +@pytest.fixture +def box_and_vertex(): + + assy = cq.Assembly() + box_wp = cq.Workplane().box(1, 2, 3) + assy.add(box_wp, name="box") + vertex_wp = cq.Workplane().newObject([cq.Vertex.makeVertex(0, 0, 0)]) + assy.add(vertex_wp, name="vertex") + return assy + + def test_color(): c1 = cq.Color("red") @@ -225,3 +236,72 @@ def test_expression_grammar(nested_assy): nested_assy.constrain( "TOP@faces@>Z", "SECOND/BOTTOM@vertices@>X and >Y and >Z", "Point" ) + + +def test_InPlane_constraint(box_and_vertex): + + # add first constraint + box_and_vertex.constrain( + "box", + box_and_vertex.children[0].obj.faces(">X").val(), + "vertex", + box_and_vertex.children[1].obj.val(), + "InPlane", + param=0, + ) + box_and_vertex.solve() + + x_pos = ( + box_and_vertex.children[1].loc.wrapped.Transformation().TranslationPart().X() + ) + assert x_pos == pytest.approx(0.5) + + # add a second InPlane constraint + box_and_vertex.constrain("box@faces@>Y", "vertex", "InPlane", param=0) + box_and_vertex.solve() + + # should still be on the >X face from the first constraint + x_pos = ( + box_and_vertex.children[1].loc.wrapped.Transformation().TranslationPart().X() + ) + assert x_pos == pytest.approx(0.5) + + # now should additionally be on the >Y face + y_pos = ( + box_and_vertex.children[1].loc.wrapped.Transformation().TranslationPart().Y() + ) + assert y_pos == pytest.approx(1) + + # add a third InPlane constraint + box_and_vertex.constrain("box@faces@>Z", "vertex", "InPlane", param=0) + box_and_vertex.solve() + + # should now be on the >X and >Y and >Z corner + assert ( + box_and_vertex.children[1] + .loc.wrapped.Transformation() + .TranslationPart() + .IsEqual(gp_XYZ(0.5, 1, 1.5), 1e-6) + ) + + +@pytest.mark.parametrize("param1", range(3)) +@pytest.mark.parametrize("param0", range(3)) +def test_InPlane_param(box_and_vertex, param0, param1): + + box_and_vertex.constrain("box@faces@>Z", "vertex", "InPlane", param=param0) + box_and_vertex.constrain("box@faces@>X", "vertex", "InPlane", param=param1) + box_and_vertex.solve() + + # note that pytest.approx(0) has a tolerance of 1.0e-12, so can't use that here + z_offset = abs( + box_and_vertex.children[1].loc.wrapped.Transformation().TranslationPart().Z() + - 1.5 + ) + assert z_offset - param0 < 1e-6 + + x_offset = abs( + box_and_vertex.children[1].loc.wrapped.Transformation().TranslationPart().X() + - 0.5 + ) + assert x_offset - param1 < 1e-6 From bfed9bc1e326a9c66abf413fb10a206007d7c91a Mon Sep 17 00:00:00 2001 From: Marcus Boyd Date: Sun, 4 Apr 2021 20:38:59 +0930 Subject: [PATCH 04/10] Added InPlane constraint type hint --- cadquery/assembly.py | 2 +- cadquery/occ_impl/solver.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cadquery/assembly.py b/cadquery/assembly.py index 979417f82..3689a6273 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -18,7 +18,7 @@ # type definitions AssemblyObjects = Union[Shape, Workplane, None] -ConstraintKinds = Literal["Plane", "Point", "Axis"] +ConstraintKinds = Literal["Plane", "Point", "Axis", "InPlane"] ExportLiterals = Literal["STEP", "XML"] PATH_DELIM = "/" diff --git a/cadquery/occ_impl/solver.py b/cadquery/occ_impl/solver.py index a87d628f8..d9fe1bfd2 100644 --- a/cadquery/occ_impl/solver.py +++ b/cadquery/occ_impl/solver.py @@ -9,7 +9,7 @@ from .geom import Location DOF6 = Tuple[float, float, float, float, float, float] -ConstraintMarker = Union[gp_Dir, gp_Pnt] +ConstraintMarker = Union[gp_Pln, gp_Dir, gp_Pnt] Constraint = Tuple[ Tuple[ConstraintMarker, ...], Tuple[Optional[ConstraintMarker], ...], Optional[Any] ] From a851c7ee085c03b23d53ed9def69f9f7f7097733 Mon Sep 17 00:00:00 2001 From: Marcus Boyd Date: Sun, 4 Apr 2021 20:42:43 +0930 Subject: [PATCH 05/10] tidy --- tests/test_assembly.py | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 7c057198b..8736e28f9 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -260,17 +260,13 @@ def test_InPlane_constraint(box_and_vertex): box_and_vertex.constrain("box@faces@>Y", "vertex", "InPlane", param=0) box_and_vertex.solve() - # should still be on the >X face from the first constraint - x_pos = ( - box_and_vertex.children[1].loc.wrapped.Transformation().TranslationPart().X() + vertex_translation_part = ( + box_and_vertex.children[1].loc.wrapped.Transformation().TranslationPart() ) - assert x_pos == pytest.approx(0.5) - + # should still be on the >X face from the first constraint + assert vertex_translation_part.X() == pytest.approx(0.5) # now should additionally be on the >Y face - y_pos = ( - box_and_vertex.children[1].loc.wrapped.Transformation().TranslationPart().Y() - ) - assert y_pos == pytest.approx(1) + assert vertex_translation_part.Y() == pytest.approx(1) # add a third InPlane constraint box_and_vertex.constrain("box@faces@>Z", "vertex", "InPlane", param=0) @@ -294,14 +290,10 @@ def test_InPlane_param(box_and_vertex, param0, param1): box_and_vertex.solve() # note that pytest.approx(0) has a tolerance of 1.0e-12, so can't use that here - z_offset = abs( - box_and_vertex.children[1].loc.wrapped.Transformation().TranslationPart().Z() - - 1.5 + vertex_translation_part = ( + box_and_vertex.children[1].loc.wrapped.Transformation().TranslationPart() ) + z_offset = abs(vertex_translation_part.Z() - 1.5) assert z_offset - param0 < 1e-6 - - x_offset = abs( - box_and_vertex.children[1].loc.wrapped.Transformation().TranslationPart().X() - - 0.5 - ) + x_offset = abs(vertex_translation_part.X() - 0.5) assert x_offset - param1 < 1e-6 From bd2d2d02095b5e7a4a78d313218b914357dca49c Mon Sep 17 00:00:00 2001 From: Marcus Boyd Date: Sun, 4 Apr 2021 20:42:55 +0930 Subject: [PATCH 06/10] added extra InPlane constraint test --- tests/test_assembly.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 8736e28f9..22b1edb7d 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -281,6 +281,22 @@ def test_InPlane_constraint(box_and_vertex): ) +def test_InPlane_3_parts(box_and_vertex): + + cylinder_height = 2 + cylinder = cq.Workplane().circle(0.1).extrude(cylinder_height) + box_and_vertex.add(cylinder, name="cylinder") + box_and_vertex.constrain("box@faces@>Z", "cylinder@faces@Z", "vertex", "InPlane") + box_and_vertex.constrain("box@faces@>X", "vertex", "InPlane") + box_and_vertex.solve() + vertex_translation_part = ( + box_and_vertex.children[1].loc.wrapped.Transformation().TranslationPart() + ) + assert vertex_translation_part.Z() == pytest.approx(1.5 + cylinder_height) + assert vertex_translation_part.X() == pytest.approx(0.5) + + @pytest.mark.parametrize("param1", range(3)) @pytest.mark.parametrize("param0", range(3)) def test_InPlane_param(box_and_vertex, param0, param1): From 5eae5836d58641ab99c37d1179f81e437a00b080 Mon Sep 17 00:00:00 2001 From: Marcus Boyd Date: Fri, 9 Apr 2021 17:01:20 +0930 Subject: [PATCH 07/10] InPlane constraint handle more types --- cadquery/assembly.py | 11 +++--- tests/test_assembly.py | 82 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/cadquery/assembly.py b/cadquery/assembly.py index 3689a6273..663e9c3d1 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -4,7 +4,7 @@ from uuid import uuid1 as uuid from .cq import Workplane -from .occ_impl.shapes import Shape, Face, Edge +from .occ_impl.shapes import Shape, Face, Edge, Wire from .occ_impl.geom import Location, Vector, Plane from .occ_impl.assembly import Color from .occ_impl.solver import ( @@ -108,9 +108,12 @@ def _getAxis(self, arg: Shape) -> Vector: def _getPlane(self, arg: Shape) -> Plane: - # This is very duck typing. Might want to restrict types later to - # prevent confusion eg. making a plane from an spline Edge. - normal = self._getAxis(arg) + if isinstance(arg, Face): + normal = arg.normalAt() + elif isinstance(arg, (Edge, Wire)): + normal = arg.normal() + else: + raise ValueError(f"Can not get normal from {arg}.") origin = arg.Center() return Plane(origin, normal=normal) diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 22b1edb7d..9b5851c24 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -1,5 +1,6 @@ import pytest import os +from itertools import product import cadquery as cq from cadquery.occ_impl.exporters.assembly import exportAssembly, exportCAF @@ -313,3 +314,84 @@ def test_InPlane_param(box_and_vertex, param0, param1): assert z_offset - param0 < 1e-6 x_offset = abs(vertex_translation_part.X() - 0.5) assert x_offset - param1 < 1e-6 + + +def test_constraint_getPlane(): + """ + Test that _getPlane does the right thing with different arguments + """ + ids = (0, 1) + sublocs = (cq.Location(), cq.Location()) + + def make_constraint(shape0): + return cq.Constraint(ids, (shape0, shape0), sublocs, "InPlane", 0) + + def fail_this(shape0): + c0 = make_constraint(shape0) + with pytest.raises(ValueError): + c0._getPlane(c0.args[0]) + + def resulting_plane(shape0): + c0 = make_constraint(shape0) + return c0._getPlane(c0.args[0]) + + # point should fail + fail_this(cq.Vertex.makeVertex(0, 0, 0)) + + # line should fail + fail_this(cq.Edge.makeLine(cq.Vector(1, 0, 0), cq.Vector(0, 0, 0))) + + # planar edge (circle) should succeed + origin = cq.Vector(1, 2, 3) + direction = cq.Vector(4, 5, 6).normalized() + p1 = resulting_plane(cq.Edge.makeCircle(1, pnt=origin, dir=direction)) + assert p1.zDir == direction + assert p1.origin == origin + + # planar edge (spline) should succeed + # it's a touch risky calling a spline a planar edge, but lets see if it's within tolerance + points0 = [cq.Vector(x) for x in [(-1, 0, 1), (0, 1, 1), (1, 0, 1), (0, -1, 1)]] + planar_spline = cq.Edge.makeSpline(points0, periodic=True) + p2 = resulting_plane(planar_spline) + assert p2.origin == planar_spline.Center() + assert p2.zDir == cq.Vector(0, 0, 1) + + # non-planar edge should fail + points1 = [cq.Vector(x) for x in [(-1, 0, -1), (0, 1, 1), (1, 0, -1), (0, -1, 1)]] + nonplanar_spline = cq.Edge.makeSpline(points1, periodic=True) + fail_this(nonplanar_spline) + + # planar wire should succeed + # make a triangle in the XZ plane + points2 = [cq.Vector(x) for x in [(-1, 0, -1), (0, 0, 1), (1, 0, -1)]] + points2.append(points2[0]) + triangle = cq.Wire.makePolygon(points2) + p3 = resulting_plane(triangle) + assert p3.origin == triangle.Center() + assert p3.zDir == cq.Vector(0, 1, 0) + + # non-planar wire should fail + points3 = [cq.Vector(x) for x in [(-1, 0, -1), (0, 1, 1), (1, 0, 0), (0, -1, 1)]] + wonky_shape = cq.Wire.makePolygon(points3) + fail_this(wonky_shape) + + # all faces should succeed + for length, width in product([None, 10], [None, 11]): + f0 = cq.Face.makePlane( + length=length, width=width, basePnt=(1, 2, 3), dir=(1, 0, 0) + ) + p4 = resulting_plane(f0) + if length and width: + assert p4.origin == cq.Vector(1, 2, 3) + assert p4.zDir == cq.Vector(1, 0, 0) + + f1 = cq.Face.makeFromWires(triangle, []) + p5 = resulting_plane(f1) + # not sure why, but the origins only roughly line up + assert (p5.origin - triangle.Center()).Length < 0.1 + assert p5.zDir == cq.Vector(0, 1, 0) + + # shell... not sure? + + # solid should fail + fail_this(cq.Solid.makeBox(1, 1, 1)) From 55de2935992467844485fc152cab1c0ab522ae16 Mon Sep 17 00:00:00 2001 From: Marcus Boyd Date: Fri, 9 Apr 2021 21:21:28 +0930 Subject: [PATCH 08/10] Added signed param to InPlane constraint --- cadquery/occ_impl/solver.py | 5 ++++- tests/test_assembly.py | 11 ++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cadquery/occ_impl/solver.py b/cadquery/occ_impl/solver.py index d9fe1bfd2..19cc03cac 100644 --- a/cadquery/occ_impl/solver.py +++ b/cadquery/occ_impl/solver.py @@ -127,7 +127,10 @@ def pln_pnt_cost( val = 0 if val is None else val - return (val - (m1.Transformed(t1).Distance(m2.Transformed(t2)))) ** 2 + m1_located = m1.Transformed(t1) + # offset in the plane's normal direction by val: + m1_located.Translate(gp_Vec(m1_located.Axis().Direction()).Multiplied(val)) + return m1_located.SquareDistance(m2.Transformed(t2)) def f(x): """ diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 9b5851c24..4f9bee4e5 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -298,22 +298,19 @@ def test_InPlane_3_parts(box_and_vertex): assert vertex_translation_part.X() == pytest.approx(0.5) -@pytest.mark.parametrize("param1", range(3)) -@pytest.mark.parametrize("param0", range(3)) +@pytest.mark.parametrize("param1", [-1, 0, 2]) +@pytest.mark.parametrize("param0", [-2, 0, 0.01]) def test_InPlane_param(box_and_vertex, param0, param1): box_and_vertex.constrain("box@faces@>Z", "vertex", "InPlane", param=param0) box_and_vertex.constrain("box@faces@>X", "vertex", "InPlane", param=param1) box_and_vertex.solve() - # note that pytest.approx(0) has a tolerance of 1.0e-12, so can't use that here vertex_translation_part = ( box_and_vertex.children[1].loc.wrapped.Transformation().TranslationPart() ) - z_offset = abs(vertex_translation_part.Z() - 1.5) - assert z_offset - param0 < 1e-6 - x_offset = abs(vertex_translation_part.X() - 0.5) - assert x_offset - param1 < 1e-6 + assert vertex_translation_part.Z() - 1.5 == pytest.approx(param0, abs=1e-6) + assert vertex_translation_part.X() - 0.5 == pytest.approx(param1, abs=1e-6) def test_constraint_getPlane(): From daa28f9d0c589cc9956f30f5eaa4bf9d3dde5926 Mon Sep 17 00:00:00 2001 From: Marcus Boyd Date: Fri, 9 Apr 2021 21:30:08 +0930 Subject: [PATCH 09/10] spelling --- cadquery/assembly.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cadquery/assembly.py b/cadquery/assembly.py index 663e9c3d1..e943c1ea7 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -173,8 +173,8 @@ def __init__( """ construct an assembly - :param obj: root object of the assembly (deafault: None) - :param loc: location of the root object (deafault: None, interpreted as identity transformation) + :param obj: root object of the assembly (default: None) + :param loc: location of the root object (default: None, interpreted as identity transformation) :param name: unique name of the root object (default: None, reasulting in an UUID being generated) :param color: color of the added object (default: None) :return: An Assembly object. @@ -230,7 +230,7 @@ def add( Add a subassembly to the current assembly. :param obj: subassembly to be added - :param loc: location of the root object (deafault: None, resulting in the location stored in + :param loc: location of the root object (default: None, resulting in the location stored in the subassembly being used) :param name: unique name of the root object (default: None, resulting in the name stored in the subassembly being used) @@ -251,7 +251,7 @@ def add( Add a subassembly to the current assembly with explicit location and name. :param obj: object to be added as a subassembly - :param loc: location of the root object (deafault: None, interpreted as identity + :param loc: location of the root object (default: None, interpreted as identity transformation) :param name: unique name of the root object (default: None, resulting in an UUID being generated) @@ -291,7 +291,7 @@ def add(self, arg, **kwargs): def _query(self, q: str) -> Tuple[str, Optional[Shape]]: """ - Execute a selector query on the assembly. + Execute a selector query on the assembly. The query is expected to be in the following format: name[?tag][@kind@args] @@ -443,9 +443,9 @@ def save( ) -> "Assembly": """ save as STEP or OCCT native XML file - + :param path: filepath - :param exportType: export format (deafault: None, results in format being inferred form the path) + :param exportType: export format (default: None, results in format being inferred form the path) """ if exportType is None: From 8d9ffbc39aae17e680d6a66036c7ccf553384c66 Mon Sep 17 00:00:00 2001 From: Marcus Boyd Date: Sat, 10 Apr 2021 15:29:40 +0930 Subject: [PATCH 10/10] renamed InPlane constraint to PointInPlane also reordered arguments to point then plane --- cadquery/assembly.py | 8 +++---- cadquery/occ_impl/solver.py | 28 +++++++++++------------ tests/test_assembly.py | 45 ++++++++++++++++++------------------- 3 files changed, 40 insertions(+), 41 deletions(-) diff --git a/cadquery/assembly.py b/cadquery/assembly.py index e943c1ea7..60c2d42b6 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -18,7 +18,7 @@ # type definitions AssemblyObjects = Union[Shape, Workplane, None] -ConstraintKinds = Literal["Plane", "Point", "Axis", "InPlane"] +ConstraintKinds = Literal["Plane", "Point", "Axis", "PointInPlane"] ExportLiterals = Literal["STEP", "XML"] PATH_DELIM = "/" @@ -134,11 +134,11 @@ def toPOD(self) -> ConstraintPOD: rv.append((arg.Center().toPnt(),)) elif self.kind == "Plane": rv.append((self._getAxis(arg).toDir(), arg.Center().toPnt())) - elif self.kind == "InPlane": + elif self.kind == "PointInPlane": if idx == 0: - rv.append((self._getPlane(arg).toPln(),)) - else: rv.append((arg.Center().toPnt(),)) + else: + rv.append((self._getPlane(arg).toPln(),)) else: raise ValueError(f"Unknown constraint kind {self.kind}") diff --git a/cadquery/occ_impl/solver.py b/cadquery/occ_impl/solver.py index 19cc03cac..f5850c18e 100644 --- a/cadquery/occ_impl/solver.py +++ b/cadquery/occ_impl/solver.py @@ -117,9 +117,9 @@ def dir_cost( DIR_SCALING * (val - m1.Transformed(t1).Angle(m2.Transformed(t2))) ** 2 ) - def pln_pnt_cost( - m1: gp_Pln, - m2: gp_Pnt, + def pnt_pln_cost( + m1: gp_Pnt, + m2: gp_Pln, t1: gp_Trsf, t2: gp_Trsf, val: Optional[float] = None, @@ -127,10 +127,10 @@ def pln_pnt_cost( val = 0 if val is None else val - m1_located = m1.Transformed(t1) + m2_located = m2.Transformed(t2) # offset in the plane's normal direction by val: - m1_located.Translate(gp_Vec(m1_located.Axis().Direction()).Multiplied(val)) - return m1_located.SquareDistance(m2.Transformed(t2)) + m2_located.Translate(gp_Vec(m2_located.Axis().Direction()).Multiplied(val)) + return m2_located.SquareDistance(m1.Transformed(t1)) def f(x): """ @@ -151,12 +151,12 @@ def f(x): t2 = transforms[k2] if k2 not in self.locked else gp_Trsf() for m1, m2 in zip(ms1, ms2): - if isinstance(m1, gp_Pnt): + if isinstance(m1, gp_Pnt) and isinstance(m2, gp_Pnt): rv += pt_cost(m1, m2, t1, t2, d) elif isinstance(m1, gp_Dir): rv += dir_cost(m1, m2, t1, t2, d) - elif isinstance(m1, gp_Pln): - rv += pln_pnt_cost(m1, m2, t1, t2, d) + elif isinstance(m1, gp_Pnt) and isinstance(m2, gp_Pln): + rv += pnt_pln_cost(m1, m2, t1, t2, d) else: raise NotImplementedError(f"{m1,m2}") @@ -186,7 +186,7 @@ def jac(x): t2 = transforms[k2] if k2 not in self.locked else gp_Trsf() for m1, m2 in zip(ms1, ms2): - if isinstance(m1, gp_Pnt): + if isinstance(m1, gp_Pnt) and isinstance(m2, gp_Pnt): tmp = pt_cost(m1, m2, t1, t2, d) for j in range(NDOF): @@ -218,8 +218,8 @@ def jac(x): tmp2 = dir_cost(m1, m2, t1, t2j, d) rv[k2 * NDOF + j] += (tmp2 - tmp) / DIFF_EPS - elif isinstance(m1, gp_Pln): - tmp = pln_pnt_cost(m1, m2, t1, t2, d) + elif isinstance(m1, gp_Pnt) and isinstance(m2, gp_Pln): + tmp = pnt_pln_cost(m1, m2, t1, t2, d) for j in range(NDOF): @@ -227,11 +227,11 @@ def jac(x): t2j = transforms_delta[k2 * NDOF + j] if k1 not in self.locked: - tmp1 = pln_pnt_cost(m1, m2, t1j, t2, d) + tmp1 = pnt_pln_cost(m1, m2, t1j, t2, d) rv[k1 * NDOF + j] += (tmp1 - tmp) / DIFF_EPS if k2 not in self.locked: - tmp2 = pln_pnt_cost(m1, m2, t1, t2j, d) + tmp2 = pnt_pln_cost(m1, m2, t1, t2j, d) rv[k2 * NDOF + j] += (tmp2 - tmp) / DIFF_EPS else: raise NotImplementedError(f"{m1,m2}") diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 4f9bee4e5..fa06a403a 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -47,9 +47,8 @@ def nested_assy(): @pytest.fixture def box_and_vertex(): - assy = cq.Assembly() box_wp = cq.Workplane().box(1, 2, 3) - assy.add(box_wp, name="box") + assy = cq.Assembly(box_wp, name="box") vertex_wp = cq.Workplane().newObject([cq.Vertex.makeVertex(0, 0, 0)]) assy.add(vertex_wp, name="vertex") return assy @@ -239,60 +238,60 @@ def test_expression_grammar(nested_assy): ) -def test_InPlane_constraint(box_and_vertex): +def test_PointInPlane_constraint(box_and_vertex): # add first constraint box_and_vertex.constrain( - "box", - box_and_vertex.children[0].obj.faces(">X").val(), "vertex", - box_and_vertex.children[1].obj.val(), - "InPlane", + box_and_vertex.children[0].obj.val(), + "box", + box_and_vertex.obj.faces(">X").val(), + "PointInPlane", param=0, ) box_and_vertex.solve() x_pos = ( - box_and_vertex.children[1].loc.wrapped.Transformation().TranslationPart().X() + box_and_vertex.children[0].loc.wrapped.Transformation().TranslationPart().X() ) assert x_pos == pytest.approx(0.5) - # add a second InPlane constraint - box_and_vertex.constrain("box@faces@>Y", "vertex", "InPlane", param=0) + # add a second PointInPlane constraint + box_and_vertex.constrain("vertex", "box@faces@>Y", "PointInPlane", param=0) box_and_vertex.solve() vertex_translation_part = ( - box_and_vertex.children[1].loc.wrapped.Transformation().TranslationPart() + box_and_vertex.children[0].loc.wrapped.Transformation().TranslationPart() ) # should still be on the >X face from the first constraint assert vertex_translation_part.X() == pytest.approx(0.5) # now should additionally be on the >Y face assert vertex_translation_part.Y() == pytest.approx(1) - # add a third InPlane constraint - box_and_vertex.constrain("box@faces@>Z", "vertex", "InPlane", param=0) + # add a third PointInPlane constraint + box_and_vertex.constrain("vertex", "box@faces@>Z", "PointInPlane", param=0) box_and_vertex.solve() # should now be on the >X and >Y and >Z corner assert ( - box_and_vertex.children[1] + box_and_vertex.children[0] .loc.wrapped.Transformation() .TranslationPart() .IsEqual(gp_XYZ(0.5, 1, 1.5), 1e-6) ) -def test_InPlane_3_parts(box_and_vertex): +def test_PointInPlane_3_parts(box_and_vertex): cylinder_height = 2 cylinder = cq.Workplane().circle(0.1).extrude(cylinder_height) box_and_vertex.add(cylinder, name="cylinder") box_and_vertex.constrain("box@faces@>Z", "cylinder@faces@Z", "vertex", "InPlane") - box_and_vertex.constrain("box@faces@>X", "vertex", "InPlane") + box_and_vertex.constrain("vertex", "cylinder@faces@>Z", "PointInPlane") + box_and_vertex.constrain("vertex", "box@faces@>X", "PointInPlane") box_and_vertex.solve() vertex_translation_part = ( - box_and_vertex.children[1].loc.wrapped.Transformation().TranslationPart() + box_and_vertex.children[0].loc.wrapped.Transformation().TranslationPart() ) assert vertex_translation_part.Z() == pytest.approx(1.5 + cylinder_height) assert vertex_translation_part.X() == pytest.approx(0.5) @@ -300,14 +299,14 @@ def test_InPlane_3_parts(box_and_vertex): @pytest.mark.parametrize("param1", [-1, 0, 2]) @pytest.mark.parametrize("param0", [-2, 0, 0.01]) -def test_InPlane_param(box_and_vertex, param0, param1): +def test_PointInPlane_param(box_and_vertex, param0, param1): - box_and_vertex.constrain("box@faces@>Z", "vertex", "InPlane", param=param0) - box_and_vertex.constrain("box@faces@>X", "vertex", "InPlane", param=param1) + box_and_vertex.constrain("vertex", "box@faces@>Z", "PointInPlane", param=param0) + box_and_vertex.constrain("vertex", "box@faces@>X", "PointInPlane", param=param1) box_and_vertex.solve() vertex_translation_part = ( - box_and_vertex.children[1].loc.wrapped.Transformation().TranslationPart() + box_and_vertex.children[0].loc.wrapped.Transformation().TranslationPart() ) assert vertex_translation_part.Z() - 1.5 == pytest.approx(param0, abs=1e-6) assert vertex_translation_part.X() - 0.5 == pytest.approx(param1, abs=1e-6) @@ -321,7 +320,7 @@ def test_constraint_getPlane(): sublocs = (cq.Location(), cq.Location()) def make_constraint(shape0): - return cq.Constraint(ids, (shape0, shape0), sublocs, "InPlane", 0) + return cq.Constraint(ids, (shape0, shape0), sublocs, "PointInPlane", 0) def fail_this(shape0): c0 = make_constraint(shape0)