Coverage for /usr/lib/python3/dist-packages/matplotlib/bezier.py: 16%

224 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2025-06-14 15:25 +0200

1""" 

2A module providing some utility functions regarding Bézier path manipulation. 

3""" 

4 

5from functools import lru_cache 

6import math 

7import warnings 

8 

9import numpy as np 

10 

11from matplotlib import _api 

12 

13 

14# same algorithm as 3.8's math.comb 

15@np.vectorize 

16@lru_cache(maxsize=128) 

17def _comb(n, k): 

18 if k > n: 

19 return 0 

20 k = min(k, n - k) 

21 i = np.arange(1, k + 1) 

22 return np.prod((n + 1 - i)/i).astype(int) 

23 

24 

25class NonIntersectingPathException(ValueError): 

26 pass 

27 

28 

29# some functions 

30 

31 

32def get_intersection(cx1, cy1, cos_t1, sin_t1, 

33 cx2, cy2, cos_t2, sin_t2): 

34 """ 

35 Return the intersection between the line through (*cx1*, *cy1*) at angle 

36 *t1* and the line through (*cx2*, *cy2*) at angle *t2*. 

37 """ 

38 

39 # line1 => sin_t1 * (x - cx1) - cos_t1 * (y - cy1) = 0. 

40 # line1 => sin_t1 * x + cos_t1 * y = sin_t1*cx1 - cos_t1*cy1 

41 

42 line1_rhs = sin_t1 * cx1 - cos_t1 * cy1 

43 line2_rhs = sin_t2 * cx2 - cos_t2 * cy2 

44 

45 # rhs matrix 

46 a, b = sin_t1, -cos_t1 

47 c, d = sin_t2, -cos_t2 

48 

49 ad_bc = a * d - b * c 

50 if abs(ad_bc) < 1e-12: 

51 raise ValueError("Given lines do not intersect. Please verify that " 

52 "the angles are not equal or differ by 180 degrees.") 

53 

54 # rhs_inverse 

55 a_, b_ = d, -b 

56 c_, d_ = -c, a 

57 a_, b_, c_, d_ = [k / ad_bc for k in [a_, b_, c_, d_]] 

58 

59 x = a_ * line1_rhs + b_ * line2_rhs 

60 y = c_ * line1_rhs + d_ * line2_rhs 

61 

62 return x, y 

63 

64 

65def get_normal_points(cx, cy, cos_t, sin_t, length): 

66 """ 

67 For a line passing through (*cx*, *cy*) and having an angle *t*, return 

68 locations of the two points located along its perpendicular line at the 

69 distance of *length*. 

70 """ 

71 

72 if length == 0.: 

73 return cx, cy, cx, cy 

74 

75 cos_t1, sin_t1 = sin_t, -cos_t 

76 cos_t2, sin_t2 = -sin_t, cos_t 

77 

78 x1, y1 = length * cos_t1 + cx, length * sin_t1 + cy 

79 x2, y2 = length * cos_t2 + cx, length * sin_t2 + cy 

80 

81 return x1, y1, x2, y2 

82 

83 

84# BEZIER routines 

85 

86# subdividing bezier curve 

87# http://www.cs.mtu.edu/~shene/COURSES/cs3621/NOTES/spline/Bezier/bezier-sub.html 

88 

89 

90def _de_casteljau1(beta, t): 

91 next_beta = beta[:-1] * (1 - t) + beta[1:] * t 

92 return next_beta 

93 

94 

95def split_de_casteljau(beta, t): 

96 """ 

97 Split a Bézier segment defined by its control points *beta* into two 

98 separate segments divided at *t* and return their control points. 

99 """ 

100 beta = np.asarray(beta) 

101 beta_list = [beta] 

102 while True: 

103 beta = _de_casteljau1(beta, t) 

104 beta_list.append(beta) 

105 if len(beta) == 1: 

106 break 

107 left_beta = [beta[0] for beta in beta_list] 

108 right_beta = [beta[-1] for beta in reversed(beta_list)] 

109 

110 return left_beta, right_beta 

111 

112 

113def find_bezier_t_intersecting_with_closedpath( 

114 bezier_point_at_t, inside_closedpath, t0=0., t1=1., tolerance=0.01): 

