rgrid-python 4.5.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
grid_py/_curve.py ADDED
@@ -0,0 +1,1668 @@
1
+ """Curve, xspline, and bezier grobs for grid_py.
2
+
3
+ Python port of R's ``grid/R/curve.R`` (~535 lines). Provides grob
4
+ constructors, ``grid_*`` drawing wrappers, point-extraction helpers, and the
5
+ internal control-point calculation routines that underpin curved connectors in
6
+ the *grid* graphics system.
7
+
8
+ The three main families are:
9
+
10
+ * **curve** -- a smooth curve between two endpoints, parameterised by
11
+ curvature, angle, and number of control points.
12
+ * **xspline** -- an X-spline through arbitrary control points.
13
+ * **bezier** -- a cubic Bezier curve through four (or more) control points.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import math
19
+ from typing import (
20
+ Any,
21
+ Dict,
22
+ List,
23
+ Optional,
24
+ Sequence,
25
+ Tuple,
26
+ Union,
27
+ )
28
+
29
+ import numpy as np
30
+ from numpy.typing import NDArray
31
+
32
+ from ._arrow import Arrow
33
+ from ._gpar import Gpar
34
+ from ._grob import GList, GTree, Grob
35
+ from ._primitives import lines_grob, segments_grob
36
+ from ._units import Unit, convert_x, convert_y, is_unit
37
+
38
+ __all__ = [
39
+ # curve
40
+ "curve_grob",
41
+ "grid_curve",
42
+ # xspline
43
+ "xspline_grob",
44
+ "grid_xspline",
45
+ "xspline_points",
46
+ # bezier
47
+ "bezier_grob",
48
+ "grid_bezier",
49
+ "bezier_points",
50
+ # utility
51
+ "arc_curvature",
52
+ ]
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Module-level display list (shared with _primitives)
56
+ # ---------------------------------------------------------------------------
57
+
58
+ _display_list: List[Grob] = []
59
+
60
+
61
+ def _grid_draw(grob: Grob) -> None:
62
+ """Append *grob* to the module-level display list."""
63
+ _display_list.append(grob)
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Helper: ensure a value is a Unit
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ def _ensure_unit(x: Any, default_units: str) -> Unit:
72
+ """Convert *x* to a :class:`Unit` if it is not already one.
73
+
74
+ Parameters
75
+ ----------
76
+ x : Any
77
+ A numeric scalar, sequence of numerics, or an existing ``Unit``.
78
+ default_units : str
79
+ The unit string to use when *x* is not already a ``Unit``.
80
+
81
+ Returns
82
+ -------
83
+ Unit
84
+ """
85
+ if is_unit(x):
86
+ return x
87
+ return Unit(x, default_units)
88
+
89
+
90
+ # ===================================================================== #
91
+ # Internal: arc curvature utility #
92
+ # ===================================================================== #
93
+
94
+
95
+ def arc_curvature(
96
+ x1: float,
97
+ y1: float,
98
+ x2: float,
99
+ y2: float,
100
+ x3: float,
101
+ y3: float,
102
+ ) -> float:
103
+ """Compute the signed curvature of the arc through three points.
104
+
105
+ Parameters
106
+ ----------
107
+ x1, y1 : float
108
+ First point.
109
+ x2, y2 : float
110
+ Second point (apex).
111
+ x3, y3 : float
112
+ Third point.
113
+
114
+ Returns
115
+ -------
116
+ float
117
+ The signed curvature (positive = curves right, negative = curves
118
+ left). Returns ``0.0`` when the points are collinear or
119
+ coincident.
120
+
121
+ Notes
122
+ -----
123
+ Curvature is ``2 * signed_area / (d12 * d23 * d13)`` where
124
+ ``signed_area`` is the cross-product triangle area.
125
+ """
126
+ # Twice the signed area of the triangle
127
+ area2 = (x2 - x1) * (y3 - y1) - (x3 - x1) * (y2 - y1)
128
+ d12 = math.hypot(x2 - x1, y2 - y1)
129
+ d23 = math.hypot(x3 - x2, y3 - y2)
130
+ d13 = math.hypot(x3 - x1, y3 - y1)
131
+ denom = d12 * d23 * d13
132
+ if denom == 0.0:
133
+ return 0.0
134
+ return 2.0 * area2 / denom
135
+
136
+
137
+ # ===================================================================== #
138
+ # Internal: control-point calculation (mirrors R's calcControlPoints) #
139
+ # ===================================================================== #
140
+
141
+
142
+ def _calc_origin(
143
+ x1: NDArray[np.float64],
144
+ y1: NDArray[np.float64],
145
+ x2: NDArray[np.float64],
146
+ y2: NDArray[np.float64],
147
+ origin: float,
148
+ hand: str,
149
+ ) -> Tuple[NDArray[np.float64], NDArray[np.float64]]:
150
+ """Compute the origin of rotation for control-point generation.
151
+
152
+ Parameters
153
+ ----------
154
+ x1, y1, x2, y2 : ndarray
155
+ Endpoint coordinates.
156
+ origin : float
157
+ Origin offset (derived from curvature).
158
+ hand : str
159
+ ``"left"`` or ``"right"``.
160
+
161
+ Returns
162
+ -------
163
+ tuple of ndarray
164
+ ``(ox, oy)`` origin coordinates.
165
+ """
166
+ xm = (x1 + x2) / 2.0
167
+ ym = (y1 + y2) / 2.0
168
+ dx = x2 - x1
169
+ dy = y2 - y1
170
+
171
+ tmpox = xm + origin * dx / 2.0
172
+ tmpoy = ym + origin * dy / 2.0
173
+
174
+ # Handle special slope cases (vectorised)
175
+ slope = np.where(dx != 0.0, dy / np.where(dx != 0.0, dx, 1.0), np.inf)
176
+ finite_slope = np.isfinite(slope)
177
+ oslope = np.where(slope != 0.0, -1.0 / np.where(slope != 0.0, slope, 1.0), np.inf)
178
+ finite_oslope = np.isfinite(oslope)
179
+
180
+ tmpox = np.where(~finite_slope, xm, tmpox)
181
+ tmpoy = np.where(~finite_slope, ym + origin * dy / 2.0, tmpoy)
182
+ tmpoy = np.where(finite_slope & ~finite_oslope, ym, tmpoy)
183
+
184
+ # Rotate by -90 degrees about midpoint
185
+ sintheta = -1.0
186
+ ox = xm - (tmpoy - ym) * sintheta
187
+ oy = ym + (tmpox - xm) * sintheta
188
+
189
+ return ox, oy
190
+
191
+
192
+ def _calc_control_points(
193
+ x1: NDArray[np.float64],
194
+ y1: NDArray[np.float64],
195
+ x2: NDArray[np.float64],
196
+ y2: NDArray[np.float64],
197
+ curvature: float,
198
+ angle: Optional[float],
199
+ ncp: int,
200
+ ) -> Tuple[NDArray[np.float64], NDArray[np.float64]]:
201
+ """Compute control points by rotating endpoints about an origin.
202
+
203
+ Parameters
204
+ ----------
205
+ x1, y1, x2, y2 : ndarray
206
+ Endpoint coordinates (in inches).
207
+ curvature : float
208
+ Signed curvature parameter.
209
+ angle : float or None
210
+ Angle in degrees (0-180). ``None`` means auto-compute.
211
+ ncp : int
212
+ Number of control points per curve segment.
213
+
214
+ Returns
215
+ -------
216
+ tuple of ndarray
217
+ ``(cpx, cpy)`` arrays of control-point coordinates, flattened in
218
+ row-major order.
219
+ """
220
+ xm = (x1 + x2) / 2.0
221
+ ym = (y1 + y2) / 2.0
222
+ dx = x2 - x1
223
+ dy = y2 - y1
224
+ slope = np.where(dx != 0.0, dy / np.where(dx != 0.0, dx, 1.0), np.inf)
225
+
226
+ # Angle computation
227
+ if angle is None:
228
+ angle_rad = np.where(
229
+ slope < 0,
230
+ 2.0 * np.arctan(np.abs(slope)),
231
+ 2.0 * np.arctan(1.0 / np.where(slope != 0, np.abs(slope), 1e-30)),
232
+ )
233
+ else:
234
+ angle_rad = np.full_like(x1, angle / 180.0 * math.pi)
235
+
236
+ sina = np.sin(angle_rad)
237
+ cosa = np.cos(angle_rad)
238
+ cornerx = xm + (x1 - xm) * cosa - (y1 - ym) * sina
239
+ cornery = ym + (y1 - ym) * cosa + (x1 - xm) * sina
240
+
241
+ # Rotation angle to align region with axes
242
+ denom_beta = cornerx - x1
243
+ denom_beta = np.where(denom_beta == 0.0, 1e-30, denom_beta)
244
+ beta = -np.arctan((cornery - y1) / denom_beta)
245
+ sinb = np.sin(beta)
246
+ cosb = np.cos(beta)
247
+
248
+ # Rotate end point about start
249
+ newx2 = x1 + dx * cosb - dy * sinb
250
+ newy2 = y1 + dy * cosb + dx * sinb
251
+
252
+ # Scale to make region square
253
+ denom_scale = newx2 - x1
254
+ denom_scale = np.where(denom_scale == 0.0, 1e-30, denom_scale)
255
+ scalex = (newy2 - y1) / denom_scale
256
+ scalex = np.where(scalex == 0.0, 1e-30, scalex)
257
+ newx1 = x1 * scalex
258
+ newx2 = newx2 * scalex
259
+
260
+ # Origin in the "square" region
261
+ ratio = 2.0 * (math.sin(math.atan(curvature)) ** 2)
262
+ if ratio == 0.0:
263
+ ratio = 1e-30
264
+ origin = curvature - curvature / ratio
265
+ hand = "right" if curvature > 0 else "left"
266
+
267
+ ox, oy = _calc_origin(newx1, y1, newx2, newy2, origin, hand)
268
+
269
+ # Direction and angular sweep for control points
270
+ direction = 1.0 if hand == "right" else -1.0
271
+ maxtheta = math.pi + math.copysign(1.0, origin * direction) * 2.0 * math.atan(abs(origin))
272
+ # Port of R's ``seq(from, to, by)``: ``seq(0, 0, by=0)`` returns
273
+ # ``c(0)`` of length 1, not a length-``ncp+2`` ramp.
274
+ step = direction * maxtheta / (ncp + 1)
275
+ if step == 0.0:
276
+ theta_all = np.array([0.0])
277
+ else:
278
+ theta_all = np.linspace(0.0, direction * maxtheta, ncp + 2)
279
+ # R's ``[c(-1, -(ncp+2))]`` — drop first and last. On a length-1
280
+ # vector R silently allows out-of-range negative indices, yielding
281
+ # an empty result. ``theta_all[1:-1]`` matches both cases.
282
+ theta = theta_all[1:-1]
283
+ costheta = np.cos(theta)
284
+ sintheta = np.sin(theta)
285
+
286
+ # Matrix multiplication: ncurve x ncp
287
+ # (newx1 - ox) is shape (ncurve,), costheta is shape (ncp,)
288
+ cpx = ox[:, None] + np.outer(newx1 - ox, costheta) - np.outer(y1 - oy, sintheta)
289
+ cpy = oy[:, None] + np.outer(y1 - oy, costheta) + np.outer(newx1 - ox, sintheta)
290
+
291
+ # Reverse scaling
292
+ cpx = cpx / scalex[:, None]
293
+
294
+ # Reverse rotation
295
+ sinnb = np.sin(-beta)
296
+ cosnb = np.cos(-beta)
297
+ finalcpx = x1[:, None] + (cpx - x1[:, None]) * cosnb[:, None] - (cpy - y1[:, None]) * sinnb[:, None]
298
+ finalcpy = y1[:, None] + (cpy - y1[:, None]) * cosnb[:, None] + (cpx - x1[:, None]) * sinnb[:, None]
299
+
300
+ return finalcpx.ravel(order="C"), finalcpy.ravel(order="C")
301
+
302
+
303
+ def _interleave(
304
+ ncp: int,
305
+ ncurve: int,
306
+ val: NDArray[np.float64],
307
+ sval: NDArray[np.float64],
308
+ eval_: NDArray[np.float64],
309
+ end: NDArray[np.bool_],
310
+ ) -> NDArray[np.float64]:
311
+ """Interleave control-point values with start/end extras.
312
+
313
+ Parameters
314
+ ----------
315
+ ncp : int
316
+ Number of control points per curve.
317
+ ncurve : int
318
+ Number of curves.
319
+ val : ndarray
320
+ Control-point values (ncp * ncurve).
321
+ sval : ndarray
322
+ Start values (ncurve).
323
+ eval_ : ndarray
324
+ End values (ncurve).
325
+ end : ndarray of bool
326
+ If ``True`` for curve *i*, append ``eval_[i]``; otherwise prepend
327
+ ``sval[i]``.
328
+
329
+ Returns
330
+ -------
331
+ ndarray
332
+ Interleaved values, length ``(ncp + 1) * ncurve``.
333
+ """
334
+ sval = np.resize(sval, ncurve)
335
+ eval_ = np.resize(eval_, ncurve)
336
+ # Port of R's ``matrix(val, ncol=ncurve)``: empty ``val`` yields a
337
+ # ``0 × ncurve`` matrix (numpy's reshape would raise otherwise).
338
+ if val.size == 0:
339
+ m = np.empty((0, ncurve), dtype=np.float64)
340
+ else:
341
+ m = val.reshape((ncp, ncurve), order="F")
342
+ result = np.empty((ncp + 1, ncurve), dtype=np.float64)
343
+ for i in range(ncurve):
344
+ if end[i]:
345
+ col = np.concatenate([m[:, i], [eval_[i]]])
346
+ else:
347
+ col = np.concatenate([[sval[i]], m[:, i]])
348
+ # R's ``result[,i] <- <shorter vector>`` recycles the rhs to
349
+ # fill the column; for ``val`` empty the rhs is a length-1
350
+ # scalar, which broadcasts naturally.
351
+ if col.size == 1:
352
+ result[:, i] = col[0]
353
+ else:
354
+ result[:, i] = col
355
+ return result.ravel(order="F")
356
+
357
+
358
+ def _calc_square_control_points(
359
+ x1: NDArray[np.float64],
360
+ y1: NDArray[np.float64],
361
+ x2: NDArray[np.float64],
362
+ y2: NDArray[np.float64],
363
+ curvature: float,
364
+ angle: Optional[float],
365
+ ncp: int,
366
+ ) -> Tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.bool_]]:
367
+ """Compute "square" control points with an extra interleaved point.
368
+
369
+ Parameters
370
+ ----------
371
+ x1, y1, x2, y2 : ndarray
372
+ Endpoint coordinates.
373
+ curvature : float
374
+ Signed curvature.
375
+ angle : float or None
376
+ Angle in degrees.
377
+ ncp : int
378
+ Number of control points per segment.
379
+
380
+ Returns
381
+ -------
382
+ tuple
383
+ ``(cpx, cpy, end)`` where *end* is a boolean mask indicating
384
+ whether the extra point was appended (True) or prepended (False).
385
+ """
386
+ dx = x2 - x1
387
+ dy = y2 - y1
388
+ slope = np.where(dx != 0.0, dy / np.where(dx != 0.0, dx, 1.0), np.inf)
389
+
390
+ end = (slope > 1) | ((slope < 0) & (slope > -1))
391
+ if curvature < 0:
392
+ end = ~end
393
+
394
+ abs_slope = np.abs(slope)
395
+ sign_slope = np.sign(slope)
396
+
397
+ startx = np.where(end, x1,
398
+ np.where(abs_slope > 1, x2 - dx, x2 - sign_slope * dy))
399
+ starty = np.where(end, y1,
400
+ np.where(abs_slope > 1, y2 - sign_slope * dx, y2 - dy))
401
+ endx = np.where(end,
402
+ np.where(abs_slope > 1, x1 + dx, x1 + sign_slope * dy),
403
+ x2)
404
+ endy = np.where(end,
405
+ np.where(abs_slope > 1, y1 + sign_slope * dx, y1 + dy),
406
+ y2)
407
+
408
+ cpx, cpy = _calc_control_points(startx, starty, endx, endy,
409
+ curvature, angle, ncp)
410
+
411
+ ncurve = len(x1)
412
+ cpx = _interleave(ncp, ncurve, cpx, startx, endx, end)
413
+ cpy = _interleave(ncp, ncurve, cpy, starty, endy, end)
414
+
415
+ return cpx, cpy, end
416
+
417
+
418
+ # ===================================================================== #
419
+ # Internal: curve point calculation #
420
+ # ===================================================================== #
421
+
422
+
423
+ def _calc_curve_points(
424
+ x1: float,
425
+ y1: float,
426
+ x2: float,
427
+ y2: float,
428
+ curvature: float = 1.0,
429
+ angle: float = 90.0,
430
+ ncp: int = 1,
431
+ shape: float = 0.5,
432
+ square: bool = True,
433
+ squareShape: float = 1.0,
434
+ inflect: bool = False,
435
+ open_: bool = True,
436
+ ) -> Tuple[NDArray[np.float64], NDArray[np.float64]]:
437
+ """Compute the full set of curve points (control + interpolation).
438
+
439
+ This mirrors R's ``calcCurveGrob`` but returns the x-spline control
440
+ points instead of building a grob tree.
441
+
442
+ Parameters
443
+ ----------
444
+ x1, y1 : float
445
+ Start point (in working coordinates, e.g. inches).
446
+ x2, y2 : float
447
+ End point.
448
+ curvature : float
449
+ Curvature parameter (0 = straight line).
450
+ angle : float
451
+ Angle in degrees (0--180).
452
+ ncp : int
453
+ Number of control points.
454
+ shape : float
455
+ X-spline shape parameter (-1 to 1).
456
+ square : bool
457
+ Whether to use "square" control-point placement.
458
+ squareShape : float
459
+ Shape for the extra square control point.
460
+ inflect : bool
461
+ Whether the curve should inflect at the midpoint.
462
+ open_ : bool
463
+ Whether the resulting spline is open.
464
+
465
+ Returns
466
+ -------
467
+ tuple of ndarray
468
+ ``(x_pts, y_pts)`` control-point arrays suitable for an x-spline.
469
+ """
470
+ ax1 = np.atleast_1d(np.asarray(x1, dtype=np.float64))
471
+ ay1 = np.atleast_1d(np.asarray(y1, dtype=np.float64))
472
+ ax2 = np.atleast_1d(np.asarray(x2, dtype=np.float64))
473
+ ay2 = np.atleast_1d(np.asarray(y2, dtype=np.float64))
474
+
475
+ # Outlaw identical endpoints
476
+ if np.any((ax1 == ax2) & (ay1 == ay2)):
477
+ raise ValueError("end points must not be identical")
478
+
479
+ maxn = max(len(ax1), len(ay1), len(ax2), len(ay2))
480
+ ax1 = np.resize(ax1, maxn)
481
+ ay1 = np.resize(ay1, maxn)
482
+ ax2 = np.resize(ax2, maxn)
483
+ ay2 = np.resize(ay2, maxn)
484
+
485
+ # Straight line
486
+ if curvature == 0 or angle < 1 or angle > 179:
487
+ return np.array([x1, x2], dtype=np.float64), np.array([y1, y2], dtype=np.float64)
488
+
489
+ ncurve = maxn
490
+
491
+ if inflect:
492
+ xm = (ax1 + ax2) / 2.0
493
+ ym = (ay1 + ay2) / 2.0
494
+ shape_vec1 = np.tile(np.resize(np.atleast_1d(shape), ncp), ncurve)
495
+ shape_vec2 = shape_vec1[::-1].copy()
496
+
497
+ if square:
498
+ cpx1, cpy1, end1 = _calc_square_control_points(
499
+ ax1, ay1, xm, ym, curvature, angle, ncp)
500
+ cpx2, cpy2, end2 = _calc_square_control_points(
501
+ xm, ym, ax2, ay2, -curvature, angle, ncp)
502
+ shape_vec1 = _interleave(ncp, ncurve, shape_vec1,
503
+ np.full(ncurve, squareShape),
504
+ np.full(ncurve, squareShape), end1)
505
+ shape_vec2 = _interleave(ncp, ncurve, shape_vec2,
506
+ np.full(ncurve, squareShape),
507
+ np.full(ncurve, squareShape), end2)
508
+ ncp_eff = ncp + 1
509
+ else:
510
+ cpx1, cpy1 = _calc_control_points(ax1, ay1, xm, ym,
511
+ curvature, angle, ncp)
512
+ cpx2, cpy2 = _calc_control_points(xm, ym, ax2, ay2,
513
+ -curvature, angle, ncp)
514
+ ncp_eff = ncp
515
+
516
+ # Build arrays: x1, cps1, xm, cps2, x2
517
+ all_x = np.concatenate([ax1, cpx1, xm, cpx2, ax2])
518
+ all_y = np.concatenate([ay1, cpy1, ym, cpy2, ay2])
519
+ all_shape = np.concatenate([
520
+ np.zeros(ncurve), shape_vec1,
521
+ np.zeros(ncurve), shape_vec2,
522
+ np.zeros(ncurve),
523
+ ])
524
+ return all_x, all_y
525
+ else:
526
+ shape_vec = np.tile(np.resize(np.atleast_1d(shape), ncp), ncurve)
527
+
528
+ if square:
529
+ cpx, cpy, end = _calc_square_control_points(
530
+ ax1, ay1, ax2, ay2, curvature, angle, ncp)
531
+ shape_vec = _interleave(ncp, ncurve, shape_vec,
532
+ np.full(ncurve, squareShape),
533
+ np.full(ncurve, squareShape), end)
534
+ ncp_eff = ncp + 1
535
+ else:
536
+ cpx, cpy = _calc_control_points(ax1, ay1, ax2, ay2,
537
+ curvature, angle, ncp)
538
+ ncp_eff = ncp
539
+
540
+ all_x = np.concatenate([ax1, cpx, ax2])
541
+ all_y = np.concatenate([ay1, cpy, ay2])
542
+ return all_x, all_y
543
+
544
+
545
+ # ===================================================================== #
546
+ # Internal: X-spline point calculation #
547
+ # ===================================================================== #
548
+
549
+
550
+ def _calc_xspline_points(
551
+ x: NDArray[np.float64],
552
+ y: NDArray[np.float64],
553
+ shape: Union[float, NDArray[np.float64]] = 0.0,
554
+ open_: bool = True,
555
+ repEnds: bool = True,
556
+ ) -> Tuple[NDArray[np.float64], NDArray[np.float64]]:
557
+ """Evaluate an X-spline through the given control points.
558
+
559
+ Faithful port of R's ``src/main/xspline.c`` (itself derived from
560
+ XFig 3.2.4, which in turn implements the Blanc & Schlick 1995
561
+ X-spline model verbatim). The per-point ``shape`` parameter is in
562
+ ``[-1, 1]`` with the standard interpretation:
563
+
564
+ - ``shape < 0``: "approximating" (B-spline-like)
565
+ - ``shape = 0``: control point is a sharp corner
566
+ - ``shape > 0``: "interpolating" (curve passes through)
567
+
568
+ Blending is done with the three polynomial kernels defined in the
569
+ Blanc-Schlick paper — ``f_blend`` (quintic), ``g_blend`` (quintic),
570
+ and ``h_blend`` (quartic). These are **exact**, not a Catmull-Rom /
571
+ B-spline / linear approximation.
572
+
573
+ Parameters
574
+ ----------
575
+ x, y : ndarray
576
+ Control-point coordinates (inches, device, or any linear unit).
577
+ shape : float or ndarray
578
+ Per-control-point shape parameter(s) in ``[-1, 1]``. Scalar is
579
+ broadcast to all points.
580
+ open_ : bool
581
+ Open (True) or closed (False) spline.
582
+ repEnds : bool
583
+ For open splines, replicate the first and last control points so
584
+ the curve passes through the endpoints. Matches R's ``repEnds``.
585
+
586
+ Returns
587
+ -------
588
+ tuple of ndarray
589
+ ``(x_pts, y_pts)`` evaluated spline coordinates.
590
+
591
+ References
592
+ ----------
593
+ Blanc, C. and Schlick, C. (1995). X-splines: A spline model designed
594
+ for the end-user. *Proceedings of SIGGRAPH 95*, pp. 377-386.
595
+
596
+ R implementation: ``src/main/xspline.c``.
597
+ """
598
+ x = np.asarray(x, dtype=np.float64)
599
+ y = np.asarray(y, dtype=np.float64)
600
+ n = len(x)
601
+
602
+ if n < 2:
603
+ return x.copy(), y.copy()
604
+
605
+ if np.isscalar(shape):
606
+ s = np.full(n, float(shape), dtype=np.float64)
607
+ else:
608
+ s = np.asarray(shape, dtype=np.float64)
609
+ if len(s) < n:
610
+ s = np.resize(s, n)
611
+ s = np.clip(s, -1.0, 1.0)
612
+
613
+ # R forces the first and last control points' shape to 0 for OPEN
614
+ # xsplines (primitives.R:795-803 ``validDetails.xspline``). This
615
+ # makes the curve pass exactly through the endpoints: at shape=0,
616
+ # ``positive_s1/s2_influence`` at ``t=0`` reduces to ``A1=1`` and
617
+ # all other weights 0, so the blend resolves to the (duplicated)
618
+ # first control point. Without this, open splines with nonzero
619
+ # end shapes do not land on the endpoints.
620
+ if open_ and n >= 1:
621
+ s = s.copy()
622
+ s[0] = 0.0
623
+ s[-1] = 0.0
624
+
625
+ # R's precision parameter (LOW_PRECISION=1.0 is the default for
626
+ # ``GEXspline``). Step size is derived adaptively from segment
627
+ # geometry (see ``_xsp_step``).
628
+ precision = 1.0
629
+
630
+ if open_:
631
+ out_x, out_y = _xsp_compute_open(x, y, s, repEnds, precision)
632
+ else:
633
+ out_x, out_y = _xsp_compute_closed(x, y, s, precision)
634
+
635
+ return out_x, out_y
636
+
637
+
638
+ # -- Blanc-Schlick polynomial blending kernels ------------------------------
639
+ #
640
+ # Direct port of ``f_blend`` / ``g_blend`` / ``h_blend`` in
641
+ # R's ``src/main/xspline.c`` (lines 138-159). ``Q(s) = -s``.
642
+
643
+ def _xsp_f_blend(numerator: float, denominator: float) -> float:
644
+ # f(u) = u^3 * (10 - p + (2p - 15) u + (6 - p) u^2), p = 2*denom^2
645
+ p = 2.0 * denominator * denominator
646
+ u = numerator / denominator
647
+ u2 = u * u
648
+ return u * u2 * (10.0 - p + (2.0 * p - 15.0) * u + (6.0 - p) * u2)
649
+
650
+
651
+ def _xsp_g_blend(u: float, q: float) -> float:
652
+ # g(u) = u * (q + u * (2q + u * (8 - 12q + u * (14q - 11 + u * (4 - 5q)))))
653
+ return u * (q + u * (2.0 * q + u * (8.0 - 12.0 * q + u *
654
+ (14.0 * q - 11.0 + u * (4.0 - 5.0 * q)))))
655
+
656
+
657
+ def _xsp_h_blend(u: float, q: float) -> float:
658
+ # h(u) = u * (q + u * (2q + u^2 * (-2q - u*q)))
659
+ u2 = u * u
660
+ return u * (q + u * (2.0 * q + u2 * (-2.0 * q - u * q)))
661
+
662
+
663
+ # -- Influence functions ----------------------------------------------------
664
+ #
665
+ # Direct port of ``negative_s1_influence`` / ``negative_s2_influence`` /
666
+ # ``positive_s1_influence`` / ``positive_s2_influence`` (xspline.c:161-197).
667
+ # ``Q(s) = -s`` is applied for the negative-s branches.
668
+
669
+ def _xsp_neg_s1(t: float, s1: float) -> Tuple[float, float]:
670
+ q = -s1
671
+ return _xsp_h_blend(-t, q), _xsp_g_blend(t, q)
672
+
673
+
674
+ def _xsp_neg_s2(t: float, s2: float) -> Tuple[float, float]:
675
+ q = -s2
676
+ return _xsp_g_blend(1.0 - t, q), _xsp_h_blend(t - 1.0, q)
677
+
678
+
679
+ def _xsp_pos_s1(k: float, t: float, s1: float) -> Tuple[float, float]:
680
+ Tk = k + 1.0 + s1
681
+ A0 = _xsp_f_blend(t + k + 1.0 - Tk, k - Tk) if (t + k + 1.0) < Tk else 0.0
682
+ Tk = k + 1.0 - s1
683
+ A2 = _xsp_f_blend(t + k + 1.0 - Tk, k + 2.0 - Tk)
684
+ return A0, A2
685
+
686
+
687
+ def _xsp_pos_s2(k: float, t: float, s2: float) -> Tuple[float, float]:
688
+ Tk = k + 2.0 + s2
689
+ A1 = _xsp_f_blend(t + k + 1.0 - Tk, k + 1.0 - Tk)
690
+ Tk = k + 2.0 - s2
691
+ A3 = _xsp_f_blend(t + k + 1.0 - Tk, k + 3.0 - Tk) if (t + k + 1.0) > Tk else 0.0
692
+ return A1, A3
693
+
694
+
695
+ def _xsp_weights(k: float, t: float, s1: float, s2: float
696
+ ) -> Tuple[float, float, float, float]:
697
+ """Compute (A0, A1, A2, A3) blending weights for one ``(k, t, s1, s2)``."""
698
+ if s1 < 0.0:
699
+ A0, A2 = _xsp_neg_s1(t, s1)
700
+ else:
701
+ A0, A2 = _xsp_pos_s1(k, t, s1)
702
+ if s2 < 0.0:
703
+ A1, A3 = _xsp_neg_s2(t, s2)
704
+ else:
705
+ A1, A3 = _xsp_pos_s2(k, t, s2)
706
+ return A0, A1, A2, A3
707
+
708
+
709
+ def _xsp_point(A: Tuple[float, float, float, float],
710
+ px: Tuple[float, float, float, float],
711
+ py: Tuple[float, float, float, float]
712
+ ) -> Tuple[float, float]:
713
+ """``point_computing`` / ``point_adding``: weighted blend normalised."""
714
+ ws = A[0] + A[1] + A[2] + A[3]
715
+ num_x = A[0] * px[0] + A[1] * px[1] + A[2] * px[2] + A[3] * px[3]
716
+ num_y = A[0] * py[0] + A[1] * py[1] + A[2] * py[2] + A[3] * py[3]
717
+ return num_x / ws, num_y / ws
718
+
719
+
720
+ # -- Adaptive step computation (xspline.c:224-342) --------------------------
721
+
722
+ _MAX_SPLINE_STEP = 0.2
723
+
724
+
725
+ def _xsp_step(k: int, px: Tuple[float, ...], py: Tuple[float, ...],
726
+ s1: float, s2: float, precision: float) -> float:
727
+ """Port of R's ``step_computing`` — adaptive step based on curve extent.
728
+
729
+ The step is chosen so the polyline sampling resolution matches the
730
+ physical distance from segment origin to extremity, augmented by a
731
+ curvature term (cosine of the origin-mid-extremity angle).
732
+ """
733
+ if s1 == 0.0 and s2 == 0.0:
734
+ return 1.0 # linear segment
735
+
736
+ # origin (t=0)
737
+ if s1 > 0.0:
738
+ if s2 < 0.0:
739
+ A0, A2 = _xsp_pos_s1(k, 0.0, s1)
740
+ A1, A3 = _xsp_neg_s2(0.0, s2)
741
+ else:
742
+ A0, A2 = _xsp_pos_s1(k, 0.0, s1)
743
+ A1, A3 = _xsp_pos_s2(k, 0.0, s2)
744
+ xstart, ystart = _xsp_point((A0, A1, A2, A3), px, py)
745
+ else:
746
+ xstart, ystart = px[1], py[1]
747
+
748
+ # extremity (t=1)
749
+ if s2 > 0.0:
750
+ if s1 < 0.0:
751
+ A0, A2 = _xsp_neg_s1(1.0, s1)
752
+ A1, A3 = _xsp_pos_s2(k, 1.0, s2)
753
+ else:
754
+ A0, A2 = _xsp_pos_s1(k, 1.0, s1)
755
+ A1, A3 = _xsp_pos_s2(k, 1.0, s2)
756
+ xend, yend = _xsp_point((A0, A1, A2, A3), px, py)
757
+ else:
758
+ xend, yend = px[2], py[2]
759
+
760
+ # midpoint (t=0.5)
761
+ if s2 > 0.0:
762
+ if s1 < 0.0:
763
+ A0, A2 = _xsp_neg_s1(0.5, s1)
764
+ A1, A3 = _xsp_pos_s2(k, 0.5, s2)
765
+ else:
766
+ A0, A2 = _xsp_pos_s1(k, 0.5, s1)
767
+ A1, A3 = _xsp_pos_s2(k, 0.5, s2)
768
+ elif s1 < 0.0:
769
+ A0, A2 = _xsp_neg_s1(0.5, s1)
770
+ A1, A3 = _xsp_neg_s2(0.5, s2)
771
+ else:
772
+ A0, A2 = _xsp_pos_s1(k, 0.5, s1)
773
+ A1, A3 = _xsp_neg_s2(0.5, s2)
774
+ xmid, ymid = _xsp_point((A0, A1, A2, A3), px, py)
775
+
776
+ xv1, yv1 = xstart - xmid, ystart - ymid
777
+ xv2, yv2 = xend - xmid, yend - ymid
778
+ scal = xv1 * xv2 + yv1 * yv2
779
+ sides = math.sqrt((xv1 * xv1 + yv1 * yv1) * (xv2 * xv2 + yv2 * yv2))
780
+ angle_cos = 0.0 if sides == 0.0 else scal / sides
781
+
782
+ xlen = xend - xstart
783
+ ylen = yend - ystart
784
+ dist = math.sqrt(xlen * xlen + ylen * ylen)
785
+
786
+ # R (via XFig) does all step math in 1200 ppi units. Our coordinates
787
+ # are in whatever linear unit the caller passed (usually inches), so
788
+ # scale by 1200 here to reproduce R's sampling density. Downstream
789
+ # output coordinates are unaffected — only the step count changes.
790
+ dist = dist * 1200.0
791
+
792
+ # R's diagonal clamp (xspline.c:312-325) avoids runaway sampling when
793
+ # control points are far outside the device; we approximate it with a
794
+ # fixed cap equivalent to ~1.7 inches × 1200 diagonal.
795
+ if dist > 2000.0:
796
+ dist = 2000.0
797
+
798
+ n_steps = math.sqrt(dist) / 2.0
799
+ n_steps += int((1.0 + angle_cos) * 10.0)
800
+ step = 1.0 if n_steps == 0 else precision / n_steps
801
+ if step > _MAX_SPLINE_STEP or step == 0.0:
802
+ step = _MAX_SPLINE_STEP
803
+ return step
804
+
805
+
806
+ # -- Segment sampling (xspline.c:344-423) -----------------------------------
807
+
808
+ def _xsp_segment(step: float, k: int,
809
+ px: Tuple[float, ...], py: Tuple[float, ...],
810
+ s1: float, s2: float,
811
+ out_x: List[float], out_y: List[float]) -> None:
812
+ """Port of ``spline_segment_computing`` — sample segment over ``t ∈ [0, 1)``.
813
+
814
+ Emits points into ``out_x`` / ``out_y`` with de-duplication against the
815
+ last emitted point (matches R's ``add_point`` which skips repeats).
816
+ """
817
+ t = 0.0
818
+ while t < 1.0:
819
+ A = _xsp_weights(k, t, s1, s2)
820
+ bx, by = _xsp_point(A, px, py)
821
+ if not out_x or out_x[-1] != bx or out_y[-1] != by:
822
+ out_x.append(bx)
823
+ out_y.append(by)
824
+ t += step
825
+
826
+
827
+ def _xsp_last_segment(step: float, k: int,
828
+ px: Tuple[float, ...], py: Tuple[float, ...],
829
+ s1: float, s2: float,
830
+ out_x: List[float], out_y: List[float]) -> None:
831
+ """Port of ``spline_last_segment_computing`` — one point at t=1."""
832
+ A = _xsp_weights(k, 1.0, s1, s2)
833
+ bx, by = _xsp_point(A, px, py)
834
+ if not out_x or out_x[-1] != bx or out_y[-1] != by:
835
+ out_x.append(bx)
836
+ out_y.append(by)
837
+
838
+
839
+ # -- Open / closed drivers (xspline.c:455-547) ------------------------------
840
+
841
+ def _xsp_compute_open(
842
+ x: NDArray[np.float64], y: NDArray[np.float64], s: NDArray[np.float64],
843
+ repEnds: bool, precision: float,
844
+ ) -> Tuple[NDArray[np.float64], NDArray[np.float64]]:
845
+ n = len(x)
846
+ if repEnds and n < 2:
847
+ raise ValueError("there must be at least two control points")
848
+ if not repEnds and n < 4:
849
+ raise ValueError("there must be at least four control points")
850
+
851
+ out_x: List[float] = []
852
+ out_y: List[float] = []
853
+
854
+ if repEnds:
855
+ # First control point is needed twice for the first segment.
856
+ # px/py/ps arrays are the 4-point sliding window.
857
+ px = [x[0], x[0], x[1], x[2 if n > 2 else 1]]
858
+ py = [y[0], y[0], y[1], y[2 if n > 2 else 1]]
859
+ ps = [s[0], s[0], s[1], s[2 if n > 2 else 1]]
860
+
861
+ k = 0
862
+ while True:
863
+ step = _xsp_step(k, px, py, ps[1], ps[2], precision)
864
+ _xsp_segment(step, k, tuple(px), tuple(py), ps[1], ps[2],
865
+ out_x, out_y)
866
+ if k + 3 >= n:
867
+ break
868
+ # R's ``NEXT_CONTROL_POINTS(K, N)`` macro (xspline.c:438-442):
869
+ # ``px[0] = x[K % N]``, ``px[1] = x[(K+1) % N]``, etc. K is the
870
+ # CURRENT segment index — not incremented before indexing. Note
871
+ # this is why the sliding window overlaps between iterations.
872
+ px = [x[k % n], x[(k + 1) % n], x[(k + 2) % n], x[(k + 3) % n]]
873
+ py = [y[k % n], y[(k + 1) % n], y[(k + 2) % n], y[(k + 3) % n]]
874
+ ps = [s[k % n], s[(k + 1) % n], s[(k + 2) % n], s[(k + 3) % n]]
875
+ k += 1
876
+
877
+ # Last control point needed twice for the last segment.
878
+ if n == 2:
879
+ px = [x[n - 2], x[n - 2], x[n - 1], x[n - 1]]
880
+ py = [y[n - 2], y[n - 2], y[n - 1], y[n - 1]]
881
+ ps = [s[n - 2], s[n - 2], s[n - 1], s[n - 1]]
882
+ else:
883
+ px = [x[n - 3], x[n - 2], x[n - 1], x[n - 1]]
884
+ py = [y[n - 3], y[n - 2], y[n - 1], y[n - 1]]
885
+ ps = [s[n - 3], s[n - 2], s[n - 1], s[n - 1]]
886
+ step = _xsp_step(k, px, py, ps[1], ps[2], precision)
887
+ _xsp_segment(step, k, tuple(px), tuple(py), ps[1], ps[2],
888
+ out_x, out_y)
889
+
890
+ # Final point: px[3], py[3] (xspline.c:510)
891
+ if not out_x or out_x[-1] != px[3] or out_y[-1] != py[3]:
892
+ out_x.append(float(px[3]))
893
+ out_y.append(float(py[3]))
894
+ else:
895
+ # repEnds=False: no endpoint replication. Exactly n-3 segments,
896
+ # then one final-segment t=1 point.
897
+ step = 0.0
898
+ for k in range(n - 3):
899
+ px = [x[k], x[k + 1], x[k + 2], x[k + 3]]
900
+ py = [y[k], y[k + 1], y[k + 2], y[k + 3]]
901
+ ps = [s[k], s[k + 1], s[k + 2], s[k + 3]]
902
+ step = _xsp_step(k, px, py, ps[1], ps[2], precision)
903
+ _xsp_segment(step, k, tuple(px), tuple(py), ps[1], ps[2],
904
+ out_x, out_y)
905
+ # Last segment's t=1 evaluation (xspline.c:516)
906
+ k = n - 4
907
+ px = [x[k], x[k + 1], x[k + 2], x[k + 3]]
908
+ py = [y[k], y[k + 1], y[k + 2], y[k + 3]]
909
+ ps = [s[k], s[k + 1], s[k + 2], s[k + 3]]
910
+ _xsp_last_segment(step, k, tuple(px), tuple(py), ps[1], ps[2],
911
+ out_x, out_y)
912
+
913
+ # R trims leading / trailing duplicate points (grid.c:2494-2504).
914
+ # We emulate that: remove consecutive duplicates at start only
915
+ # (trailing dedup already happens in _xsp_segment's emit).
916
+ while len(out_x) > 1 and out_x[0] == out_x[1] and out_y[0] == out_y[1]:
917
+ out_x.pop(0)
918
+ out_y.pop(0)
919
+
920
+ return (np.asarray(out_x, dtype=np.float64),
921
+ np.asarray(out_y, dtype=np.float64))
922
+
923
+
924
+ def _xsp_compute_closed(
925
+ x: NDArray[np.float64], y: NDArray[np.float64], s: NDArray[np.float64],
926
+ precision: float,
927
+ ) -> Tuple[NDArray[np.float64], NDArray[np.float64]]:
928
+ n = len(x)
929
+ if n < 3:
930
+ raise ValueError("there must be at least three control points")
931
+
932
+ out_x: List[float] = []
933
+ out_y: List[float] = []
934
+
935
+ # INIT_CONTROL_POINTS: (n-1, 0, 1, 2) mod n
936
+ idx = [(n - 1) % n, 0 % n, 1 % n, 2 % n]
937
+ px = [x[i] for i in idx]
938
+ py = [y[i] for i in idx]
939
+ ps = [s[i] for i in idx]
940
+
941
+ for k in range(n):
942
+ step = _xsp_step(k, px, py, ps[1], ps[2], precision)
943
+ _xsp_segment(step, k, tuple(px), tuple(py), ps[1], ps[2],
944
+ out_x, out_y)
945
+ # NEXT_CONTROL_POINTS(K, N): (K..K+3) mod n
946
+ idx = [(k + 1) % n, (k + 2) % n, (k + 3) % n, (k + 4) % n]
947
+ px = [x[i] for i in idx]
948
+ py = [y[i] for i in idx]
949
+ ps = [s[i] for i in idx]
950
+
951
+ return (np.asarray(out_x, dtype=np.float64),
952
+ np.asarray(out_y, dtype=np.float64))
953
+
954
+
955
+ # ===================================================================== #
956
+ # Internal: Bezier point calculation (de Casteljau) #
957
+ # ===================================================================== #
958
+
959
+
960
+ def _calc_bezier_points(
961
+ x: NDArray[np.float64],
962
+ y: NDArray[np.float64],
963
+ n: int = 50,
964
+ ) -> Tuple[NDArray[np.float64], NDArray[np.float64]]:
965
+ """Evaluate a Bezier curve using the de Casteljau algorithm.
966
+
967
+ Parameters
968
+ ----------
969
+ x, y : ndarray
970
+ Control-point coordinates. Typically 4 points for a cubic
971
+ Bezier, but any number >= 2 is accepted.
972
+ n : int
973
+ Number of evaluation points along the curve.
974
+
975
+ Returns
976
+ -------
977
+ tuple of ndarray
978
+ ``(x_pts, y_pts)`` evaluated Bezier curve coordinates.
979
+ """
980
+ x = np.asarray(x, dtype=np.float64)
981
+ y = np.asarray(y, dtype=np.float64)
982
+ npts = len(x)
983
+
984
+ if npts < 2:
985
+ return x.copy(), y.copy()
986
+
987
+ t_vals = np.linspace(0.0, 1.0, n)
988
+ out_x = np.empty(n, dtype=np.float64)
989
+ out_y = np.empty(n, dtype=np.float64)
990
+
991
+ for k, t in enumerate(t_vals):
992
+ # de Casteljau
993
+ bx = x.copy()
994
+ by = y.copy()
995
+ for r in range(1, npts):
996
+ bx[:npts - r] = (1 - t) * bx[:npts - r] + t * bx[1:npts - r + 1]
997
+ by[:npts - r] = (1 - t) * by[:npts - r] + t * by[1:npts - r + 1]
998
+ out_x[k] = bx[0]
999
+ out_y[k] = by[0]
1000
+
1001
+ return out_x, out_y
1002
+
1003
+
1004
+ # ===================================================================== #
1005
+ # curveGrob / grid.curve #
1006
+ # ===================================================================== #
1007
+
1008
+
1009
+ def curve_grob(
1010
+ x1: Any = 0,
1011
+ y1: Any = 0,
1012
+ x2: Any = 1,
1013
+ y2: Any = 1,
1014
+ default_units: str = "npc",
1015
+ curvature: float = 1.0,
1016
+ angle: float = 90.0,
1017
+ ncp: int = 1,
1018
+ shape: float = 0.5,
1019
+ square: bool = True,
1020
+ squareShape: float = 1.0,
1021
+ inflect: bool = False,
1022
+ arrow: Optional[Arrow] = None,
1023
+ open_: bool = True,
1024
+ name: Optional[str] = None,
1025
+ gp: Optional[Gpar] = None,
1026
+ vp: Optional[Any] = None,
1027
+ ) -> GTree:
1028
+ """Create a *curve* grob (GTree).
1029
+
1030
+ A curve grob draws a smooth curve between two endpoints. The shape
1031
+ of the curve is controlled by ``curvature``, ``angle``, ``ncp``, and
1032
+ ``shape``.
1033
+
1034
+ Parameters
1035
+ ----------
1036
+ x1, y1 : Unit or numeric
1037
+ Start-point coordinates.
1038
+ x2, y2 : Unit or numeric
1039
+ End-point coordinates.
1040
+ default_units : str
1041
+ Unit type for bare numerics (default ``"npc"``).
1042
+ curvature : float
1043
+ Amount of curvature. 0 = straight line, positive curves right,
1044
+ negative curves left.
1045
+ angle : float
1046
+ Angle in degrees (0--180) controlling the skewness of the curve.
1047
+ ncp : int
1048
+ Number of control points on the curve.
1049
+ shape : float
1050
+ X-spline shape parameter (-1 to 1).
1051
+ square : bool
1052
+ Whether to use "square" control-point placement for better
1053
+ aesthetics with right-angled curves.
1054
+ squareShape : float
1055
+ Shape for extra square control point (-1 to 1).
1056
+ inflect : bool
1057
+ Whether the curve should inflect at the midpoint.
1058
+ arrow : Arrow or None
1059
+ Arrow-head specification.
1060
+ open_ : bool
1061
+ Whether the spline is open.
1062
+ name : str or None
1063
+ Grob name (auto-generated when ``None``).
1064
+ gp : Gpar or None
1065
+ Graphical parameters.
1066
+ vp : viewport or None
1067
+ Optional viewport.
1068
+
1069
+ Returns
1070
+ -------
1071
+ GTree
1072
+ A grob tree with ``_grid_class="curve"``.
1073
+
1074
+ Raises
1075
+ ------
1076
+ ValueError
1077
+ If ``shape`` or ``squareShape`` is outside [-1, 1].
1078
+ """
1079
+ if not (-1 <= shape <= 1):
1080
+ raise ValueError("'shape' must be between -1 and 1")
1081
+ if not (-1 <= squareShape <= 1):
1082
+ raise ValueError("'squareShape' must be between -1 and 1")
1083
+
1084
+ ux1 = _ensure_unit(x1, default_units)
1085
+ uy1 = _ensure_unit(y1, default_units)
1086
+ ux2 = _ensure_unit(x2, default_units)
1087
+ uy2 = _ensure_unit(y2, default_units)
1088
+
1089
+ angle = angle % 180
1090
+
1091
+ return _CurveGrob(
1092
+ name=name,
1093
+ gp=gp,
1094
+ vp=vp,
1095
+ _grid_class="curve",
1096
+ x1=ux1,
1097
+ y1=uy1,
1098
+ x2=ux2,
1099
+ y2=uy2,
1100
+ curvature=float(curvature),
1101
+ angle=float(angle),
1102
+ ncp=int(ncp),
1103
+ shape=float(shape),
1104
+ square=bool(square),
1105
+ squareShape=float(squareShape),
1106
+ inflect=bool(inflect),
1107
+ arrow=arrow,
1108
+ open_=bool(open_),
1109
+ )
1110
+
1111
+
1112
+ class _CurveGrob(GTree):
1113
+ """GTree for ``_grid_class="curve"``.
1114
+
1115
+ ``make_content`` lazily expands the curve into ``segments`` and / or
1116
+ ``xspline`` children at draw time, so endpoint unit conversion happens
1117
+ in the current viewport context.
1118
+ """
1119
+
1120
+ def make_content(self) -> Grob:
1121
+ return _calc_curve_content(self)
1122
+
1123
+
1124
+ def _calc_curve_content(x: "_CurveGrob") -> GTree:
1125
+ """Expand a curve grob into a gTree of segments / xspline children.
1126
+
1127
+ curvature = 0 or near-flat angles produce a plain ``segments_grob``.
1128
+ Under ``square=True`` horizontal / vertical segments are peeled off
1129
+ (``_calc_control_points`` divides by dx / dy). Other cases build an
1130
+ xspline from control points, optionally reflecting about the midpoint
1131
+ when ``inflect=True``.
1132
+ """
1133
+ x1_u = x.x1
1134
+ y1_u = x.y1
1135
+ x2_u = x.x2
1136
+ y2_u = x.y2
1137
+ curvature = float(x.curvature)
1138
+ angle = float(x.angle)
1139
+ ncp = int(x.ncp)
1140
+ shape = float(x.shape)
1141
+ square = bool(x.square)
1142
+ squareShape = float(x.squareShape)
1143
+ inflect = bool(x.inflect)
1144
+ arrow = x.arrow
1145
+ open_ = bool(x.open_)
1146
+
1147
+ x1 = np.atleast_1d(np.asarray(convert_x(x1_u, "inches", valueOnly=True), dtype=float))
1148
+ y1 = np.atleast_1d(np.asarray(convert_y(y1_u, "inches", valueOnly=True), dtype=float))
1149
+ x2 = np.atleast_1d(np.asarray(convert_x(x2_u, "inches", valueOnly=True), dtype=float))
1150
+ y2 = np.atleast_1d(np.asarray(convert_y(y2_u, "inches", valueOnly=True), dtype=float))
1151
+
1152
+ if np.any((x1 == x2) & (y1 == y2)):
1153
+ raise ValueError("end points must not be identical")
1154
+
1155
+ maxn = int(max(len(x1), len(y1), len(x2), len(y2)))
1156
+ x1 = np.resize(x1, maxn)
1157
+ y1 = np.resize(y1, maxn)
1158
+ x2 = np.resize(x2, maxn)
1159
+ y2 = np.resize(y2, maxn)
1160
+
1161
+ def _straight(a1: np.ndarray, b1: np.ndarray, a2: np.ndarray, b2: np.ndarray) -> Grob:
1162
+ return segments_grob(
1163
+ x0=a1, y0=b1, x1=a2, y1=b2,
1164
+ default_units="inches", arrow=arrow, name="segment",
1165
+ )
1166
+
1167
+ children_list: List[Grob] = []
1168
+
1169
+ if curvature == 0:
1170
+ children_list.append(_straight(x1, y1, x2, y2))
1171
+ else:
1172
+ if angle < 1 or angle > 179:
1173
+ children_list.append(_straight(x1, y1, x2, y2))
1174
+ else:
1175
+ straight_grob: Optional[Grob] = None
1176
+ if square:
1177
+ subset = (x1 == x2) | (y1 == y2)
1178
+ if np.any(subset):
1179
+ straight_grob = _straight(x1[subset], y1[subset], x2[subset], y2[subset])
1180
+ keep = ~subset
1181
+ x1 = x1[keep]
1182
+ y1 = y1[keep]
1183
+ x2 = x2[keep]
1184
+ y2 = y2[keep]
1185
+
1186
+ ncurve = int(len(x1))
1187
+ if ncurve == 0:
1188
+ if straight_grob is not None:
1189
+ children_list.append(straight_grob)
1190
+ else:
1191
+ base_shape = np.full(ncp * ncurve, shape, dtype=float)
1192
+
1193
+ if inflect:
1194
+ xm = (x1 + x2) / 2.0
1195
+ ym = (y1 + y2) / 2.0
1196
+ shape1 = base_shape.copy()
1197
+ shape2 = base_shape[::-1].copy()
1198
+
1199
+ if square:
1200
+ cpx1, cpy1, end1 = _calc_square_control_points(
1201
+ x1, y1, xm, ym, curvature, angle, ncp,
1202
+ )
1203
+ cpx2, cpy2, end2 = _calc_square_control_points(
1204
+ xm, ym, x2, y2, -curvature, angle, ncp,
1205
+ )
1206
+ shape1 = _interleave(
1207
+ ncp, ncurve, shape1,
1208
+ np.full(ncurve, squareShape),
1209
+ np.full(ncurve, squareShape),
1210
+ end1,
1211
+ )
1212
+ shape2 = _interleave(
1213
+ ncp, ncurve, shape2,
1214
+ np.full(ncurve, squareShape),
1215
+ np.full(ncurve, squareShape),
1216
+ end2,
1217
+ )
1218
+ ncp_eff = ncp + 1
1219
+ else:
1220
+ cpx1, cpy1 = _calc_control_points(
1221
+ x1, y1, xm, ym, curvature, angle, ncp,
1222
+ )
1223
+ cpx2, cpy2 = _calc_control_points(
1224
+ xm, ym, x2, y2, -curvature, angle, ncp,
1225
+ )
1226
+ ncp_eff = ncp
1227
+
1228
+ idset = np.arange(1, ncurve + 1, dtype=int)
1229
+ spline_x = np.concatenate([x1, cpx1, xm, cpx2, x2])
1230
+ spline_y = np.concatenate([y1, cpy1, ym, cpy2, y2])
1231
+ rep_id = np.repeat(idset, ncp_eff)
1232
+ spline_id = np.concatenate([idset, rep_id, idset, rep_id, idset])
1233
+ spline_shape = np.concatenate([
1234
+ np.zeros(ncurve),
1235
+ shape1,
1236
+ np.zeros(ncurve),
1237
+ shape2,
1238
+ np.zeros(ncurve),
1239
+ ])
1240
+ spline = xspline_grob(
1241
+ x=spline_x, y=spline_y,
1242
+ default_units="inches",
1243
+ shape=spline_shape,
1244
+ open_=open_, arrow=arrow,
1245
+ name="xspline",
1246
+ )
1247
+ spline.id = spline_id
1248
+ if straight_grob is not None:
1249
+ children_list.extend([straight_grob, spline])
1250
+ else:
1251
+ children_list.append(spline)
1252
+ else:
1253
+ shape_arr = base_shape
1254
+ if square:
1255
+ cpx, cpy, cend = _calc_square_control_points(
1256
+ x1, y1, x2, y2, curvature, angle, ncp,
1257
+ )
1258
+ shape_arr = _interleave(
1259
+ ncp, ncurve, shape_arr,
1260
+ np.full(ncurve, squareShape),
1261
+ np.full(ncurve, squareShape),
1262
+ cend,
1263
+ )
1264
+ ncp_eff = ncp + 1
1265
+ else:
1266
+ cpx, cpy = _calc_control_points(
1267
+ x1, y1, x2, y2, curvature, angle, ncp,
1268
+ )
1269
+ ncp_eff = ncp
1270
+
1271
+ idset = np.arange(1, ncurve + 1, dtype=int)
1272
+ spline_x = np.concatenate([x1, cpx, x2])
1273
+ spline_y = np.concatenate([y1, cpy, y2])
1274
+ spline_id = np.concatenate([
1275
+ idset,
1276
+ np.repeat(idset, ncp_eff),
1277
+ idset,
1278
+ ])
1279
+ spline_shape = np.concatenate([
1280
+ np.zeros(ncurve),
1281
+ shape_arr,
1282
+ np.zeros(ncurve),
1283
+ ])
1284
+ spline = xspline_grob(
1285
+ x=spline_x, y=spline_y,
1286
+ default_units="inches",
1287
+ shape=spline_shape,
1288
+ open_=open_, arrow=arrow,
1289
+ name="xspline",
1290
+ )
1291
+ spline.id = spline_id
1292
+ if straight_grob is not None:
1293
+ children_list.extend([straight_grob, spline])
1294
+ else:
1295
+ children_list.append(spline)
1296
+
1297
+ return GTree(
1298
+ children=GList(*children_list),
1299
+ name=x.name, gp=x.gp, vp=x.vp,
1300
+ )
1301
+
1302
+
1303
+ def grid_curve(
1304
+ x1: Any = 0,
1305
+ y1: Any = 0,
1306
+ x2: Any = 1,
1307
+ y2: Any = 1,
1308
+ default_units: str = "npc",
1309
+ curvature: float = 1.0,
1310
+ angle: float = 90.0,
1311
+ ncp: int = 1,
1312
+ shape: float = 0.5,
1313
+ square: bool = True,
1314
+ squareShape: float = 1.0,
1315
+ inflect: bool = False,
1316
+ arrow: Optional[Arrow] = None,
1317
+ open_: bool = True,
1318
+ name: Optional[str] = None,
1319
+ gp: Optional[Gpar] = None,
1320
+ draw: bool = True,
1321
+ vp: Optional[Any] = None,
1322
+ ) -> GTree:
1323
+ """Create and optionally draw a *curve* grob.
1324
+
1325
+ Parameters
1326
+ ----------
1327
+ x1, y1 : Unit or numeric
1328
+ Start-point coordinates.
1329
+ x2, y2 : Unit or numeric
1330
+ End-point coordinates.
1331
+ default_units : str
1332
+ Unit type for bare numerics.
1333
+ curvature : float
1334
+ Curvature parameter.
1335
+ angle : float
1336
+ Angle in degrees (0--180).
1337
+ ncp : int
1338
+ Number of control points.
1339
+ shape : float
1340
+ X-spline shape (-1 to 1).
1341
+ square : bool
1342
+ Use square control-point placement.
1343
+ squareShape : float
1344
+ Shape for extra square point.
1345
+ inflect : bool
1346
+ Inflect at midpoint.
1347
+ arrow : Arrow or None
1348
+ Arrow specification.
1349
+ open_ : bool
1350
+ Open spline.
1351
+ name : str or None
1352
+ Grob name.
1353
+ gp : Gpar or None
1354
+ Graphical parameters.
1355
+ draw : bool
1356
+ If ``True`` (default), immediately record the grob for drawing.
1357
+ vp : viewport or None
1358
+ Optional viewport.
1359
+
1360
+ Returns
1361
+ -------
1362
+ GTree
1363
+ The curve grob.
1364
+ """
1365
+ grob = curve_grob(
1366
+ x1=x1, y1=y1, x2=x2, y2=y2,
1367
+ default_units=default_units,
1368
+ curvature=curvature, angle=angle, ncp=ncp,
1369
+ shape=shape, square=square, squareShape=squareShape,
1370
+ inflect=inflect, arrow=arrow, open_=open_,
1371
+ name=name, gp=gp, vp=vp,
1372
+ )
1373
+ if draw:
1374
+ _grid_draw(grob)
1375
+ return grob
1376
+
1377
+
1378
+ # ===================================================================== #
1379
+ # xsplineGrob / grid.xspline #
1380
+ # ===================================================================== #
1381
+
1382
+
1383
+ def xspline_grob(
1384
+ x: Optional[Any] = None,
1385
+ y: Optional[Any] = None,
1386
+ default_units: str = "npc",
1387
+ shape: Union[float, Sequence[float]] = 0.0,
1388
+ open_: bool = True,
1389
+ arrow: Optional[Arrow] = None,
1390
+ repEnds: bool = True,
1391
+ name: Optional[str] = None,
1392
+ gp: Optional[Gpar] = None,
1393
+ vp: Optional[Any] = None,
1394
+ ) -> Grob:
1395
+ """Create an *xspline* grob.
1396
+
1397
+ An X-spline grob draws a smooth curve through control points whose
1398
+ shape is governed by per-point ``shape`` parameters.
1399
+
1400
+ Parameters
1401
+ ----------
1402
+ x, y : Unit, numeric, sequence, or None
1403
+ Control-point coordinates. Defaults to ``Unit([0, 1], "npc")``
1404
+ when ``None``.
1405
+ default_units : str
1406
+ Unit type for bare numerics.
1407
+ shape : float or sequence of float
1408
+ Shape parameter(s) in [-1, 1]. A scalar is broadcast to all
1409
+ control points.
1410
+ open_ : bool
1411
+ Whether the spline is open (True) or closed (False).
1412
+ arrow : Arrow or None
1413
+ Arrow-head specification.
1414
+ repEnds : bool
1415
+ Whether to replicate endpoints so the spline passes through them.
1416
+ name : str or None
1417
+ Grob name.
1418
+ gp : Gpar or None
1419
+ Graphical parameters.
1420
+ vp : viewport or None
1421
+ Optional viewport.
1422
+
1423
+ Returns
1424
+ -------
1425
+ Grob
1426
+ A grob with ``_grid_class="xspline"``.
1427
+ """
1428
+ if x is None:
1429
+ x = Unit([0, 1], "npc")
1430
+ else:
1431
+ x = _ensure_unit(x, default_units)
1432
+ if y is None:
1433
+ y = Unit([0, 1], "npc")
1434
+ else:
1435
+ y = _ensure_unit(y, default_units)
1436
+
1437
+ # Normalise shape to a numpy array
1438
+ shape_arr = np.atleast_1d(np.asarray(shape, dtype=np.float64))
1439
+ if np.any((shape_arr < -1) | (shape_arr > 1)):
1440
+ raise ValueError("all 'shape' values must be between -1 and 1")
1441
+
1442
+ return Grob(
1443
+ x=x,
1444
+ y=y,
1445
+ shape=shape_arr,
1446
+ open_=bool(open_),
1447
+ arrow=arrow,
1448
+ repEnds=bool(repEnds),
1449
+ name=name,
1450
+ gp=gp,
1451
+ vp=vp,
1452
+ _grid_class="xspline",
1453
+ )
1454
+
1455
+
1456
+ def grid_xspline(
1457
+ x: Optional[Any] = None,
1458
+ y: Optional[Any] = None,
1459
+ default_units: str = "npc",
1460
+ shape: Union[float, Sequence[float]] = 0.0,
1461
+ open_: bool = True,
1462
+ arrow: Optional[Arrow] = None,
1463
+ repEnds: bool = True,
1464
+ name: Optional[str] = None,
1465
+ gp: Optional[Gpar] = None,
1466
+ draw: bool = True,
1467
+ vp: Optional[Any] = None,
1468
+ ) -> Grob:
1469
+ """Create and optionally draw an *xspline* grob.
1470
+
1471
+ Parameters
1472
+ ----------
1473
+ x, y : Unit, numeric, sequence, or None
1474
+ Control-point coordinates.
1475
+ default_units : str
1476
+ Unit type for bare numerics.
1477
+ shape : float or sequence of float
1478
+ Shape parameter(s).
1479
+ open_ : bool
1480
+ Open spline.
1481
+ arrow : Arrow or None
1482
+ Arrow specification.
1483
+ repEnds : bool
1484
+ Replicate endpoints.
1485
+ name : str or None
1486
+ Grob name.
1487
+ gp : Gpar or None
1488
+ Graphical parameters.
1489
+ draw : bool
1490
+ If ``True`` (default), record for drawing.
1491
+ vp : viewport or None
1492
+ Optional viewport.
1493
+
1494
+ Returns
1495
+ -------
1496
+ Grob
1497
+ The xspline grob.
1498
+ """
1499
+ grob = xspline_grob(
1500
+ x=x, y=y, default_units=default_units,
1501
+ shape=shape, open_=open_, arrow=arrow,
1502
+ repEnds=repEnds, name=name, gp=gp, vp=vp,
1503
+ )
1504
+ if draw:
1505
+ _grid_draw(grob)
1506
+ return grob
1507
+
1508
+
1509
+ def xspline_points(x: Grob) -> Dict[str, NDArray[np.float64]]:
1510
+ """Extract evaluated X-spline points from an xspline grob.
1511
+
1512
+ Parameters
1513
+ ----------
1514
+ x : Grob
1515
+ An xspline grob (``_grid_class="xspline"``).
1516
+
1517
+ Returns
1518
+ -------
1519
+ dict
1520
+ Dictionary with keys ``"x"`` and ``"y"``, each an ndarray of
1521
+ evaluated spline coordinates.
1522
+
1523
+ Raises
1524
+ ------
1525
+ TypeError
1526
+ If *x* is not an xspline grob.
1527
+ """
1528
+ if not isinstance(x, Grob) or getattr(x, "_grid_class", None) != "xspline":
1529
+ raise TypeError("'x' must be an xspline grob")
1530
+
1531
+ # Extract numeric values from Unit objects
1532
+ ctrl_x = np.asarray(x.x.values if hasattr(x.x, "values") else x.x, dtype=np.float64)
1533
+ ctrl_y = np.asarray(x.y.values if hasattr(x.y, "values") else x.y, dtype=np.float64)
1534
+ shape = x.shape if hasattr(x, "shape") else 0.0
1535
+ open_ = getattr(x, "open_", True)
1536
+ repEnds = getattr(x, "repEnds", True)
1537
+
1538
+ px, py = _calc_xspline_points(ctrl_x, ctrl_y, shape, open_, repEnds)
1539
+ return {"x": px, "y": py}
1540
+
1541
+
1542
+ # ===================================================================== #
1543
+ # bezierGrob / grid.bezier #
1544
+ # ===================================================================== #
1545
+
1546
+
1547
+ def bezier_grob(
1548
+ x: Any,
1549
+ y: Any,
1550
+ default_units: str = "npc",
1551
+ arrow: Optional[Arrow] = None,
1552
+ name: Optional[str] = None,
1553
+ gp: Optional[Gpar] = None,
1554
+ vp: Optional[Any] = None,
1555
+ ) -> GTree:
1556
+ """Create a *bezier* grob (GTree).
1557
+
1558
+ A Bezier grob draws a cubic (or higher-order) Bezier curve through
1559
+ the given control points.
1560
+
1561
+ Parameters
1562
+ ----------
1563
+ x, y : Unit or numeric
1564
+ Control-point coordinates. For a cubic Bezier, supply exactly 4
1565
+ points; the curve interpolates the first and last and is
1566
+ attracted toward the middle two.
1567
+ default_units : str
1568
+ Unit type for bare numerics.
1569
+ arrow : Arrow or None
1570
+ Arrow-head specification.
1571
+ name : str or None
1572
+ Grob name.
1573
+ gp : Gpar or None
1574
+ Graphical parameters.
1575
+ vp : viewport or None
1576
+ Optional viewport.
1577
+
1578
+ Returns
1579
+ -------
1580
+ GTree
1581
+ A grob tree with ``_grid_class="beziergrob"``.
1582
+ """
1583
+ ux = _ensure_unit(x, default_units)
1584
+ uy = _ensure_unit(y, default_units)
1585
+
1586
+ return GTree(
1587
+ name=name,
1588
+ gp=gp,
1589
+ vp=vp,
1590
+ _grid_class="beziergrob",
1591
+ x=ux,
1592
+ y=uy,
1593
+ arrow=arrow,
1594
+ )
1595
+
1596
+
1597
+ def grid_bezier(
1598
+ x: Any,
1599
+ y: Any,
1600
+ default_units: str = "npc",
1601
+ arrow: Optional[Arrow] = None,
1602
+ name: Optional[str] = None,
1603
+ gp: Optional[Gpar] = None,
1604
+ draw: bool = True,
1605
+ vp: Optional[Any] = None,
1606
+ ) -> GTree:
1607
+ """Create and optionally draw a *bezier* grob.
1608
+
1609
+ Parameters
1610
+ ----------
1611
+ x, y : Unit or numeric
1612
+ Control-point coordinates.
1613
+ default_units : str
1614
+ Unit type for bare numerics.
1615
+ arrow : Arrow or None
1616
+ Arrow specification.
1617
+ name : str or None
1618
+ Grob name.
1619
+ gp : Gpar or None
1620
+ Graphical parameters.
1621
+ draw : bool
1622
+ If ``True`` (default), record for drawing.
1623
+ vp : viewport or None
1624
+ Optional viewport.
1625
+
1626
+ Returns
1627
+ -------
1628
+ GTree
1629
+ The bezier grob.
1630
+ """
1631
+ grob = bezier_grob(
1632
+ x=x, y=y, default_units=default_units,
1633
+ arrow=arrow, name=name, gp=gp, vp=vp,
1634
+ )
1635
+ if draw:
1636
+ _grid_draw(grob)
1637
+ return grob
1638
+
1639
+
1640
+ def bezier_points(x: Grob, n: int = 50) -> Dict[str, NDArray[np.float64]]:
1641
+ """Extract evaluated Bezier curve points from a bezier grob.
1642
+
1643
+ Parameters
1644
+ ----------
1645
+ x : Grob
1646
+ A bezier grob (``_grid_class="beziergrob"``).
1647
+ n : int
1648
+ Number of evaluation points (default 50).
1649
+
1650
+ Returns
1651
+ -------
1652
+ dict
1653
+ Dictionary with keys ``"x"`` and ``"y"``, each an ndarray of
1654
+ evaluated Bezier coordinates.
1655
+
1656
+ Raises
1657
+ ------
1658
+ TypeError
1659
+ If *x* is not a bezier grob.
1660
+ """
1661
+ if not isinstance(x, (Grob, GTree)) or getattr(x, "_grid_class", None) != "beziergrob":
1662
+ raise TypeError("'x' must be a beziergrob grob")
1663
+
1664
+ ctrl_x = np.asarray(x.x.values if hasattr(x.x, "values") else x.x, dtype=np.float64)
1665
+ ctrl_y = np.asarray(x.y.values if hasattr(x.y, "values") else x.y, dtype=np.float64)
1666
+
1667
+ px, py = _calc_bezier_points(ctrl_x, ctrl_y, n=n)
1668
+ return {"x": px, "y": py}