Skip to content

Commit b0a8cb2

Browse files
authored
PointInPlane constraint (#712)
* Allow Plane init with no specific x dir and add Plane.toPln * PointInPlane constraint
1 parent a71a93e commit b0a8cb2

File tree

5 files changed

+318
-54
lines changed

5 files changed

+318
-54
lines changed

cadquery/assembly.py

Lines changed: 53 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
from uuid import uuid1 as uuid
55

66
from .cq import Workplane
7-
from .occ_impl.shapes import Shape, Face, Edge
8-
from .occ_impl.geom import Location, Vector
7+
from .occ_impl.shapes import Shape, Face, Edge, Wire
8+
from .occ_impl.geom import Location, Vector, Plane
99
from .occ_impl.assembly import Color
1010
from .occ_impl.solver import (
1111
ConstraintSolver,
@@ -18,7 +18,7 @@
1818

1919
# type definitions
2020
AssemblyObjects = Union[Shape, Workplane, None]
21-
ConstraintKinds = Literal["Plane", "Point", "Axis"]
21+
ConstraintKinds = Literal["Plane", "Point", "Axis", "PointInPlane"]
2222
ExportLiterals = Literal["STEP", "XML"]
2323

2424
PATH_DELIM = "/"
@@ -79,7 +79,7 @@ def __init__(
7979
):
8080
"""
8181
Construct a constraint.
82-
82+
8383
:param objects: object names refernced in the constraint
8484
:param args: subshapes (e.g. faces or edges) of the objects
8585
:param sublocs: locations of the objects (only relevant if the objects are nested in a sub-assembly)
@@ -106,14 +106,25 @@ def _getAxis(self, arg: Shape) -> Vector:
106106

107107
return rv
108108

109+
def _getPlane(self, arg: Shape) -> Plane:
110+
111+
if isinstance(arg, Face):
112+
normal = arg.normalAt()
113+
elif isinstance(arg, (Edge, Wire)):
114+
normal = arg.normal()
115+
else:
116+
raise ValueError(f"Can not get normal from {arg}.")
117+
origin = arg.Center()
118+
return Plane(origin, normal=normal)
119+
109120
def toPOD(self) -> ConstraintPOD:
110121
"""
111122
Convert the constraint to a representation used by the solver.
112123
"""
113124

114125
rv: List[Tuple[ConstraintMarker, ...]] = []
115126

116-
for arg, loc in zip(self.args, self.sublocs):
127+
for idx, (arg, loc) in enumerate(zip(self.args, self.sublocs)):
117128

118129
arg = arg.located(loc * arg.location())
119130

@@ -123,6 +134,11 @@ def toPOD(self) -> ConstraintPOD:
123134
rv.append((arg.Center().toPnt(),))
124135
elif self.kind == "Plane":
125136
rv.append((self._getAxis(arg).toDir(), arg.Center().toPnt()))
137+
elif self.kind == "PointInPlane":
138+
if idx == 0:
139+
rv.append((arg.Center().toPnt(),))
140+
else:
141+
rv.append((self._getPlane(arg).toPln(),))
126142
else:
127143
raise ValueError(f"Unknown constraint kind {self.kind}")
128144

@@ -157,22 +173,22 @@ def __init__(
157173
"""
158174
construct an assembly
159175
160-
:param obj: root object of the assembly (deafault: None)
161-
:param loc: location of the root object (deafault: None, interpreted as identity transformation)
176+
:param obj: root object of the assembly (default: None)
177+
:param loc: location of the root object (default: None, interpreted as identity transformation)
162178
:param name: unique name of the root object (default: None, reasulting in an UUID being generated)
163179
:param color: color of the added object (default: None)
164180
:return: An Assembly object.
165-
166-
181+
182+
167183
To create an empty assembly use::
168-
184+
169185
assy = Assembly(None)
170-
186+
171187
To create one constraint a root object::
172-
188+
173189
b = Workplane().box(1,1,1)
174190
assy = Assembly(b, Location(Vector(0,0,1)), name="root")
175-
191+
176192
"""
177193

178194
self.obj = obj
@@ -211,12 +227,15 @@ def add(
211227
color: Optional[Color] = None,
212228
) -> "Assembly":
213229
"""
214-
add a subassembly to the current assembly.
215-
230+
Add a subassembly to the current assembly.
231+
216232
:param obj: subassembly to be added
217-
:param loc: location of the root object (deafault: None, resulting in the location stored in the subassembly being used)
218-
:param name: unique name of the root object (default: None, resulting in the name stored in the subassembly being used)
219-
:param color: color of the added object (default: None, resulting in the color stored in the subassembly being used)
233+
:param loc: location of the root object (default: None, resulting in the location stored in
234+
the subassembly being used)
235+
:param name: unique name of the root object (default: None, resulting in the name stored in
236+
the subassembly being used)
237+
:param color: color of the added object (default: None, resulting in the color stored in the
238+
subassembly being used)
220239
"""
221240
...
222241

@@ -229,18 +248,20 @@ def add(
229248
color: Optional[Color] = None,
230249
) -> "Assembly":
231250
"""
232-
add a subassembly to the current assembly with explicit location and name
233-
251+
Add a subassembly to the current assembly with explicit location and name.
252+
234253
:param obj: object to be added as a subassembly
235-
:param loc: location of the root object (deafault: None, interpreted as identity transformation)
236-
:param name: unique name of the root object (default: None, resulting in an UUID being generated)
254+
:param loc: location of the root object (default: None, interpreted as identity
255+
transformation)
256+
:param name: unique name of the root object (default: None, resulting in an UUID being
257+
generated)
237258
:param color: color of the added object (default: None)
238259
"""
239260
...
240261

241262
def add(self, arg, **kwargs):
242263
"""
243-
add a subassembly to the current assembly.
264+
Add a subassembly to the current assembly.
244265
"""
245266

246267
if isinstance(arg, Assembly):
@@ -270,18 +291,18 @@ def add(self, arg, **kwargs):
270291

271292
def _query(self, q: str) -> Tuple[str, Optional[Shape]]:
272293
"""
273-
Execute a selector query on the assembly.
294+
Execute a selector query on the assembly.
274295
The query is expected to be in the following format:
275-
296+
276297
name[?tag][@kind@args]
277-
298+
278299
valid example include:
279-
300+
280301
obj_name @ faces @ >Z
281-
obj_name?tag1@faces@>Z
302+
obj_name?tag1@faces@>Z
282303
obj_name ? tag
283304
obj_name
284-
305+
285306
"""
286307

287308
tmp: Workplane
@@ -311,7 +332,7 @@ def _query(self, q: str) -> Tuple[str, Optional[Shape]]:
311332
def _subloc(self, name: str) -> Tuple[Location, str]:
312333
"""
313334
Calculate relative location of an object in a subassembly.
314-
335+
315336
Returns the relative posiitons as well as the name of the top assembly.
316337
"""
317338

@@ -422,9 +443,9 @@ def save(
422443
) -> "Assembly":
423444
"""
424445
save as STEP or OCCT native XML file
425-
446+
426447
:param path: filepath
427-
:param exportType: export format (deafault: None, results in format being inferred form the path)
448+
:param exportType: export format (default: None, results in format being inferred form the path)
428449
"""
429450

430451
if exportType is None:

cadquery/occ_impl/geom.py

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,18 @@
22

33
from typing import overload, Sequence, Union, Tuple, Type, Optional
44

5-
from OCP.gp import gp_Vec, gp_Ax1, gp_Ax3, gp_Pnt, gp_Dir, gp_Trsf, gp_GTrsf, gp, gp_XYZ
5+
from OCP.gp import (
6+
gp_Vec,
7+
gp_Ax1,
8+
gp_Ax3,
9+
gp_Pnt,
10+
gp_Dir,
11+
gp_Pln,
12+
gp_Trsf,
13+
gp_GTrsf,
14+
gp_XYZ,
15+
gp,
16+
)
617
from OCP.Bnd import Bnd_Box
718
from OCP.BRepBndLib import BRepBndLib
819
from OCP.BRepMesh import BRepMesh_IncrementalMesh
@@ -500,30 +511,35 @@ def bottom(cls, origin=(0, 0, 0), xDir=Vector(1, 0, 0)):
500511
plane._setPlaneDir(xDir)
501512
return plane
502513

503-
def __init__(self, origin, xDir, normal):
504-
"""Create a Plane with an arbitrary orientation
505-
506-
TODO: project x and y vectors so they work even if not orthogonal
507-
:param origin: the origin
508-
:type origin: a three-tuple of the origin, in global coordinates
509-
:param xDir: a vector representing the xDirection.
510-
:type xDir: a three-tuple representing a vector, or a FreeCAD Vector
511-
:param normal: the normal direction for the new plane
512-
:type normal: a FreeCAD Vector
513-
:raises: ValueError if the specified xDir is not orthogonal to the provided normal.
514-
:return: a plane in the global space, with the xDirection of the plane in the specified direction.
514+
def __init__(
515+
self,
516+
origin: Union[Tuple[float, float, float], Vector],
517+
xDir: Optional[Union[Tuple[float, float, float], Vector]] = None,
518+
normal: Union[Tuple[float, float, float], Vector] = (0, 0, 1),
519+
):
520+
"""
521+
Create a Plane with an arbitrary orientation
522+
523+
:param origin: the origin in global coordinates
524+
:param xDir: an optional vector representing the xDirection.
525+
:param normal: the normal direction for the plane
526+
:raises ValueError: if the specified xDir is not orthogonal to the provided normal
515527
"""
516528
zDir = Vector(normal)
517529
if zDir.Length == 0.0:
518530
raise ValueError("normal should be non null")
519531

520-
xDir = Vector(xDir)
521-
if xDir.Length == 0.0:
522-
raise ValueError("xDir should be non null")
523-
524532
self.zDir = zDir.normalized()
533+
534+
if xDir is None:
535+
ax3 = gp_Ax3(Vector(origin).toPnt(), Vector(normal).toDir())
536+
xDir = Vector(ax3.XDirection())
537+
else:
538+
xDir = Vector(xDir)
539+
if xDir.Length == 0.0:
540+
raise ValueError("xDir should be non null")
525541
self._setPlaneDir(xDir)
526-
self.origin = origin
542+
self.origin = Vector(origin)
527543

528544
def _eq_iter(self, other):
529545
"""Iterator to successively test equality"""
@@ -725,6 +741,10 @@ def location(self) -> "Location":
725741

726742
return Location(self)
727743

744+
def toPln(self) -> gp_Pln:
745+
746+
return gp_Pln(gp_Ax3(self.origin.toPnt(), self.zDir.toDir(), self.xDir.toDir()))
747+
728748

729749
class BoundBox(object):
730750
"""A BoundingBox for an object or set of objects. Wraps the OCP one"""

cadquery/occ_impl/solver.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44
from numpy import array, eye, zeros, pi
55
from scipy.optimize import minimize
66

7-
from OCP.gp import gp_Vec, gp_Dir, gp_Pnt, gp_Trsf, gp_Quaternion
7+
from OCP.gp import gp_Vec, gp_Pln, gp_Lin, gp_Dir, gp_Pnt, gp_Trsf, gp_Quaternion
88

99
from .geom import Location
1010

1111
DOF6 = Tuple[float, float, float, float, float, float]
12-
ConstraintMarker = Union[gp_Dir, gp_Pnt]
12+
ConstraintMarker = Union[gp_Pln, gp_Dir, gp_Pnt]
1313
Constraint = Tuple[
1414
Tuple[ConstraintMarker, ...], Tuple[Optional[ConstraintMarker], ...], Optional[Any]
1515
]
@@ -117,7 +117,25 @@ def dir_cost(
117117
DIR_SCALING * (val - m1.Transformed(t1).Angle(m2.Transformed(t2))) ** 2
118118
)
119119

120+
def pnt_pln_cost(
121+
m1: gp_Pnt,
122+
m2: gp_Pln,
123+
t1: gp_Trsf,
124+
t2: gp_Trsf,
125+
val: Optional[float] = None,
126+
) -> float:
127+
128+
val = 0 if val is None else val
129+
130+
m2_located = m2.Transformed(t2)
131+
# offset in the plane's normal direction by val:
132+
m2_located.Translate(gp_Vec(m2_located.Axis().Direction()).Multiplied(val))
133+
return m2_located.SquareDistance(m1.Transformed(t1))
134+
120135
def f(x):
136+
"""
137+
Function to be minimized
138+
"""
121139

122140
constraints = self.constraints
123141
ne = self.ne
@@ -133,10 +151,12 @@ def f(x):
133151
t2 = transforms[k2] if k2 not in self.locked else gp_Trsf()
134152

135153
for m1, m2 in zip(ms1, ms2):
136-
if isinstance(m1, gp_Pnt):
154+
if isinstance(m1, gp_Pnt) and isinstance(m2, gp_Pnt):
137155
rv += pt_cost(m1, m2, t1, t2, d)
138156
elif isinstance(m1, gp_Dir):
139157
rv += dir_cost(m1, m2, t1, t2, d)
158+
elif isinstance(m1, gp_Pnt) and isinstance(m2, gp_Pln):
159+
rv += pnt_pln_cost(m1, m2, t1, t2, d)
140160
else:
141161
raise NotImplementedError(f"{m1,m2}")
142162

@@ -166,7 +186,7 @@ def jac(x):
166186
t2 = transforms[k2] if k2 not in self.locked else gp_Trsf()
167187

168188
for m1, m2 in zip(ms1, ms2):
169-
if isinstance(m1, gp_Pnt):
189+
if isinstance(m1, gp_Pnt) and isinstance(m2, gp_Pnt):
170190
tmp = pt_cost(m1, m2, t1, t2, d)
171191

172192
for j in range(NDOF):
@@ -197,6 +217,22 @@ def jac(x):
197217
if k2 not in self.locked:
198218
tmp2 = dir_cost(m1, m2, t1, t2j, d)
199219
rv[k2 * NDOF + j] += (tmp2 - tmp) / DIFF_EPS
220+
221+
elif isinstance(m1, gp_Pnt) and isinstance(m2, gp_Pln):
222+
tmp = pnt_pln_cost(m1, m2, t1, t2, d)
223+
224+
for j in range(NDOF):
225+
226+
t1j = transforms_delta[k1 * NDOF + j]
227+
t2j = transforms_delta[k2 * NDOF + j]
228+
229+
if k1 not in self.locked:
230+
tmp1 = pnt_pln_cost(m1, m2, t1j, t2, d)
231+
rv[k1 * NDOF + j] += (tmp1 - tmp) / DIFF_EPS
232+
233+
if k2 not in self.locked:
234+
tmp2 = pnt_pln_cost(m1, m2, t1, t2j, d)
235+
rv[k2 * NDOF + j] += (tmp2 - tmp) / DIFF_EPS
200236
else:
201237
raise NotImplementedError(f"{m1,m2}")
202238

0 commit comments

Comments
 (0)