115 """ 

116 Find the intersection of the Bézier curve with a closed path. 

117 

118 The intersection point *t* is approximated by two parameters *t0*, *t1* 

119 such that *t0* <= *t* <= *t1*. 

120 

121 Search starts from *t0* and *t1* and uses a simple bisecting algorithm 

122 therefore one of the end points must be inside the path while the other 

123 doesn't. The search stops when the distance of the points parametrized by 

124 *t0* and *t1* gets smaller than the given *tolerance*. 

125 

126 Parameters 

127 ---------- 

128 bezier_point_at_t : callable 

129 A function returning x, y coordinates of the Bézier at parameter *t*. 

130 It must have the signature:: 

131 

132 bezier_point_at_t(t: float) -> tuple[float, float] 

133 

134 inside_closedpath : callable 

135 A function returning True if a given point (x, y) is inside the 

136 closed path. It must have the signature:: 

137 

138 inside_closedpath(point: tuple[float, float]) -> bool 

139 

140 t0, t1 : float 

141 Start parameters for the search. 

142 

143 tolerance : float 

144 Maximal allowed distance between the final points. 

145 

146 Returns 

147 ------- 

148 t0, t1 : float 

149 The Bézier path parameters. 

150 """ 

151 start = bezier_point_at_t(t0) 

152 end = bezier_point_at_t(t1) 

153 

154 start_inside = inside_closedpath(start) 

155 end_inside = inside_closedpath(end) 

156 

157 if start_inside == end_inside and start != end: 

158 raise NonIntersectingPathException( 

159 "Both points are on the same side of the closed path") 

160 

161 while True: 

162 

163 # return if the distance is smaller than the tolerance 

164 if np.hypot(start[0] - end[0], start[1] - end[1]) < tolerance: 

165 return t0, t1 

166 

167 # calculate the middle point 

168 middle_t = 0.5 * (t0 + t1) 

169 middle = bezier_point_at_t(middle_t) 

170 middle_inside = inside_closedpath(middle) 

171 

172 if start_inside ^ middle_inside: 

173 t1 = middle_t 

174 end = middle 

175 else: 

176 t0 = middle_t 

177 start = middle 

178 start_inside = middle_inside 

179 

180 

181class BezierSegment: 

182 """ 

183 A d-dimensional Bézier segment. 

184 

185 Parameters 

186 ---------- 

187 control_points : (N, d) array 

188 Location of the *N* control points. 

189 """ 

190 

191 def __init__(self, control_points): 

192 self._cpoints = np.asarray(control_points) 

193 self._N, self._d = self._cpoints.shape 

194 self._orders = np.arange(self._N) 

195 coeff = [math.factorial(self._N - 1) 

196 // (math.factorial(i) * math.factorial(self._N - 1 - i)) 

197 for i in range(self._N)] 

198 self._px = (self._cpoints.T * coeff).T 

199 

200 def __call__(self, t): 

201 """ 

202 Evaluate the Bézier curve at point(s) *t* in [0, 1]. 

203 

204 Parameters 

205 ---------- 

206 t : (k,) array-like 

207 Points at which to evaluate the curve. 

208 

209 Returns 

210 ------- 

211 (k, d) array 

212 Value of the curve for each point in *t*. 

213 """ 

214 t = np.asarray(t) 

215 return (np.power.outer(1 - t, self._orders[::-1]) 

216 * np.power.outer(t, self._orders)) @ self._px 

217 

218 def point_at_t(self, t): 

219 """ 

220 Evaluate the curve at a single point, returning a tuple of *d* floats. 

221 """ 

222 return tuple(self(t)) 

223 

224 @property 

225 def control_points(self): 

226 """The control points of the curve.""" 

227 return self._cpoints 

228 

229 @property 

230 def dimension(self): 

231 """The dimension of the curve.""" 

232 return self._d 

233 

234 @property 

235 def degree(self): 

236 """Degree of the polynomial. One less the number of control points.""" 

237 return self._N - 1 

238 

239 @property 

240 def polynomial_coefficients(self): 

241 r""" 

242 The polynomial coefficients of the Bézier curve. 

243 

244 .. warning:: Follows opposite convention from `numpy.polyval`. 

245 

246 Returns 

247 ------- 

248 (n+1, d) array 

249 Coefficients after expanding in polynomial basis, where :math:`n` 

250 is the degree of the Bézier curve and :math:`d` its dimension. 

251 These are the numbers (:math:`C_j`) such that the curve can be 

252 written :math:`\sum_{j=0}^n C_j t^j`. 

253 

254 Notes 

255 ----- 

256 The coefficients are calculated as 

257 

258 .. math:: 

259 

260 {n \choose j} \sum_{i=0}^j (-1)^{i+j} {j \choose i} P_i 

261 

262 where :math:`P_i` are the control points of the curve. 

263 """ 

264 n = self.degree 

265 # matplotlib uses n <= 4. overflow plausible starting around n = 15. 

266 if n > 10: 

