Skip to content

Commit 3cb7237

Browse files
Assy solver fix (#806)
* Use nlopt in place of scipy.optimize * report the results of optimization * Deps update and OCP 7.5 -> 7.5.1 * Rewrite cost and gradient * Check solve results
1 parent f422dfe commit 3cb7237

File tree

5 files changed

+61
-36
lines changed

5 files changed

+61
-36
lines changed

cadquery/assembly.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@ class Assembly(object):
163163
objects: Dict[str, "Assembly"]
164164
constraints: List[Constraint]
165165

166+
_solve_result: Optional[Dict[str, Any]]
167+
166168
def __init__(
167169
self,
168170
obj: AssemblyObjects = None,
@@ -201,6 +203,8 @@ def __init__(
201203
self.constraints = []
202204
self.objects = {self.name: self}
203205

206+
self._solve_result = None
207+
204208
def _copy(self) -> "Assembly":
205209
"""
206210
Make a deep copy of an assembly
@@ -430,7 +434,7 @@ def solve(self) -> "Assembly":
430434
solver = ConstraintSolver(locs, constraints, locked=[lock_ix])
431435

432436
# solve
433-
locs_new = solver.solve()
437+
locs_new, self._solve_result = solver.solve()
434438

435439
# update positions
436440
for loc_new, n in zip(locs_new, ents):

cadquery/occ_impl/solver.py

Lines changed: 43 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
from typing import Tuple, Union, Any, Callable, List, Optional
1+
from typing import Tuple, Union, Any, Callable, List, Optional, Dict
22
from nptyping import NDArray as Array
33

44
from numpy import array, eye, zeros, pi
5-
from scipy.optimize import minimize
5+
import nlopt
66

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

99
from .geom import Location
1010

@@ -15,8 +15,10 @@
1515
]
1616

1717
NDOF = 6
18-
DIR_SCALING = 1e4
19-
DIFF_EPS = 1e-9
18+
DIR_SCALING = 1e2
19+
DIFF_EPS = 1e-10
20+
TOL = 1e-12
21+
MAXITER = 2000
2022

2123

2224
class ConstraintSolver(object):
@@ -99,9 +101,7 @@ def pt_cost(
99101

100102
val = 0 if val is None else val
101103

102-
return (
103-
val - (m1.Transformed(t1).XYZ() - m2.Transformed(t2).XYZ()).Modulus()
104-
) ** 2
104+
return val - (m1.Transformed(t1).XYZ() - m2.Transformed(t2).XYZ()).Modulus()
105105

106106
def dir_cost(
107107
m1: gp_Dir,
@@ -113,9 +113,7 @@ def dir_cost(
113113

114114
val = pi if val is None else val
115115

116-
return (
117-
DIR_SCALING * (val - m1.Transformed(t1).Angle(m2.Transformed(t2))) ** 2
118-
)
116+
return DIR_SCALING * (val - m1.Transformed(t1).Angle(m2.Transformed(t2)))
119117

120118
def pnt_pln_cost(
121119
m1: gp_Pnt,
@@ -130,7 +128,7 @@ def pnt_pln_cost(
130128
m2_located = m2.Transformed(t2)
131129
# offset in the plane's normal direction by val:
132130
m2_located.Translate(gp_Vec(m2_located.Axis().Direction()).Multiplied(val))
133-
return m2_located.SquareDistance(m1.Transformed(t1))
131+
return m2_located.Distance(m1.Transformed(t1))
134132

135133
def f(x):
136134
"""
@@ -152,11 +150,11 @@ def f(x):
152150

153151
for m1, m2 in zip(ms1, ms2):
154152
if isinstance(m1, gp_Pnt) and isinstance(m2, gp_Pnt):
155-
rv += pt_cost(m1, m2, t1, t2, d)
153+
rv += pt_cost(m1, m2, t1, t2, d) ** 2
156154
elif isinstance(m1, gp_Dir):
157-
rv += dir_cost(m1, m2, t1, t2, d)
155+
rv += dir_cost(m1, m2, t1, t2, d) ** 2
158156
elif isinstance(m1, gp_Pnt) and isinstance(m2, gp_Pln):
159-
rv += pnt_pln_cost(m1, m2, t1, t2, d)
157+
rv += pnt_pln_cost(m1, m2, t1, t2, d) ** 2
160158
else:
161159
raise NotImplementedError(f"{m1,m2}")
162160

@@ -196,11 +194,11 @@ def jac(x):
196194

197195
if k1 not in self.locked:
198196
tmp1 = pt_cost(m1, m2, t1j, t2, d)
199-
rv[k1 * NDOF + j] += (tmp1 - tmp) / DIFF_EPS
197+
rv[k1 * NDOF + j] += 2 * tmp * (tmp1 - tmp) / DIFF_EPS
200198

201199
if k2 not in self.locked:
202200
tmp2 = pt_cost(m1, m2, t1, t2j, d)
203-
rv[k2 * NDOF + j] += (tmp2 - tmp) / DIFF_EPS
201+
rv[k2 * NDOF + j] += 2 * tmp * (tmp2 - tmp) / DIFF_EPS
204202

205203
elif isinstance(m1, gp_Dir):
206204
tmp = dir_cost(m1, m2, t1, t2, d)
@@ -212,11 +210,11 @@ def jac(x):
212210

213211
if k1 not in self.locked:
214212
tmp1 = dir_cost(m1, m2, t1j, t2, d)
215-
rv[k1 * NDOF + j] += (tmp1 - tmp) / DIFF_EPS
213+
rv[k1 * NDOF + j] += 2 * tmp * (tmp1 - tmp) / DIFF_EPS
216214

217215
if k2 not in self.locked:
218216
tmp2 = dir_cost(m1, m2, t1, t2j, d)
219-
rv[k2 * NDOF + j] += (tmp2 - tmp) / DIFF_EPS
217+
rv[k2 * NDOF + j] += 2 * tmp * (tmp2 - tmp) / DIFF_EPS
220218

221219
elif isinstance(m1, gp_Pnt) and isinstance(m2, gp_Pln):
222220
tmp = pnt_pln_cost(m1, m2, t1, t2, d)
@@ -228,34 +226,48 @@ def jac(x):
228226

229227
if k1 not in self.locked:
230228
tmp1 = pnt_pln_cost(m1, m2, t1j, t2, d)
231-
rv[k1 * NDOF + j] += (tmp1 - tmp) / DIFF_EPS
229+
rv[k1 * NDOF + j] += 2 * tmp * (tmp1 - tmp) / DIFF_EPS
232230

233231
if k2 not in self.locked:
234232
tmp2 = pnt_pln_cost(m1, m2, t1, t2j, d)
235-
rv[k2 * NDOF + j] += (tmp2 - tmp) / DIFF_EPS
233+
rv[k2 * NDOF + j] += 2 * tmp * (tmp2 - tmp) / DIFF_EPS
236234
else:
237235
raise NotImplementedError(f"{m1,m2}")
238236

239237
return rv
240238

241239
return f, jac
242240

243-
def solve(self) -> List[Location]:
241+
def solve(self) -> Tuple[List[Location], Dict[str, Any]]:
244242

245243
x0 = array([el for el in self.entities]).ravel()
246244
f, jac = self._cost()
247245

248-
res = minimize(
249-
f,
250-
x0,
251-
jac=jac,
252-
method="BFGS",
253-
options=dict(disp=True, gtol=1e-12, maxiter=1000),
254-
)
246+
def func(x, grad):
247+
248+
if grad.size > 0:
249+
grad[:] = jac(x)
250+
251+
return f(x)
252+
253+
opt = nlopt.opt(nlopt.LD_CCSAQ, len(x0))
254+
opt.set_min_objective(func)
255+
256+
opt.set_ftol_abs(0)
257+
opt.set_ftol_rel(0)
258+
opt.set_xtol_rel(TOL)
259+
opt.set_xtol_abs(0)
260+
opt.set_maxeval(MAXITER)
255261

256-
x = res.x
262+
x = opt.optimize(x0)
263+
result = {
264+
"cost": opt.last_optimum_value(),
265+
"iters": opt.get_numevals(),
266+
"status": opt.last_optimize_result(),
267+
}
257268

258-
return [
269+
locs = [
259270
Location(self._build_transform(*x[NDOF * i : NDOF * (i + 1)]))
260271
for i in range(self.ne)
261272
]
273+
return locs, result

conda/meta.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@ requirements:
1515
- setuptools
1616
run:
1717
- python {{ environ.get('PYTHON_VERSION') }}
18-
- ocp 7.5
18+
- ocp 7.5.1
1919
- pyparsing 2.*
2020
- ezdxf
2121
- ipython
2222
- typing_extensions
2323
- nptyping
24-
- scipy
25-
24+
- nlopt
25+
2626
test:
2727
requires:
2828
- pytest

environment.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ dependencies:
1919
- ipython
2020
- typing_extensions
2121
- nptyping
22-
- scipy
22+
- nlopt
2323
- path
2424
- pip
2525
- pip:

tests/test_assembly.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import os
33
from itertools import product
44

5+
import nlopt
56
import cadquery as cq
67
from cadquery.occ_impl.exporters.assembly import (
78
exportAssembly,
@@ -227,6 +228,10 @@ def test_constrain(simple_assy, nested_assy):
227228

228229
simple_assy.solve()
229230

231+
assert simple_assy._solve_result["status"] == nlopt.XTOL_REACHED
232+
assert simple_assy._solve_result["cost"] < 1e-9
233+
assert simple_assy._solve_result["iters"] > 0
234+
230235
assert (
231236
simple_assy.loc.wrapped.Transformation()
232237
.TranslationPart()
@@ -242,6 +247,10 @@ def test_constrain(simple_assy, nested_assy):
242247

243248
nested_assy.solve()
244249

250+
assert nested_assy._solve_result["status"] == nlopt.XTOL_REACHED
251+
assert nested_assy._solve_result["cost"] < 1e-9
252+
assert nested_assy._solve_result["iters"] > 0
253+
245254
assert (
246255
nested_assy.children[0]
247256
.loc.wrapped.Transformation()

0 commit comments

Comments
 (0)