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/_patterns.py ADDED
@@ -0,0 +1,1049 @@
1
+ """
2
+ Gradient and pattern fill system for grid_py.
3
+
4
+ Python port of R's ``grid`` package pattern infrastructure
5
+ (``grid/R/patterns.R``). Provides linear gradients, radial gradients,
6
+ and tiling patterns that can be used as fill values in graphical
7
+ parameter (gpar) objects.
8
+
9
+ Classes
10
+ -------
11
+ LinearGradient
12
+ A two-point linear colour gradient.
13
+ RadialGradient
14
+ A two-circle radial colour gradient.
15
+ Pattern
16
+ A tiling pattern based on an arbitrary grob.
17
+
18
+ Functions
19
+ ---------
20
+ linear_gradient
21
+ Factory for :class:`LinearGradient`.
22
+ radial_gradient
23
+ Factory for :class:`RadialGradient`.
24
+ pattern
25
+ Factory for :class:`Pattern`.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import Any, List, Optional, Sequence, Union
31
+
32
+ import numpy as np
33
+
34
+ from ._units import Unit, is_unit
35
+
36
+ __all__: List[str] = [
37
+ "LinearGradient",
38
+ "RadialGradient",
39
+ "Pattern",
40
+ "ResolvedPattern",
41
+ "linear_gradient",
42
+ "radial_gradient",
43
+ "pattern",
44
+ "is_pattern",
45
+ "is_pattern_list",
46
+ "is_resolved_pattern",
47
+ "resolve_fill",
48
+ "resolve_pattern",
49
+ "record_grob_for_pattern_resolution",
50
+ "record_gtree_for_pattern_resolution",
51
+ ]
52
+
53
+ # Valid *extend* modes, matching R's ``match.arg`` choices.
54
+ _VALID_EXTEND: tuple[str, ...] = ("pad", "repeat", "reflect", "none")
55
+
56
+
57
+ def _validate_extend(extend: str) -> str:
58
+ """Return *extend* if valid, otherwise raise ``ValueError``.
59
+
60
+ Parameters
61
+ ----------
62
+ extend : str
63
+ One of ``"pad"``, ``"repeat"``, ``"reflect"``, or ``"none"``.
64
+
65
+ Returns
66
+ -------
67
+ str
68
+ The validated extend string.
69
+
70
+ Raises
71
+ ------
72
+ ValueError
73
+ If *extend* is not one of the four recognised modes.
74
+ """
75
+ if extend not in _VALID_EXTEND:
76
+ raise ValueError(
77
+ f"extend must be one of {_VALID_EXTEND!r}, got {extend!r}"
78
+ )
79
+ return extend
80
+
81
+
82
+ def _ensure_unit(value: Any, default_units: str) -> Unit:
83
+ """Coerce *value* to a :class:`Unit` if it is not already one.
84
+
85
+ Parameters
86
+ ----------
87
+ value : Any
88
+ A :class:`Unit` instance, or a numeric scalar that will be
89
+ wrapped in ``Unit(value, default_units)``.
90
+ default_units : str
91
+ Unit type used when *value* is not already a :class:`Unit`.
92
+
93
+ Returns
94
+ -------
95
+ Unit
96
+ The (possibly newly created) unit.
97
+ """
98
+ if is_unit(value):
99
+ return value
100
+ return Unit(value, default_units)
101
+
102
+
103
+ def _make_stops(
104
+ colours: Sequence[str],
105
+ stops: Optional[Sequence[float]],
106
+ ) -> tuple[list[str], list[float]]:
107
+ """Normalise *colours* and *stops* following R semantics.
108
+
109
+ Both sequences are recycled to the length of the longer one. If
110
+ *stops* is ``None``, evenly-spaced values in [0, 1] are generated.
111
+
112
+ Parameters
113
+ ----------
114
+ colours : Sequence[str]
115
+ Colour specification strings.
116
+ stops : Sequence[float] or None
117
+ Gradient stop positions in [0, 1].
118
+
119
+ Returns
120
+ -------
121
+ tuple[list[str], list[float]]
122
+ ``(colours, stops)`` recycled to the same length.
123
+
124
+ Raises
125
+ ------
126
+ ValueError
127
+ If the resulting length is less than 1.
128
+ """
129
+ n_colours = len(colours)
130
+ if stops is None:
131
+ n_stops = n_colours
132
+ stops_arr: np.ndarray = np.linspace(0.0, 1.0, n_stops)
133
+ else:
134
+ stops_arr = np.asarray(stops, dtype=float)
135
+ n_stops = len(stops_arr)
136
+
137
+ nstops = max(n_colours, n_stops)
138
+ if nstops < 1:
139
+ raise ValueError("colours and stops must be at least length 1")
140
+
141
+ # Recycle both to *nstops* (mirroring R's ``rep(x, length.out=n)``).
142
+ colours_out: list[str] = [
143
+ colours[i % n_colours] for i in range(nstops)
144
+ ]
145
+ if len(stops_arr) < nstops:
146
+ stops_arr = np.resize(stops_arr, nstops)
147
+ stops_out: list[float] = stops_arr[:nstops].tolist()
148
+
149
+ return colours_out, stops_out
150
+
151
+
152
+ # ======================================================================
153
+ # LinearGradient
154
+ # ======================================================================
155
+
156
+
157
+ class LinearGradient:
158
+ """A linear colour gradient defined by two endpoints.
159
+
160
+ This corresponds to R's ``grid::linearGradient()`` and the internal
161
+ class ``GridLinearGradient``.
162
+
163
+ Parameters
164
+ ----------
165
+ colours : list[str]
166
+ Colour strings (e.g. ``["black", "white"]``).
167
+ stops : list[float] or None
168
+ Gradient stop positions in [0, 1]. ``None`` (default) produces
169
+ evenly-spaced stops matching the length of *colours*.
170
+ x1 : Unit or float or None
171
+ Horizontal start of the gradient line. Defaults to
172
+ ``Unit(0, "npc")``.
173
+ y1 : Unit or float or None
174
+ Vertical start of the gradient line. Defaults to
175
+ ``Unit(0, "npc")``.
176
+ x2 : Unit or float or None
177
+ Horizontal end of the gradient line. Defaults to
178
+ ``Unit(1, "npc")``.
179
+ y2 : Unit or float or None
180
+ Vertical end of the gradient line. Defaults to
181
+ ``Unit(1, "npc")``.
182
+ default_units : str
183
+ Unit type applied when a coordinate is given as a plain number.
184
+ extend : str
185
+ One of ``"pad"``, ``"repeat"``, ``"reflect"``, or ``"none"``.
186
+ group : bool
187
+ If ``True`` the gradient is resolved relative to the bounding
188
+ box of *all* shapes; if ``False`` it is resolved per shape.
189
+
190
+ Raises
191
+ ------
192
+ ValueError
193
+ If *extend* is invalid, or *colours*/*stops* have length < 1,
194
+ or any coordinate has length != 1.
195
+ """
196
+
197
+ def __init__(
198
+ self,
199
+ colours: list[str],
200
+ stops: Optional[list[float]] = None,
201
+ x1: Optional[Union[Unit, float]] = None,
202
+ y1: Optional[Union[Unit, float]] = None,
203
+ x2: Optional[Union[Unit, float]] = None,
204
+ y2: Optional[Union[Unit, float]] = None,
205
+ default_units: str = "npc",
206
+ extend: str = "pad",
207
+ group: bool = True,
208
+ ) -> None:
209
+ self.colours, self.stops = _make_stops(colours, stops)
210
+
211
+ self.x1: Unit = _ensure_unit(
212
+ x1 if x1 is not None else 0.0, default_units
213
+ )
214
+ self.y1: Unit = _ensure_unit(
215
+ y1 if y1 is not None else 0.0, default_units
216
+ )
217
+ self.x2: Unit = _ensure_unit(
218
+ x2 if x2 is not None else 1.0, default_units
219
+ )
220
+ self.y2: Unit = _ensure_unit(
221
+ y2 if y2 is not None else 1.0, default_units
222
+ )
223
+
224
+ # Each coordinate must be scalar (length 1).
225
+ for name, val in (
226
+ ("x1", self.x1),
227
+ ("y1", self.y1),
228
+ ("x2", self.x2),
229
+ ("y2", self.y2),
230
+ ):
231
+ if len(val) != 1:
232
+ raise ValueError(
233
+ f"{name} must be length 1, got length {len(val)}"
234
+ )
235
+
236
+ self.extend: str = _validate_extend(extend)
237
+ self.group: bool = bool(group)
238
+
239
+ # ------------------------------------------------------------------
240
+ # Dunder helpers
241
+ # ------------------------------------------------------------------
242
+
243
+ def __repr__(self) -> str: # noqa: D105
244
+ return (
245
+ f"LinearGradient(colours={self.colours!r}, "
246
+ f"stops={self.stops!r}, "
247
+ f"x1={self.x1!r}, y1={self.y1!r}, "
248
+ f"x2={self.x2!r}, y2={self.y2!r}, "
249
+ f"extend={self.extend!r}, group={self.group!r})"
250
+ )
251
+
252
+
253
+ # ======================================================================
254
+ # RadialGradient
255
+ # ======================================================================
256
+
257
+
258
+ class RadialGradient:
259
+ """A radial colour gradient defined by two circles.
260
+
261
+ This corresponds to R's ``grid::radialGradient()`` and the internal
262
+ class ``GridRadialGradient``.
263
+
264
+ Parameters
265
+ ----------
266
+ colours : list[str]
267
+ Colour strings.
268
+ stops : list[float] or None
269
+ Gradient stop positions in [0, 1].
270
+ cx1 : Unit or float or None
271
+ Horizontal centre of the inner circle. Default ``0.5 npc``.
272
+ cy1 : Unit or float or None
273
+ Vertical centre of the inner circle. Default ``0.5 npc``.
274
+ r1 : Unit or float or None
275
+ Radius of the inner circle. Default ``0 npc``.
276
+ cx2 : Unit or float or None
277
+ Horizontal centre of the outer circle. Default ``0.5 npc``.
278
+ cy2 : Unit or float or None
279
+ Vertical centre of the outer circle. Default ``0.5 npc``.
280
+ r2 : Unit or float or None
281
+ Radius of the outer circle. Default ``0.5 npc``.
282
+ default_units : str
283
+ Unit type applied when a parameter is given as a plain number.
284
+ extend : str
285
+ One of ``"pad"``, ``"repeat"``, ``"reflect"``, or ``"none"``.
286
+ group : bool
287
+ If ``True`` the gradient is resolved relative to the bounding
288
+ box of *all* shapes; if ``False`` it is resolved per shape.
289
+
290
+ Raises
291
+ ------
292
+ ValueError
293
+ If *extend* is invalid, colours/stops have length < 1, or any
294
+ coordinate/radius has length != 1.
295
+ """
296
+
297
+ def __init__(
298
+ self,
299
+ colours: list[str],
300
+ stops: Optional[list[float]] = None,
301
+ cx1: Optional[Union[Unit, float]] = None,
302
+ cy1: Optional[Union[Unit, float]] = None,
303
+ r1: Optional[Union[Unit, float]] = None,
304
+ cx2: Optional[Union[Unit, float]] = None,
305
+ cy2: Optional[Union[Unit, float]] = None,
306
+ r2: Optional[Union[Unit, float]] = None,
307
+ default_units: str = "npc",
308
+ extend: str = "pad",
309
+ group: bool = True,
310
+ ) -> None:
311
+ self.colours, self.stops = _make_stops(colours, stops)
312
+
313
+ self.cx1: Unit = _ensure_unit(
314
+ cx1 if cx1 is not None else 0.5, default_units
315
+ )
316
+ self.cy1: Unit = _ensure_unit(
317
+ cy1 if cy1 is not None else 0.5, default_units
318
+ )
319
+ self.r1: Unit = _ensure_unit(
320
+ r1 if r1 is not None else 0.0, default_units
321
+ )
322
+ self.cx2: Unit = _ensure_unit(
323
+ cx2 if cx2 is not None else 0.5, default_units
324
+ )
325
+ self.cy2: Unit = _ensure_unit(
326
+ cy2 if cy2 is not None else 0.5, default_units
327
+ )
328
+ self.r2: Unit = _ensure_unit(
329
+ r2 if r2 is not None else 0.5, default_units
330
+ )
331
+
332
+ for name, val in (
333
+ ("cx1", self.cx1),
334
+ ("cy1", self.cy1),
335
+ ("r1", self.r1),
336
+ ("cx2", self.cx2),
337
+ ("cy2", self.cy2),
338
+ ("r2", self.r2),
339
+ ):
340
+ if len(val) != 1:
341
+ raise ValueError(
342
+ f"{name} must be length 1, got length {len(val)}"
343
+ )
344
+
345
+ self.extend: str = _validate_extend(extend)
346
+ self.group: bool = bool(group)
347
+
348
+ def __repr__(self) -> str: # noqa: D105
349
+ return (
350
+ f"RadialGradient(colours={self.colours!r}, "
351
+ f"stops={self.stops!r}, "
352
+ f"cx1={self.cx1!r}, cy1={self.cy1!r}, r1={self.r1!r}, "
353
+ f"cx2={self.cx2!r}, cy2={self.cy2!r}, r2={self.r2!r}, "
354
+ f"extend={self.extend!r}, group={self.group!r})"
355
+ )
356
+
357
+
358
+ # ======================================================================
359
+ # Pattern (tiling pattern)
360
+ # ======================================================================
361
+
362
+ # Justification helpers -- resolve a single *just* string to (hjust, vjust).
363
+ _JUST_H: dict[str, float] = {
364
+ "left": 0.0,
365
+ "right": 1.0,
366
+ "centre": 0.5,
367
+ "center": 0.5,
368
+ "top": 0.5,
369
+ "bottom": 0.5,
370
+ }
371
+
372
+ _JUST_V: dict[str, float] = {
373
+ "left": 0.5,
374
+ "right": 0.5,
375
+ "centre": 0.5,
376
+ "center": 0.5,
377
+ "top": 1.0,
378
+ "bottom": 0.0,
379
+ }
380
+
381
+
382
+ def _resolve_just(
383
+ just: Union[str, tuple[float, float]],
384
+ ) -> tuple[float, float]:
385
+ """Return ``(hjust, vjust)`` from a justification specification.
386
+
387
+ Parameters
388
+ ----------
389
+ just : str or tuple[float, float]
390
+ A justification string (``"centre"``, ``"left"``, etc.) or an
391
+ explicit ``(hjust, vjust)`` pair.
392
+
393
+ Returns
394
+ -------
395
+ tuple[float, float]
396
+ Numeric ``(hjust, vjust)`` in [0, 1].
397
+
398
+ Raises
399
+ ------
400
+ ValueError
401
+ If *just* is an unrecognised string.
402
+ """
403
+ if isinstance(just, str):
404
+ j = just.lower()
405
+ if j not in _JUST_H:
406
+ raise ValueError(
407
+ f"Unrecognised justification string: {just!r}"
408
+ )
409
+ return _JUST_H[j], _JUST_V[j]
410
+ # Assume numeric pair.
411
+ return (float(just[0]), float(just[1]))
412
+
413
+
414
+ class Pattern:
415
+ """A tiling pattern fill based on an arbitrary grob.
416
+
417
+ This corresponds to R's ``grid::pattern()`` and the internal class
418
+ ``GridTilingPattern``.
419
+
420
+ Parameters
421
+ ----------
422
+ grob : Any
423
+ A grob (graphical object) to use as the repeating tile.
424
+ x : Unit or float or None
425
+ Horizontal position of the tile. Default ``0.5 npc``.
426
+ y : Unit or float or None
427
+ Vertical position of the tile. Default ``0.5 npc``.
428
+ width : Unit or float or None
429
+ Width of the tile. Default ``1 npc``.
430
+ height : Unit or float or None
431
+ Height of the tile. Default ``1 npc``.
432
+ default_units : str
433
+ Unit type applied when a dimension is given as a plain number.
434
+ just : str or tuple[float, float]
435
+ Justification of the tile relative to ``(x, y)``. Accepts
436
+ standard strings such as ``"centre"``, ``"left"``, etc., or an
437
+ explicit ``(hjust, vjust)`` pair.
438
+ extend : str
439
+ One of ``"pad"``, ``"repeat"``, ``"reflect"``, or ``"none"``.
440
+ group : bool
441
+ If ``True`` the pattern is resolved relative to the bounding
442
+ box of *all* shapes; if ``False`` it is resolved per shape.
443
+
444
+ Raises
445
+ ------
446
+ ValueError
447
+ If *extend* is invalid or any coordinate/dimension has
448
+ length != 1.
449
+ """
450
+
451
+ def __init__(
452
+ self,
453
+ grob: Any,
454
+ x: Optional[Union[Unit, float]] = None,
455
+ y: Optional[Union[Unit, float]] = None,
456
+ width: Optional[Union[Unit, float]] = None,
457
+ height: Optional[Union[Unit, float]] = None,
458
+ default_units: str = "npc",
459
+ just: Union[str, tuple[float, float]] = "centre",
460
+ extend: str = "pad",
461
+ group: bool = True,
462
+ ) -> None:
463
+ self.grob: Any = grob
464
+
465
+ self.x: Unit = _ensure_unit(
466
+ x if x is not None else 0.5, default_units
467
+ )
468
+ self.y: Unit = _ensure_unit(
469
+ y if y is not None else 0.5, default_units
470
+ )
471
+ self.width: Unit = _ensure_unit(
472
+ width if width is not None else 1.0, default_units
473
+ )
474
+ self.height: Unit = _ensure_unit(
475
+ height if height is not None else 1.0, default_units
476
+ )
477
+
478
+ for name, val in (
479
+ ("x", self.x),
480
+ ("y", self.y),
481
+ ("width", self.width),
482
+ ("height", self.height),
483
+ ):
484
+ if len(val) != 1:
485
+ raise ValueError(
486
+ f"{name} must be length 1, got length {len(val)}"
487
+ )
488
+
489
+ self.hjust: float
490
+ self.vjust: float
491
+ self.hjust, self.vjust = _resolve_just(just)
492
+
493
+ self.extend: str = _validate_extend(extend)
494
+ self.group: bool = bool(group)
495
+
496
+ def __repr__(self) -> str: # noqa: D105
497
+ return (
498
+ f"Pattern(grob={self.grob!r}, "
499
+ f"x={self.x!r}, y={self.y!r}, "
500
+ f"width={self.width!r}, height={self.height!r}, "
501
+ f"hjust={self.hjust!r}, vjust={self.vjust!r}, "
502
+ f"extend={self.extend!r}, group={self.group!r})"
503
+ )
504
+
505
+
506
+ # ======================================================================
507
+ # Factory functions
508
+ # ======================================================================
509
+
510
+
511
+ def linear_gradient(
512
+ colours: list[str] = None,
513
+ stops: Optional[list[float]] = None,
514
+ x1: Optional[Union[Unit, float]] = None,
515
+ y1: Optional[Union[Unit, float]] = None,
516
+ x2: Optional[Union[Unit, float]] = None,
517
+ y2: Optional[Union[Unit, float]] = None,
518
+ default_units: str = "npc",
519
+ extend: str = "pad",
520
+ group: bool = True,
521
+ ) -> LinearGradient:
522
+ """Create a :class:`LinearGradient`.
523
+
524
+ This is a convenience wrapper matching R's
525
+ ``grid::linearGradient()`` function signature.
526
+
527
+ Parameters
528
+ ----------
529
+ colours : list[str], optional
530
+ Colour strings. Defaults to ``["black", "white"]``.
531
+ stops : list[float] or None
532
+ Gradient stop positions in [0, 1].
533
+ x1 : Unit or float or None
534
+ Horizontal start of the gradient line.
535
+ y1 : Unit or float or None
536
+ Vertical start of the gradient line.
537
+ x2 : Unit or float or None
538
+ Horizontal end of the gradient line.
539
+ y2 : Unit or float or None
540
+ Vertical end of the gradient line.
541
+ default_units : str
542
+ Unit type used for bare numeric coordinates.
543
+ extend : str
544
+ Gradient extension mode.
545
+ group : bool
546
+ Resolve gradient relative to all shapes (``True``) or per
547
+ shape (``False``).
548
+
549
+ Returns
550
+ -------
551
+ LinearGradient
552
+ A new linear gradient object.
553
+
554
+ Examples
555
+ --------
556
+ >>> lg = linear_gradient(["red", "blue"])
557
+ >>> lg.colours
558
+ ['red', 'blue']
559
+ """
560
+ if colours is None:
561
+ colours = ["black", "white"]
562
+ return LinearGradient(
563
+ colours=colours,
564
+ stops=stops,
565
+ x1=x1,
566
+ y1=y1,
567
+ x2=x2,
568
+ y2=y2,
569
+ default_units=default_units,
570
+ extend=extend,
571
+ group=group,
572
+ )
573
+
574
+
575
+ def radial_gradient(
576
+ colours: list[str] = None,
577
+ stops: Optional[list[float]] = None,
578
+ cx1: Optional[Union[Unit, float]] = None,
579
+ cy1: Optional[Union[Unit, float]] = None,
580
+ r1: Optional[Union[Unit, float]] = None,
581
+ cx2: Optional[Union[Unit, float]] = None,
582
+ cy2: Optional[Union[Unit, float]] = None,
583
+ r2: Optional[Union[Unit, float]] = None,
584
+ default_units: str = "npc",
585
+ extend: str = "pad",
586
+ group: bool = True,
587
+ ) -> RadialGradient:
588
+ """Create a :class:`RadialGradient`.
589
+
590
+ This is a convenience wrapper matching R's
591
+ ``grid::radialGradient()`` function signature.
592
+
593
+ Parameters
594
+ ----------
595
+ colours : list[str], optional
596
+ Colour strings. Defaults to ``["black", "white"]``.
597
+ stops : list[float] or None
598
+ Gradient stop positions in [0, 1].
599
+ cx1 : Unit or float or None
600
+ Horizontal centre of the inner circle.
601
+ cy1 : Unit or float or None
602
+ Vertical centre of the inner circle.
603
+ r1 : Unit or float or None
604
+ Radius of the inner circle.
605
+ cx2 : Unit or float or None
606
+ Horizontal centre of the outer circle.
607
+ cy2 : Unit or float or None
608
+ Vertical centre of the outer circle.
609
+ r2 : Unit or float or None
610
+ Radius of the outer circle.
611
+ default_units : str
612
+ Unit type used for bare numeric values.
613
+ extend : str
614
+ Gradient extension mode.
615
+ group : bool
616
+ Resolve gradient relative to all shapes (``True``) or per
617
+ shape (``False``).
618
+
619
+ Returns
620
+ -------
621
+ RadialGradient
622
+ A new radial gradient object.
623
+
624
+ Examples
625
+ --------
626
+ >>> rg = radial_gradient(["white", "black"])
627
+ >>> rg.r2
628
+ Unit([0.5], ['npc'])
629
+ """
630
+ if colours is None:
631
+ colours = ["black", "white"]
632
+ return RadialGradient(
633
+ colours=colours,
634
+ stops=stops,
635
+ cx1=cx1,
636
+ cy1=cy1,
637
+ r1=r1,
638
+ cx2=cx2,
639
+ cy2=cy2,
640
+ r2=r2,
641
+ default_units=default_units,
642
+ extend=extend,
643
+ group=group,
644
+ )
645
+
646
+
647
+ def pattern(
648
+ grob: Any,
649
+ x: Optional[Union[Unit, float]] = None,
650
+ y: Optional[Union[Unit, float]] = None,
651
+ width: Optional[Union[Unit, float]] = None,
652
+ height: Optional[Union[Unit, float]] = None,
653
+ default_units: str = "npc",
654
+ just: Union[str, tuple[float, float]] = "centre",
655
+ extend: str = "pad",
656
+ group: bool = True,
657
+ ) -> Pattern:
658
+ """Create a :class:`Pattern` (tiling fill).
659
+
660
+ This is a convenience wrapper matching R's ``grid::pattern()``
661
+ function signature.
662
+
663
+ Parameters
664
+ ----------
665
+ grob : Any
666
+ A grob to use as the repeating tile.
667
+ x : Unit or float or None
668
+ Horizontal position of the tile.
669
+ y : Unit or float or None
670
+ Vertical position of the tile.
671
+ width : Unit or float or None
672
+ Width of the tile.
673
+ height : Unit or float or None
674
+ Height of the tile.
675
+ default_units : str
676
+ Unit type used for bare numeric values.
677
+ just : str or tuple[float, float]
678
+ Tile justification relative to ``(x, y)``.
679
+ extend : str
680
+ Pattern extension mode.
681
+ group : bool
682
+ Resolve pattern relative to all shapes (``True``) or per
683
+ shape (``False``).
684
+
685
+ Returns
686
+ -------
687
+ Pattern
688
+ A new tiling pattern object.
689
+
690
+ Examples
691
+ --------
692
+ >>> pat = pattern("placeholder_grob")
693
+ >>> pat.hjust
694
+ 0.5
695
+ """
696
+ return Pattern(
697
+ grob=grob,
698
+ x=x,
699
+ y=y,
700
+ width=width,
701
+ height=height,
702
+ default_units=default_units,
703
+ just=just,
704
+ extend=extend,
705
+ group=group,
706
+ )
707
+
708
+
709
+ # ============================================================================
710
+ # Pattern resolution pipeline
711
+ # Port of R patterns.R:140-429
712
+ # ============================================================================
713
+
714
+
715
+ def is_pattern(fill: Any) -> bool:
716
+ """Test whether *fill* is a grid pattern (gradient or tiling).
717
+
718
+ Mirrors R ``is.pattern()``.
719
+ """
720
+ return isinstance(fill, (LinearGradient, RadialGradient, Pattern))
721
+
722
+
723
+ def is_pattern_list(fill: Any) -> bool:
724
+ """Test whether *fill* is a list of patterns."""
725
+ if isinstance(fill, (list, tuple)):
726
+ return len(fill) > 0 and all(is_pattern(f) for f in fill)
727
+ return False
728
+
729
+
730
+ def is_resolved_pattern(fill: Any) -> bool:
731
+ """Test whether a pattern has already been resolved."""
732
+ return isinstance(fill, dict) and fill.get("_resolved", False)
733
+
734
+
735
+ class ResolvedPattern:
736
+ """A pattern that has been resolved to renderer-specific form.
737
+
738
+ Port of R ``resolvedPattern()`` (patterns.R:192-196).
739
+ Wraps the original pattern with a ``ref`` (renderer handle) and
740
+ marks it as resolved.
741
+ """
742
+
743
+ def __init__(self, pattern: Any, ref: Any) -> None:
744
+ self.pattern = pattern
745
+ self.ref = ref
746
+ self._resolved = True
747
+
748
+ def __repr__(self) -> str:
749
+ return f"ResolvedPattern(ref={self.ref!r})"
750
+
751
+
752
+ # ---------------------------------------------------------------------------
753
+ # resolveFill -- S3-like dispatcher
754
+ # Port of R patterns.R:198-385
755
+ # ---------------------------------------------------------------------------
756
+
757
+
758
+ def resolve_fill(fill: Any, index: int = 1, grob: Any = None) -> Any:
759
+ """Resolve a fill value to a renderer-ready form.
760
+
761
+ Port of R ``resolveFill()`` (patterns.R:198-385).
762
+ Dispatches based on fill type:
763
+ - Simple colour strings pass through unchanged
764
+ - Already-resolved patterns pass through
765
+ - Unresolved patterns → resolvePattern()
766
+ - Pattern lists → resolve each element
767
+
768
+ Parameters
769
+ ----------
770
+ fill : Any
771
+ Fill value from gpar. May be a string, LinearGradient,
772
+ RadialGradient, Pattern, ResolvedPattern, list, etc.
773
+ index : int
774
+ Shape index (1-based, for per-shape pattern lists).
775
+ grob : Grob or None
776
+ The grob being filled (attached for coordinate queries).
777
+
778
+ Returns
779
+ -------
780
+ Any
781
+ Resolved fill value (string, ResolvedPattern, or "transparent").
782
+ """
783
+ # Already resolved (R patterns.R:210-212)
784
+ if isinstance(fill, ResolvedPattern):
785
+ return fill
786
+
787
+ # Simple fill (R patterns.R:205-207)
788
+ if not is_pattern(fill) and not is_pattern_list(fill):
789
+ return fill
790
+
791
+ # Single pattern (R patterns.R:217-280)
792
+ if is_pattern(fill):
793
+ return _resolve_fill_pattern(fill, index, grob)
794
+
795
+ # Pattern list (R patterns.R:222-334)
796
+ if is_pattern_list(fill):
797
+ return _resolve_fill_pattern_list(fill, grob)
798
+
799
+ return fill
800
+
801
+
802
+ def _resolve_fill_pattern(fill: Any, index: int, grob: Any) -> Any:
803
+ """Resolve a single pattern fill.
804
+
805
+ Port of R ``resolveFill.GridGrobPattern`` (patterns.R:237-280).
806
+ """
807
+ from ._coords import grob_points, is_empty_coords, coords_bbox
808
+ from ._viewport import Viewport, push_viewport, pop_viewport
809
+ from ._gpar import Gpar
810
+
811
+ # If grob is available, compute bounding box
812
+ if grob is not None:
813
+ pts = grob_points(grob, closed=True)
814
+ if not is_empty_coords(pts):
815
+ if getattr(fill, "group", True) or len(pts) <= 1:
816
+ bbox = coords_bbox(pts)
817
+ else:
818
+ # Per-shape bounding box
819
+ idx = (index - 1) % len(pts)
820
+ bbox = coords_bbox(pts[idx] if hasattr(pts, '__getitem__') else pts)
821
+
822
+ # Push temporary viewport for pattern resolution
823
+ # (R patterns.R:266-271)
824
+ vp = Viewport(
825
+ x=bbox["left"], y=bbox["bottom"],
826
+ width=bbox["width"], height=bbox["height"],
827
+ default_units="inches",
828
+ just=("left", "bottom"),
829
+ clip="off", mask="none",
830
+ )
831
+ push_viewport(vp, recording=False)
832
+ result = resolve_pattern(fill)
833
+ pop_viewport(recording=False)
834
+ return result
835
+ else:
836
+ import warnings
837
+ warnings.warn("Pattern fill applied to object with no inside")
838
+ return "transparent"
839
+
840
+ # No grob context — resolve in current viewport
841
+ return resolve_pattern(fill)
842
+
843
+
844
+ def _resolve_fill_pattern_list(fill: list, grob: Any) -> Any:
845
+ """Resolve a list of patterns.
846
+
847
+ Port of R ``resolveFill.GridGrobPatternList`` (patterns.R:283-334).
848
+ """
849
+ resolved = []
850
+ for i, f in enumerate(fill):
851
+ if isinstance(f, ResolvedPattern):
852
+ resolved.append(f)
853
+ elif is_pattern(f):
854
+ resolved.append(_resolve_fill_pattern(f, i + 1, grob))
855
+ else:
856
+ resolved.append(f)
857
+ return resolved
858
+
859
+
860
+ # ---------------------------------------------------------------------------
861
+ # resolvePattern -- type-specific resolution
862
+ # Port of R patterns.R:387-429
863
+ # ---------------------------------------------------------------------------
864
+
865
+
866
+ def resolve_pattern(pattern: Any) -> Any:
867
+ """Resolve a pattern object to renderer-specific form.
868
+
869
+ Port of R ``resolvePattern()`` (patterns.R:387-429).
870
+ Dispatches based on pattern type: LinearGradient, RadialGradient,
871
+ or Pattern (tiling).
872
+
873
+ The resolved pattern contains a renderer-specific ``ref`` that can
874
+ be used directly by the rendering backend.
875
+
876
+ Parameters
877
+ ----------
878
+ pattern : LinearGradient or RadialGradient or Pattern
879
+ The pattern to resolve.
880
+
881
+ Returns
882
+ -------
883
+ ResolvedPattern
884
+ The pattern with renderer-specific reference attached.
885
+ """
886
+ if isinstance(pattern, LinearGradient):
887
+ return _resolve_linear_gradient(pattern)
888
+ elif isinstance(pattern, RadialGradient):
889
+ return _resolve_radial_gradient(pattern)
890
+ elif isinstance(pattern, Pattern):
891
+ return _resolve_tiling_pattern(pattern)
892
+ return pattern
893
+
894
+
895
+ def _resolve_linear_gradient(grad: LinearGradient) -> ResolvedPattern:
896
+ """Resolve a linear gradient to renderer-specific form.
897
+
898
+ Port of R ``resolvePattern.GridLinearGradient`` (patterns.R:391-399).
899
+ Converts endpoints to device coordinates, creates a renderer pattern.
900
+ """
901
+ # The renderer will handle the actual gradient creation when it
902
+ # encounters this object in gpar$fill. We store the resolved
903
+ # coordinates for the renderer to use.
904
+ from ._units import device_loc
905
+
906
+ try:
907
+ p1 = device_loc(grad.x1, grad.y1, value_only=True, device=True)
908
+ p2 = device_loc(grad.x2, grad.y2, value_only=True, device=True)
909
+ ref = {
910
+ "type": "linear_gradient",
911
+ "x1": float(p1["x"][0]), "y1": float(p1["y"][0]),
912
+ "x2": float(p2["x"][0]), "y2": float(p2["y"][0]),
913
+ "colours": grad.colours,
914
+ "stops": grad.stops,
915
+ "extend": grad.extend,
916
+ }
917
+ except Exception:
918
+ # Fallback: store the original gradient for later resolution
919
+ ref = {"type": "linear_gradient", "pattern": grad}
920
+
921
+ return ResolvedPattern(grad, ref)
922
+
923
+
924
+ def _resolve_radial_gradient(grad: RadialGradient) -> ResolvedPattern:
925
+ """Resolve a radial gradient to renderer-specific form.
926
+
927
+ Port of R ``resolvePattern.GridRadialGradient`` (patterns.R:401-418).
928
+ """
929
+ from ._units import device_loc, device_dim, Unit
930
+ import math
931
+
932
+ try:
933
+ c1 = device_loc(grad.cx1, grad.cy1, value_only=True, device=True)
934
+ c2 = device_loc(grad.cx2, grad.cy2, value_only=True, device=True)
935
+ # R computes r as min of two axis scalings (patterns.R:403-411)
936
+ dim1a = device_dim(Unit(0, "inches"), grad.r1, value_only=True, device=True)
937
+ dim1b = device_dim(grad.r1, Unit(0, "inches"), value_only=True, device=True)
938
+ r1 = min(math.sqrt(dim1a["w"][0]**2 + dim1a["h"][0]**2),
939
+ math.sqrt(dim1b["w"][0]**2 + dim1b["h"][0]**2))
940
+ dim2a = device_dim(Unit(0, "inches"), grad.r2, value_only=True, device=True)
941
+ dim2b = device_dim(grad.r2, Unit(0, "inches"), value_only=True, device=True)
942
+ r2 = min(math.sqrt(dim2a["w"][0]**2 + dim2a["h"][0]**2),
943
+ math.sqrt(dim2b["w"][0]**2 + dim2b["h"][0]**2))
944
+ ref = {
945
+ "type": "radial_gradient",
946
+ "cx1": float(c1["x"][0]), "cy1": float(c1["y"][0]), "r1": r1,
947
+ "cx2": float(c2["x"][0]), "cy2": float(c2["y"][0]), "r2": r2,
948
+ "colours": grad.colours,
949
+ "stops": grad.stops,
950
+ "extend": grad.extend,
951
+ }
952
+ except Exception:
953
+ ref = {"type": "radial_gradient", "pattern": grad}
954
+
955
+ return ResolvedPattern(grad, ref)
956
+
957
+
958
+ def _resolve_tiling_pattern(pat: Pattern) -> ResolvedPattern:
959
+ """Resolve a tiling pattern to renderer-specific form.
960
+
961
+ Port of R ``resolvePattern.GridTilingPattern`` (patterns.R:420-429).
962
+ """
963
+ from ._units import device_loc, device_dim
964
+
965
+ try:
966
+ xy = device_loc(pat.x, pat.y, value_only=True, device=True)
967
+ wh = device_dim(pat.width, pat.height, value_only=True, device=True)
968
+ left = float(xy["x"][0]) - pat.hjust * float(wh["w"][0])
969
+ bottom = float(xy["y"][0]) - pat.vjust * float(wh["h"][0])
970
+ ref = {
971
+ "type": "tiling_pattern",
972
+ "grob": pat.grob,
973
+ "x": left, "y": bottom,
974
+ "width": float(wh["w"][0]), "height": float(wh["h"][0]),
975
+ "extend": pat.extend,
976
+ }
977
+ except Exception:
978
+ ref = {"type": "tiling_pattern", "pattern": pat}
979
+
980
+ return ResolvedPattern(pat, ref)
981
+
982
+
983
+ # ---------------------------------------------------------------------------
984
+ # recordGrobForPatternResolution / recordGTreeForPatternResolution
985
+ # Port of R patterns.R:150-190
986
+ # ---------------------------------------------------------------------------
987
+
988
+
989
+ def record_grob_for_pattern_resolution(grob: Any) -> None:
990
+ """Attach the built grob to gpar$fill for pattern resolution.
991
+
992
+ Port of R ``recordGrobForPatternResolution`` (patterns.R:150-161).
993
+ Called after ``makeContent()`` in the drawing pipeline.
994
+ If the current gpar has a pattern fill, the grob is attached so
995
+ that subsequent ``resolveFill()`` can compute the bounding box.
996
+ """
997
+ from ._state import get_state
998
+
999
+ state = get_state()
1000
+ gp = state.get_gpar()
1001
+ if gp is None:
1002
+ return
1003
+
1004
+ fill = gp.get("fill", None)
1005
+ if fill is None:
1006
+ return
1007
+
1008
+ if is_pattern(fill):
1009
+ # Attach grob to the pattern for later resolution
1010
+ fill._attached_grob = grob
1011
+ elif is_pattern_list(fill):
1012
+ for f in fill:
1013
+ if is_pattern(f):
1014
+ f._attached_grob = grob
1015
+
1016
+
1017
+ def record_gtree_for_pattern_resolution(grob: Any) -> None:
1018
+ """Resolve gTree-level pattern fill immediately.
1019
+
1020
+ Port of R ``recordGTreeForPatternResolution`` (patterns.R:176-190).
1021
+ If the gTree's own gp$fill is a pattern with group=TRUE,
1022
+ resolve it now (using the gTree's bounding box).
1023
+ """
1024
+ from ._state import get_state
1025
+
1026
+ gp = getattr(grob, "gp", None)
1027
+ if gp is None:
1028
+ return
1029
+
1030
+ fill = gp.get("fill", None) if hasattr(gp, "get") else None
1031
+ if fill is None:
1032
+ return
1033
+
1034
+ should_resolve = False
1035
+ if is_pattern(fill) and getattr(fill, "group", False):
1036
+ should_resolve = True
1037
+ elif is_pattern_list(fill):
1038
+ should_resolve = True
1039
+
1040
+ if should_resolve:
1041
+ state = get_state()
1042
+ current_gp = state.get_gpar()
1043
+ current_fill = current_gp.get("fill", None) if current_gp else None
1044
+ if current_fill is not None and (is_pattern(current_fill) or is_pattern_list(current_fill)):
1045
+ resolved = resolve_fill(current_fill, grob=grob)
1046
+ if resolved is None:
1047
+ current_gp._params["fill"] = "transparent"
1048
+ else:
1049
+ current_gp._params["fill"] = resolved