267 warnings.warn("Polynomial coefficients formula unstable for high " 

268 "order Bezier curves!", RuntimeWarning) 

269 P = self.control_points 

270 j = np.arange(n+1)[:, None] 

271 i = np.arange(n+1)[None, :] # _comb is non-zero for i <= j 

272 prefactor = (-1)**(i + j) * _comb(j, i) # j on axis 0, i on axis 1 

273 return _comb(n, j) * prefactor @ P # j on axis 0, self.dimension on 1 

274 

275 def axis_aligned_extrema(self): 

276 """ 

277 Return the dimension and location of the curve's interior extrema. 

278 

279 The extrema are the points along the curve where one of its partial 

280 derivatives is zero. 

281 

282 Returns 

283 ------- 

284 dims : array of int 

285 Index :math:`i` of the partial derivative which is zero at each 

286 interior extrema. 

287 dzeros : array of float 

288 Of same size as dims. The :math:`t` such that :math:`d/dx_i B(t) = 

289 0` 

290 """ 

291 n = self.degree 

292 if n <= 1: 

293 return np.array([]), np.array([]) 

294 Cj = self.polynomial_coefficients 

295 dCj = np.arange(1, n+1)[:, None] * Cj[1:] 

296 dims = [] 

297 roots = [] 

298 for i, pi in enumerate(dCj.T): 

299 r = np.roots(pi[::-1]) 

300 roots.append(r) 

301 dims.append(np.full_like(r, i)) 

302 roots = np.concatenate(roots) 

303 dims = np.concatenate(dims) 

304 in_range = np.isreal(roots) & (roots >= 0) & (roots <= 1) 

305 return dims[in_range], np.real(roots)[in_range] 

306 

307 

308def split_bezier_intersecting_with_closedpath( 

309 bezier, inside_closedpath, tolerance=0.01): 

310 """ 

311 Split a Bézier curve into two at the intersection with a closed path. 

312 

313 Parameters 

314 ---------- 

315 bezier : (N, 2) array-like 

316 Control points of the Bézier segment. See `.BezierSegment`. 

317 inside_closedpath : callable 

318 A function returning True if a given point (x, y) is inside the 

319 closed path. See also `.find_bezier_t_intersecting_with_closedpath`. 

320 tolerance : float 

321 The tolerance for the intersection. See also 

322 `.find_bezier_t_intersecting_with_closedpath`. 

323 

324 Returns 

325 ------- 

326 left, right 

327 Lists of control points for the two Bézier segments. 

328 """ 

329 

330 bz = BezierSegment(bezier) 

331 bezier_point_at_t = bz.point_at_t 

332 

333 t0, t1 = find_bezier_t_intersecting_with_closedpath( 

334 bezier_point_at_t, inside_closedpath, tolerance=tolerance) 

335 

336 _left, _right = split_de_casteljau(bezier, (t0 + t1) / 2.) 

337 return _left, _right 

338 

339 

340# matplotlib specific 

341 

342 

343def split_path_inout(path, inside, tolerance=0.01, reorder_inout=False): 

344 """ 

345 Divide a path into two segments at the point where ``inside(x, y)`` becomes 

346 False. 

347 """ 

348 from .path import Path 

349 path_iter = path.iter_segments() 

350 

351 ctl_points, command = next(path_iter) 

352 begin_inside = inside(ctl_points[-2:]) # true if begin point is inside 

353 

354 ctl_points_old = ctl_points 

355 

356 iold = 0 

357 i = 1 

358 

359 for ctl_points, command in path_iter: 

360 iold = i 

361 i += len(ctl_points) // 2 

362 if inside(ctl_points[-2:]) != begin_inside: 

363 bezier_path = np.concatenate([ctl_points_old[-2:], ctl_points]) 

364 break 

365 ctl_points_old = ctl_points 

366 else: 

367 raise ValueError("The path does not intersect with the patch") 

368 

369 bp = bezier_path.reshape((-1, 2)) 

370 left, right = split_bezier_intersecting_with_closedpath( 

371 bp, inside, tolerance) 

372 if len(left) == 2: 

373 codes_left = [Path.LINETO] 

374 codes_right = [Path.MOVETO, Path.LINETO] 

375 elif len(left) == 3: 

376 codes_left = [Path.CURVE3, Path.CURVE3] 

377 codes_right = [Path.MOVETO, Path.CURVE3, Path.CURVE3] 

378 elif len(left) == 4: 

379 codes_left = [Path.CURVE4, Path.CURVE4, Path.CURVE4] 

380 codes_right = [Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4] 

381 else: 

382 raise AssertionError("This should never be reached") 

383 

384 verts_left = left[1:] 

