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/__init__.py +340 -0
- grid_py/_arrow.py +331 -0
- grid_py/_clippath.py +170 -0
- grid_py/_colour.py +815 -0
- grid_py/_coords.py +1534 -0
- grid_py/_curve.py +1668 -0
- grid_py/_display_list.py +507 -0
- grid_py/_draw.py +1397 -0
- grid_py/_edit.py +756 -0
- grid_py/_font_metrics.py +319 -0
- grid_py/_gpar.py +572 -0
- grid_py/_grab.py +501 -0
- grid_py/_grob.py +1377 -0
- grid_py/_group.py +798 -0
- grid_py/_highlevel.py +2176 -0
- grid_py/_just.py +361 -0
- grid_py/_layout.py +593 -0
- grid_py/_ls.py +895 -0
- grid_py/_mask.py +196 -0
- grid_py/_path.py +414 -0
- grid_py/_patterns.py +1049 -0
- grid_py/_primitives.py +2198 -0
- grid_py/_renderer_base.py +1184 -0
- grid_py/_scene_graph.py +248 -0
- grid_py/_size.py +1352 -0
- grid_py/_state.py +683 -0
- grid_py/_transforms.py +448 -0
- grid_py/_typeset.py +384 -0
- grid_py/_units.py +1924 -0
- grid_py/_utils.py +310 -0
- grid_py/_viewport.py +1649 -0
- grid_py/_vp_calc.py +970 -0
- grid_py/py.typed +0 -0
- grid_py/renderer.py +1762 -0
- grid_py/renderer_web.py +764 -0
- grid_py/resources/d3.v7.min.js +2 -0
- grid_py/resources/gridpy.css +80 -0
- grid_py/resources/gridpy.js +813 -0
- rgrid_python-4.5.3.dist-info/METADATA +489 -0
- rgrid_python-4.5.3.dist-info/RECORD +42 -0
- rgrid_python-4.5.3.dist-info/WHEEL +4 -0
- rgrid_python-4.5.3.dist-info/licenses/LICENSE +3 -0
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}
|