385 verts_right = right[:] 

386 

387 if path.codes is None: 

388 path_in = Path(np.concatenate([path.vertices[:i], verts_left])) 

389 path_out = Path(np.concatenate([verts_right, path.vertices[i:]])) 

390 

391 else: 

392 path_in = Path(np.concatenate([path.vertices[:iold], verts_left]), 

393 np.concatenate([path.codes[:iold], codes_left])) 

394 

395 path_out = Path(np.concatenate([verts_right, path.vertices[i:]]), 

396 np.concatenate([codes_right, path.codes[i:]])) 

397 

398 if reorder_inout and not begin_inside: 

399 path_in, path_out = path_out, path_in 

400 

401 return path_in, path_out 

402 

403 

404def inside_circle(cx, cy, r): 

405 """ 

406 Return a function that checks whether a point is in a circle with center 

407 (*cx*, *cy*) and radius *r*. 

408 

409 The returned function has the signature:: 

410 

411 f(xy: tuple[float, float]) -> bool 

412 """ 

413 r2 = r ** 2 

414 

415 def _f(xy): 

416 x, y = xy 

417 return (x - cx) ** 2 + (y - cy) ** 2 < r2 

418 return _f 

419 

420 

421# quadratic Bezier lines 

422 

423def get_cos_sin(x0, y0, x1, y1): 

424 dx, dy = x1 - x0, y1 - y0 

425 d = (dx * dx + dy * dy) ** .5 

426 # Account for divide by zero 

427 if d == 0: 

428 return 0.0, 0.0 

429 return dx / d, dy / d 

430 

431 

432def check_if_parallel(dx1, dy1, dx2, dy2, tolerance=1.e-5): 

433 """ 

434 Check if two lines are parallel. 

435 

436 Parameters 

437 ---------- 

438 dx1, dy1, dx2, dy2 : float 

439 The gradients *dy*/*dx* of the two lines. 

440 tolerance : float 

441 The angular tolerance in radians up to which the lines are considered 

442 parallel. 

443 

444 Returns 

445 ------- 

446 is_parallel 

447 - 1 if two lines are parallel in same direction. 

448 - -1 if two lines are parallel in opposite direction. 

449 - False otherwise. 

450 """ 

451 theta1 = np.arctan2(dx1, dy1) 

452 theta2 = np.arctan2(dx2, dy2) 

453 dtheta = abs(theta1 - theta2) 

454 if dtheta < tolerance: 

455 return 1 

456 elif abs(dtheta - np.pi) < tolerance: 

457 return -1 

458 else: 

459 return False 

460 

461 

462def get_parallels(bezier2, width): 

463 """ 

464 Given the quadratic Bézier control points *bezier2*, returns 

465 control points of quadratic Bézier lines roughly parallel to given 

466 one separated by *width*. 

467 """ 

468 

469 # The parallel Bezier lines are constructed by following ways. 

470 # c1 and c2 are control points representing the start and end of the 

471 # Bezier line. 

472 # cm is the middle point 

473 

474 c1x, c1y = bezier2[0] 

475 cmx, cmy = bezier2[1] 

476 c2x, c2y = bezier2[2] 

477 

478 parallel_test = check_if_parallel(c1x - cmx, c1y - cmy, 

479 cmx - c2x, cmy - c2y) 

480 

481 if parallel_test == -1: 

482 _api.warn_external( 

483 "Lines do not intersect. A straight line is used instead.") 

484 cos_t1, sin_t1 = get_cos_sin(c1x, c1y, c2x, c2y) 

485 cos_t2, sin_t2 = cos_t1, sin_t1 

486 else: 

487 # t1 and t2 is the angle between c1 and cm, cm, c2. They are 

488 # also an angle of the tangential line of the path at c1 and c2 

489 cos_t1, sin_t1 = get_cos_sin(c1x, c1y, cmx, cmy) 

490 cos_t2, sin_t2 = get_cos_sin(cmx, cmy, c2x, c2y) 

491 

492 # find c1_left, c1_right which are located along the lines 

493 # through c1 and perpendicular to the tangential lines of the 

494 # Bezier path at a distance of width. Same thing for c2_left and 

495 # c2_right with respect to c2. 

496 c1x_left, c1y_left, c1x_right, c1y_right = ( 

497 get_normal_points(c1x, c1y, cos_t1, sin_t1, width) 

498 ) 

499 c2x_left, c2y_left, c2x_right, c2y_right = ( 

500 get_normal_points(c2x, c2y, cos_t2, sin_t2, width) 

501 ) 

502 

503 # find cm_left which is the intersecting point of a line through 

504 # c1_left with angle t1 and a line through c2_left with angle 

505 # t2. Same with cm_right. 

506 try: 

507 cmx_left, cmy_left = get_intersection(c1x_left, c1y_left, cos_t1, 

508 sin_t1, c2x_left, c2y_left, 

509 cos_t2, sin_t2) 

510 cmx_right, cmy_right = get_intersection(c1x_right, c1y_right, cos_t1, 

511 sin_t1, c2x_right, c2y_right, 

512 cos_t2, sin_t2) 

513 except ValueError: 

514 # Special case straight lines, i.e., angle between two lines is 

515 # less than the threshold used by get_intersection (we don't use 

516 # check_if_parallel as the threshold is not the same). 

517 cmx_left, cmy_left = ( 

518 0.5 * (c1x_left + c2x_left), 0.5 * (c1y_left + c2y_left) 

519 ) 

520 cmx_right, cmy_right = ( 

521 0.5 * (c1x_right + c2x_right), 0.5 * (c1y_right + c2y_right) 

522 ) 

523 

524 # the parallel Bezier lines are created with control points of 

525 # [c1_left, cm_left, c2_left] and [c1_right, cm_right, c2_right] 

526 path_left = [(c1x_left, c1y_left), 

527 (cmx_left, cmy_left), 

528 (c2x_left, c2y_left)] 

529 path_right = [(c1x_right, c1y_right), 

530 (cmx_right, cmy_right), 

531 (c2x_right, c2y_right)] 

532 

533 return path_left, path_right 

534 

535 

536def find_control_points(c1x, c1y, mmx, mmy, c2x, c2y): 

537 """ 

538 Find control points of the Bézier curve passing through (*c1x*, *c1y*), 

539 (*mmx*, *mmy*), and (*c2x*, *c2y*), at parametric values 0, 0.5, and 1. 

540 """ 

541 cmx = .5 * (4 * mmx - (c1x + c2x)) 

542 cmy = .5 * (4 * mmy - (c1y + c2y)) 

543 return [(c1x, c1y), (cmx, cmy), (c2x, c2y)] 

544 

545 

546def make_wedged_bezier2(bezier2, width, w1=1., wm=0.5, w2=0.): 

547 """ 

548 Being similar to `get_parallels`, returns control points of two quadratic 

549 Bézier lines having a width roughly parallel to given one separated by 

550 *width*. 

551 """ 

552 

553 # c1, cm, c2 

554 c1x, c1y = bezier2[0] 

555 cmx, cmy = bezier2[1] 

556 c3x, c3y = bezier2[2] 

557 

558 # t1 and t2 is the angle between c1 and cm, cm, c3. 

559 # They are also an angle of the tangential line of the path at c1 and c3 

560 cos_t1, sin_t1 = get_cos_sin(c1x, c1y, cmx, cmy) 

561 cos_t2, sin_t2 = get_cos_sin(cmx, cmy, c3x, c3y) 

562 

563 # find c1_left, c1_right which are located along the lines 

564 # through c1 and perpendicular to the tangential lines of the 

565 # Bezier path at a distance of width. Same thing for c3_left and 

566 # c3_right with respect to c3. 

567 c1x_left, c1y_left, c1x_right, c1y_right = ( 

568 get_normal_points(c1x, c1y, cos_t1, sin_t1, width * w1) 

569 ) 

570 c3x_left, c3y_left, c3x_right, c3y_right = ( 

571 get_normal_points(c3x, c3y, cos_t2, sin_t2, width * w2) 

572 ) 

573 

574 # find c12, c23 and c123 which are middle points of c1-cm, cm-c3 and 

575 # c12-c23 

576 c12x, c12y = (c1x + cmx) * .5, (c1y + cmy) * .5 

577 c23x, c23y = (cmx + c3x) * .5, (cmy + c3y) * .5 

578 c123x, c123y = (c12x + c23x) * .5, (c12y + c23y) * .5 

579 

580 # tangential angle of c123 (angle between c12 and c23) 

581 cos_t123, sin_t123 = get_cos_sin(c12x, c12y, c23x, c23y) 

582 

583 c123x_left, c123y_left, c123x_right, c123y_right = ( 

584 get_normal_points(c123x, c123y, cos_t123, sin_t123, width * wm) 

585 ) 

586 

587 path_left = find_control_points(c1x_left, c1y_left, 

588 c123x_left, c123y_left, 

589 c3x_left, c3y_left) 

590 path_right = find_control_points(c1x_right, c1y_right, 

591 c123x_right, c123y_right, 

592 c3x_right, c3y_right) 

593 

594 return path_left, path_right