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/_coords.py ADDED
@@ -0,0 +1,1534 @@
1
+ """Coordinate query system for grid_py (port of R's ``grid/R/coords.R``).
2
+
3
+ This module provides data structures and functions for computing sets of points
4
+ around the perimeter (or along the length) of grobs. The three container
5
+ classes -- :class:`GridCoords`, :class:`GridGrobCoords`, and
6
+ :class:`GridGTreeCoords` -- mirror R's ``GridCoords``, ``GridGrobCoords``,
7
+ and ``GridGTreeCoords`` S3 classes respectively.
8
+
9
+ ``grob_coords`` is the user-level entry point that emulates drawing set-up
10
+ behaviour (pushing viewports, setting graphical parameters).
11
+ ``grob_points`` skips that set-up and is intended for internal use when the
12
+ drawing context has already been established.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import (
18
+ Any,
19
+ Dict,
20
+ Iterator,
21
+ List,
22
+ Optional,
23
+ Sequence,
24
+ Union,
25
+ )
26
+
27
+ import numpy as np
28
+ from numpy.typing import ArrayLike
29
+
30
+ from ._grob import Grob, GTree, GList
31
+
32
+ __all__ = [
33
+ "GridCoords",
34
+ "GridGrobCoords",
35
+ "GridGTreeCoords",
36
+ "grob_coords",
37
+ "grob_points",
38
+ "grid_coords",
39
+ "grid_grob_coords",
40
+ "grid_gtree_coords",
41
+ "empty_coords",
42
+ "empty_grob_coords",
43
+ "empty_gtree_coords",
44
+ "is_empty_coords",
45
+ "coords_bbox",
46
+ "is_closed",
47
+ ]
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Print indentation (mirrors R's ``coordPrintIndent``)
51
+ # ---------------------------------------------------------------------------
52
+
53
+ _COORD_PRINT_INDENT: str = " "
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # GridCoords -- low-level coordinate pair container
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ class GridCoords:
62
+ """Container for a set of (x, y) coordinate pairs.
63
+
64
+ This is the leaf node in the coordinate hierarchy. It wraps two
65
+ equal-length NumPy arrays of x and y values (typically in inches).
66
+
67
+ Parameters
68
+ ----------
69
+ x : array_like
70
+ X coordinates.
71
+ y : array_like
72
+ Y coordinates.
73
+ name : str
74
+ Human-readable label for this coordinate set.
75
+
76
+ Raises
77
+ ------
78
+ ValueError
79
+ If *x* and *y* do not have the same length.
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ x: ArrayLike,
85
+ y: ArrayLike,
86
+ name: str = "coords",
87
+ ) -> None:
88
+ x_arr = np.asarray(x, dtype=float)
89
+ y_arr = np.asarray(y, dtype=float)
90
+ # Ensure 1-D
91
+ x_arr = np.atleast_1d(x_arr)
92
+ y_arr = np.atleast_1d(y_arr)
93
+ if x_arr.shape != y_arr.shape:
94
+ raise ValueError(
95
+ f"x and y must have the same shape, got {x_arr.shape} and {y_arr.shape}"
96
+ )
97
+ self._x = x_arr
98
+ self._y = y_arr
99
+ self._name = name
100
+
101
+ # -- properties ---------------------------------------------------------
102
+
103
+ @property
104
+ def x(self) -> np.ndarray:
105
+ """X coordinates as a NumPy array."""
106
+ return self._x
107
+
108
+ @property
109
+ def y(self) -> np.ndarray:
110
+ """Y coordinates as a NumPy array."""
111
+ return self._y
112
+
113
+ @property
114
+ def name(self) -> str:
115
+ """Human-readable label."""
116
+ return self._name
117
+
118
+ # -- query methods ------------------------------------------------------
119
+
120
+ def get_x(self) -> np.ndarray:
121
+ """Return the x coordinate array.
122
+
123
+ Returns
124
+ -------
125
+ numpy.ndarray
126
+ The x values.
127
+ """
128
+ return self._x
129
+
130
+ def get_y(self) -> np.ndarray:
131
+ """Return the y coordinate array.
132
+
133
+ Returns
134
+ -------
135
+ numpy.ndarray
136
+ The y values.
137
+ """
138
+ return self._y
139
+
140
+ # -- transformation methods ---------------------------------------------
141
+
142
+ def to_device(self, state: Any = None) -> "GridCoords":
143
+ """Convert coordinates to device space.
144
+
145
+ Parameters
146
+ ----------
147
+ state : object, optional
148
+ Device / viewport state used for unit conversion. When ``None``
149
+ the coordinates are returned unchanged (no-op).
150
+
151
+ Returns
152
+ -------
153
+ GridCoords
154
+ New instance with device-space coordinates.
155
+ """
156
+ if self.is_empty():
157
+ return self
158
+ if state is None:
159
+ return self
160
+ # If a state object provides a ``device_loc`` method, use it.
161
+ if hasattr(state, "device_loc"):
162
+ dx, dy = state.device_loc(self._x, self._y)
163
+ return GridCoords(dx, dy, name=self._name)
164
+ return self
165
+
166
+ def from_device(self, state: Any = None) -> "GridCoords":
167
+ """Transform coordinates from device space using an inverse transform.
168
+
169
+ In R this multiplies the coordinate matrix by ``solve(trans)``.
170
+
171
+ Parameters
172
+ ----------
173
+ state : object, optional
174
+ A 3x3 transformation matrix (ndarray) **or** an object with a
175
+ ``transform`` attribute that is a 3x3 matrix. When ``None`` the
176
+ coordinates are returned unchanged.
177
+
178
+ Returns
179
+ -------
180
+ GridCoords
181
+ New instance with transformed coordinates.
182
+ """
183
+ if state is None:
184
+ return self
185
+ trans = state
186
+ if hasattr(state, "transform"):
187
+ trans = state.transform
188
+ trans = np.asarray(trans, dtype=float)
189
+ if trans.shape != (3, 3):
190
+ raise ValueError("from_device requires a 3x3 transformation matrix")
191
+ # R coords.R:183: cbind(x,y,1) %*% solve(trans) → pts @ inv(trans)
192
+ inv = np.linalg.solve(trans, np.eye(3))
193
+ pts = np.column_stack([self._x, self._y, np.ones(len(self._x))])
194
+ result = pts @ inv
195
+ return GridCoords(result[:, 0], result[:, 1], name=self._name)
196
+
197
+ def is_empty(self) -> bool:
198
+ """Return ``True`` when this represents the canonical empty coords.
199
+
200
+ Returns
201
+ -------
202
+ bool
203
+ """
204
+ return (
205
+ len(self._x) == 1
206
+ and self._x[0] == 0.0
207
+ and self._y[0] == 0.0
208
+ )
209
+
210
+ def transform_coords(self, tm: np.ndarray) -> "GridCoords":
211
+ """Apply a 3x3 affine transformation matrix.
212
+
213
+ Parameters
214
+ ----------
215
+ tm : numpy.ndarray
216
+ A 3x3 affine transformation matrix.
217
+
218
+ Returns
219
+ -------
220
+ GridCoords
221
+ Transformed coordinates.
222
+ """
223
+ tm = np.asarray(tm, dtype=float)
224
+ pts = np.column_stack([self._x, self._y, np.ones(len(self._x))])
225
+ result = pts @ tm
226
+ return GridCoords(result[:, 0], result[:, 1], name=self._name)
227
+
228
+ def flatten(self) -> "GridCoords":
229
+ """Return a flattened copy (no-op for leaf nodes).
230
+
231
+ Returns
232
+ -------
233
+ GridCoords
234
+ """
235
+ return GridCoords(self._x.copy(), self._y.copy(), name=self._name)
236
+
237
+ # -- dunder methods -----------------------------------------------------
238
+
239
+ def __len__(self) -> int:
240
+ return len(self._x)
241
+
242
+ def __repr__(self) -> str:
243
+ def _fmt(arr: np.ndarray) -> str:
244
+ if len(arr) > 3:
245
+ head = " ".join(f"{v:.4g}" for v in arr[:3])
246
+ return f"{head} ... [{len(arr)} values]"
247
+ return " ".join(f"{v:.4g}" for v in arr) + f" [{len(arr)} values]"
248
+
249
+ x_str = _fmt(self._x)
250
+ y_str = _fmt(self._y)
251
+ return f"x: {x_str}\ny: {y_str}"
252
+
253
+
254
+ # ---------------------------------------------------------------------------
255
+ # GridGrobCoords -- coordinates for a single grob (list of GridCoords)
256
+ # ---------------------------------------------------------------------------
257
+
258
+
259
+ class GridGrobCoords:
260
+ """Container for coordinates of a single grob.
261
+
262
+ Wraps an ordered list of :class:`GridCoords` (one per sub-shape).
263
+
264
+ Parameters
265
+ ----------
266
+ coords_list : list of GridCoords or None
267
+ The coordinate sets for each sub-shape. ``None`` creates an empty
268
+ container.
269
+ name : str
270
+ Name of the parent grob.
271
+ rule : str or None
272
+ Fill rule (``"winding"`` or ``"evenodd"``), mirroring R's
273
+ ``attr(x, "rule")``.
274
+ """
275
+
276
+ def __init__(
277
+ self,
278
+ coords_list: Optional[List[GridCoords]] = None,
279
+ name: str = "grobcoords",
280
+ rule: Optional[str] = None,
281
+ ) -> None:
282
+ self._coords: List[GridCoords] = list(coords_list) if coords_list else []
283
+ self._name = name
284
+ self._rule = rule
285
+
286
+ # -- properties ---------------------------------------------------------
287
+
288
+ @property
289
+ def name(self) -> str:
290
+ """Name of the parent grob."""
291
+ return self._name
292
+
293
+ @property
294
+ def rule(self) -> Optional[str]:
295
+ """Fill rule (``'winding'`` or ``'evenodd'``), or ``None``."""
296
+ return self._rule
297
+
298
+ # -- query methods ------------------------------------------------------
299
+
300
+ def get_x(self, subset: Optional[Sequence[int]] = None) -> np.ndarray:
301
+ """Return concatenated x values across all (or selected) sub-shapes.
302
+
303
+ Parameters
304
+ ----------
305
+ subset : sequence of int, optional
306
+ Indices of sub-shapes to include. ``None`` means all.
307
+
308
+ Returns
309
+ -------
310
+ numpy.ndarray
311
+ """
312
+ items = self._coords if subset is None else [self._coords[i] for i in subset]
313
+ if not items:
314
+ return np.array([], dtype=float)
315
+ return np.concatenate([c.get_x() for c in items])
316
+
317
+ def get_y(self, subset: Optional[Sequence[int]] = None) -> np.ndarray:
318
+ """Return concatenated y values across all (or selected) sub-shapes.
319
+
320
+ Parameters
321
+ ----------
322
+ subset : sequence of int, optional
323
+ Indices of sub-shapes to include. ``None`` means all.
324
+
325
+ Returns
326
+ -------
327
+ numpy.ndarray
328
+ """
329
+ items = self._coords if subset is None else [self._coords[i] for i in subset]
330
+ if not items:
331
+ return np.array([], dtype=float)
332
+ return np.concatenate([c.get_y() for c in items])
333
+
334
+ # -- transformation methods ---------------------------------------------
335
+
336
+ def to_device(self, state: Any = None) -> "GridGrobCoords":
337
+ """Convert all sub-shape coordinates to device space.
338
+
339
+ Parameters
340
+ ----------
341
+ state : object, optional
342
+ Passed through to :meth:`GridCoords.to_device`.
343
+
344
+ Returns
345
+ -------
346
+ GridGrobCoords
347
+ """
348
+ return GridGrobCoords(
349
+ [c.to_device(state) for c in self._coords],
350
+ name=self._name,
351
+ rule=self._rule,
352
+ )
353
+
354
+ def from_device(self, state: Any = None) -> "GridGrobCoords":
355
+ """Transform all sub-shape coordinates from device space.
356
+
357
+ Parameters
358
+ ----------
359
+ state : object, optional
360
+ Passed through to :meth:`GridCoords.from_device`.
361
+
362
+ Returns
363
+ -------
364
+ GridGrobCoords
365
+ """
366
+ return GridGrobCoords(
367
+ [c.from_device(state) for c in self._coords],
368
+ name=self._name,
369
+ rule=self._rule,
370
+ )
371
+
372
+ def is_empty(self) -> bool:
373
+ """Return ``True`` when every sub-shape is empty.
374
+
375
+ Returns
376
+ -------
377
+ bool
378
+ """
379
+ if not self._coords:
380
+ return True
381
+ return all(c.is_empty() for c in self._coords)
382
+
383
+ def transform_coords(self, tm: np.ndarray) -> "GridGrobCoords":
384
+ """Apply an affine transformation to all sub-shapes.
385
+
386
+ Parameters
387
+ ----------
388
+ tm : numpy.ndarray
389
+ A 3x3 affine transformation matrix.
390
+
391
+ Returns
392
+ -------
393
+ GridGrobCoords
394
+ """
395
+ return GridGrobCoords(
396
+ [c.transform_coords(tm) for c in self._coords],
397
+ name=self._name,
398
+ rule=self._rule,
399
+ )
400
+
401
+ def flatten(self) -> "GridGrobCoords":
402
+ """Return a flattened copy.
403
+
404
+ Returns
405
+ -------
406
+ GridGrobCoords
407
+ """
408
+ return GridGrobCoords(
409
+ [c.flatten() for c in self._coords],
410
+ name=self._name,
411
+ rule=self._rule,
412
+ )
413
+
414
+ # -- dunder methods -----------------------------------------------------
415
+
416
+ def __len__(self) -> int:
417
+ return len(self._coords)
418
+
419
+ def __iter__(self) -> Iterator[GridCoords]:
420
+ return iter(self._coords)
421
+
422
+ def __getitem__(self, index: int) -> GridCoords:
423
+ return self._coords[index]
424
+
425
+ def __repr__(self) -> str:
426
+ lines: List[str] = []
427
+ rule_str = f" (fill: {self._rule})" if self._rule else ""
428
+ lines.append(f"grob {self._name}{rule_str}")
429
+ for i, coord in enumerate(self._coords):
430
+ label = str(i + 1)
431
+ lines.append(f"{_COORD_PRINT_INDENT}shape {label}")
432
+ for sub_line in repr(coord).split("\n"):
433
+ lines.append(f"{_COORD_PRINT_INDENT}{_COORD_PRINT_INDENT}{sub_line}")
434
+ return "\n".join(lines)
435
+
436
+
437
+ # ---------------------------------------------------------------------------
438
+ # GridGTreeCoords -- coordinates for a gTree (dict of child coords)
439
+ # ---------------------------------------------------------------------------
440
+
441
+
442
+ class GridGTreeCoords:
443
+ """Container for coordinates of a gTree.
444
+
445
+ Wraps a mapping from child names to their coordinate containers (which
446
+ may themselves be :class:`GridGrobCoords` or :class:`GridGTreeCoords`).
447
+
448
+ Parameters
449
+ ----------
450
+ coords_dict : dict or list or None
451
+ Mapping of child names to coordinate containers, **or** a list of
452
+ coordinate containers (keys are auto-generated). ``None`` creates
453
+ an empty container.
454
+ name : str
455
+ Name of the parent gTree.
456
+ """
457
+
458
+ def __init__(
459
+ self,
460
+ coords_dict: Union[
461
+ Dict[str, Union[GridGrobCoords, "GridGTreeCoords"]],
462
+ List[Union[GridGrobCoords, "GridGTreeCoords"]],
463
+ None,
464
+ ] = None,
465
+ name: str = "gtreecoords",
466
+ ) -> None:
467
+ if coords_dict is None:
468
+ self._children: Dict[str, Union[GridGrobCoords, GridGTreeCoords]] = {}
469
+ elif isinstance(coords_dict, dict):
470
+ self._children = dict(coords_dict)
471
+ else:
472
+ # Accept a list -- derive keys from child names or indices
473
+ self._children = {}
474
+ for i, child in enumerate(coords_dict):
475
+ key = getattr(child, "name", str(i))
476
+ self._children[key] = child
477
+ self._name = name
478
+
479
+ # -- properties ---------------------------------------------------------
480
+
481
+ @property
482
+ def name(self) -> str:
483
+ """Name of the parent gTree."""
484
+ return self._name
485
+
486
+ # -- query methods ------------------------------------------------------
487
+
488
+ def get_x(self) -> np.ndarray:
489
+ """Return concatenated x values across all children.
490
+
491
+ Returns
492
+ -------
493
+ numpy.ndarray
494
+ """
495
+ if not self._children:
496
+ return np.array([], dtype=float)
497
+ return np.concatenate([c.get_x() for c in self._children.values()])
498
+
499
+ def get_y(self) -> np.ndarray:
500
+ """Return concatenated y values across all children.
501
+
502
+ Returns
503
+ -------
504
+ numpy.ndarray
505
+ """
506
+ if not self._children:
507
+ return np.array([], dtype=float)
508
+ return np.concatenate([c.get_y() for c in self._children.values()])
509
+
510
+ # -- transformation methods ---------------------------------------------
511
+
512
+ def to_device(self, state: Any = None) -> "GridGTreeCoords":
513
+ """Convert all child coordinates to device space.
514
+
515
+ Parameters
516
+ ----------
517
+ state : object, optional
518
+ Passed through to child ``to_device`` methods.
519
+
520
+ Returns
521
+ -------
522
+ GridGTreeCoords
523
+ """
524
+ return GridGTreeCoords(
525
+ {k: v.to_device(state) for k, v in self._children.items()},
526
+ name=self._name,
527
+ )
528
+
529
+ def from_device(self, state: Any = None) -> "GridGTreeCoords":
530
+ """Transform all child coordinates from device space.
531
+
532
+ Parameters
533
+ ----------
534
+ state : object, optional
535
+ Passed through to child ``from_device`` methods.
536
+
537
+ Returns
538
+ -------
539
+ GridGTreeCoords
540
+ """
541
+ return GridGTreeCoords(
542
+ {k: v.from_device(state) for k, v in self._children.items()},
543
+ name=self._name,
544
+ )
545
+
546
+ def is_empty(self) -> bool:
547
+ """Return ``True`` when every child reports empty.
548
+
549
+ Returns
550
+ -------
551
+ bool
552
+ """
553
+ if not self._children:
554
+ return True
555
+ return all(c.is_empty() for c in self._children.values())
556
+
557
+ def transform_coords(self, tm: np.ndarray) -> "GridGTreeCoords":
558
+ """Apply an affine transformation to all children.
559
+
560
+ Parameters
561
+ ----------
562
+ tm : numpy.ndarray
563
+ A 3x3 affine transformation matrix.
564
+
565
+ Returns
566
+ -------
567
+ GridGTreeCoords
568
+ """
569
+ return GridGTreeCoords(
570
+ {k: v.transform_coords(tm) for k, v in self._children.items()},
571
+ name=self._name,
572
+ )
573
+
574
+ def flatten(self) -> "GridGTreeCoords":
575
+ """Return a flattened copy.
576
+
577
+ Returns
578
+ -------
579
+ GridGTreeCoords
580
+ """
581
+ return GridGTreeCoords(
582
+ {k: v.flatten() for k, v in self._children.items()},
583
+ name=self._name,
584
+ )
585
+
586
+ # -- dunder methods -----------------------------------------------------
587
+
588
+ def __len__(self) -> int:
589
+ return len(self._children)
590
+
591
+ def __iter__(self) -> Iterator[str]:
592
+ return iter(self._children)
593
+
594
+ def __getitem__(self, key: str) -> Union[GridGrobCoords, "GridGTreeCoords"]:
595
+ return self._children[key]
596
+
597
+ def __repr__(self) -> str:
598
+ lines: List[str] = []
599
+ lines.append(f"gTree {self._name}")
600
+ for child in self._children.values():
601
+ for sub_line in repr(child).split("\n"):
602
+ lines.append(f"{_COORD_PRINT_INDENT}{sub_line}")
603
+ return "\n".join(lines)
604
+
605
+
606
+ # ---------------------------------------------------------------------------
607
+ # Factory / convenience functions
608
+ # ---------------------------------------------------------------------------
609
+
610
+
611
+ def grid_coords(x: ArrayLike, y: ArrayLike) -> GridCoords:
612
+ """Create a :class:`GridCoords` instance.
613
+
614
+ This is the public factory function mirroring R's ``gridCoords()``.
615
+
616
+ Parameters
617
+ ----------
618
+ x : array_like
619
+ X coordinates.
620
+ y : array_like
621
+ Y coordinates.
622
+
623
+ Returns
624
+ -------
625
+ GridCoords
626
+ """
627
+ return GridCoords(x, y)
628
+
629
+
630
+ def grid_grob_coords(
631
+ coords_list: List[GridCoords],
632
+ name: str,
633
+ rule: Optional[str] = None,
634
+ ) -> GridGrobCoords:
635
+ """Create a :class:`GridGrobCoords` instance.
636
+
637
+ Parameters
638
+ ----------
639
+ coords_list : list of GridCoords
640
+ Coordinate sets for each sub-shape.
641
+ name : str
642
+ Name of the owning grob.
643
+ rule : str or None
644
+ Fill rule.
645
+
646
+ Returns
647
+ -------
648
+ GridGrobCoords
649
+ """
650
+ return GridGrobCoords(coords_list, name=name, rule=rule)
651
+
652
+
653
+ def grid_gtree_coords(
654
+ coords_dict: Union[
655
+ Dict[str, Union[GridGrobCoords, GridGTreeCoords]],
656
+ List[Union[GridGrobCoords, GridGTreeCoords]],
657
+ ],
658
+ name: str,
659
+ ) -> GridGTreeCoords:
660
+ """Create a :class:`GridGTreeCoords` instance.
661
+
662
+ Parameters
663
+ ----------
664
+ coords_dict : dict or list
665
+ Mapping (or list) of child coordinate containers.
666
+ name : str
667
+ Name of the owning gTree.
668
+
669
+ Returns
670
+ -------
671
+ GridGTreeCoords
672
+ """
673
+ return GridGTreeCoords(coords_dict, name=name)
674
+
675
+
676
+ # ---------------------------------------------------------------------------
677
+ # Canonical empty objects
678
+ # ---------------------------------------------------------------------------
679
+
680
+ #: The canonical "empty" coordinate pair (single (0, 0) point), mirroring
681
+ #: R's ``emptyCoords``.
682
+ _EMPTY_COORDS: GridCoords = GridCoords(np.array([0.0]), np.array([0.0]), name="empty")
683
+
684
+
685
+ def empty_coords() -> GridCoords:
686
+ """Return the canonical empty :class:`GridCoords`.
687
+
688
+ Returns
689
+ -------
690
+ GridCoords
691
+ A coordinate set with a single ``(0, 0)`` point.
692
+ """
693
+ return _EMPTY_COORDS
694
+
695
+
696
+ def empty_grob_coords(name: str = "empty") -> GridGrobCoords:
697
+ """Return an empty :class:`GridGrobCoords`.
698
+
699
+ Parameters
700
+ ----------
701
+ name : str
702
+ Label for the owning grob.
703
+
704
+ Returns
705
+ -------
706
+ GridGrobCoords
707
+ """
708
+ return GridGrobCoords([_EMPTY_COORDS], name=name)
709
+
710
+
711
+ def empty_gtree_coords(name: str = "empty") -> GridGTreeCoords:
712
+ """Return an empty :class:`GridGTreeCoords`.
713
+
714
+ Parameters
715
+ ----------
716
+ name : str
717
+ Label for the owning gTree.
718
+
719
+ Returns
720
+ -------
721
+ GridGTreeCoords
722
+ """
723
+ return GridGTreeCoords([empty_grob_coords("0")], name=name)
724
+
725
+
726
+ def is_empty_coords(x: Union[GridCoords, GridGrobCoords, GridGTreeCoords]) -> bool:
727
+ """Test whether a coordinate container is the canonical empty set.
728
+
729
+ Dispatches to the ``is_empty()`` method of the container.
730
+
731
+ Parameters
732
+ ----------
733
+ x : GridCoords or GridGrobCoords or GridGTreeCoords
734
+ Coordinate container to test.
735
+
736
+ Returns
737
+ -------
738
+ bool
739
+ """
740
+ return x.is_empty()
741
+
742
+
743
+ # ---------------------------------------------------------------------------
744
+ # Bounding box
745
+ # ---------------------------------------------------------------------------
746
+
747
+
748
+ def coords_bbox(
749
+ x: Union[GridCoords, GridGrobCoords, GridGTreeCoords],
750
+ subset: Optional[Sequence[int]] = None,
751
+ ) -> Dict[str, float]:
752
+ """Compute the axis-aligned bounding box of a coordinate set.
753
+
754
+ Parameters
755
+ ----------
756
+ x : GridCoords or GridGrobCoords or GridGTreeCoords
757
+ Coordinate container.
758
+ subset : sequence of int, optional
759
+ Passed to :meth:`GridGrobCoords.get_x` / ``get_y`` when applicable.
760
+
761
+ Returns
762
+ -------
763
+ dict
764
+ Keys ``'left'``, ``'bottom'``, ``'width'``, ``'height'``.
765
+ """
766
+ if isinstance(x, GridGrobCoords) and subset is not None:
767
+ xx = x.get_x(subset)
768
+ yy = x.get_y(subset)
769
+ else:
770
+ xx = x.get_x()
771
+ yy = x.get_y()
772
+ return {
773
+ "left": float(np.min(xx)),
774
+ "bottom": float(np.min(yy)),
775
+ "width": float(np.ptp(xx)),
776
+ "height": float(np.ptp(yy)),
777
+ }
778
+
779
+
780
+ # ---------------------------------------------------------------------------
781
+ # isClosed dispatch
782
+ # ---------------------------------------------------------------------------
783
+
784
+ #: Grob class tags that are considered open (not closed).
785
+ _OPEN_GROB_CLASSES: frozenset[str] = frozenset(
786
+ {
787
+ "move.to",
788
+ "line.to",
789
+ "lines",
790
+ "polyline",
791
+ "segments",
792
+ "beziergrob",
793
+ }
794
+ )
795
+
796
+
797
+ def is_closed(x: Any) -> bool:
798
+ """Determine the default ``closed`` value for a grob.
799
+
800
+ Mirrors R's ``isClosed()`` generic. Most grobs default to ``True``;
801
+ line-like grobs default to ``False``.
802
+
803
+ Parameters
804
+ ----------
805
+ x : Grob
806
+ A graphical object.
807
+
808
+ Returns
809
+ -------
810
+ bool
811
+ """
812
+ grid_class = getattr(x, "_grid_class", None) or ""
813
+ if grid_class in _OPEN_GROB_CLASSES:
814
+ return False
815
+ # Special handling for xspline
816
+ if grid_class == "xspline":
817
+ return not getattr(x, "open", True)
818
+ # Special handling for points
819
+ if grid_class == "points":
820
+ pch = getattr(x, "pch", None)
821
+ if pch in (3, 4, 8):
822
+ return False
823
+ return True
824
+ return True
825
+
826
+
827
+ # ---------------------------------------------------------------------------
828
+ # grobCoords / grobPoints dispatchers
829
+ # ---------------------------------------------------------------------------
830
+
831
+
832
+ def grob_coords(x: Any, closed: Optional[bool] = None) -> Union[GridGrobCoords, GridGTreeCoords]:
833
+ """Get the coordinates of a grob, performing drawing set-up.
834
+
835
+ This is the user-level function that mirrors R's ``grobCoords()``.
836
+ It dispatches based on the grob type: :class:`GList`, :class:`GTree`,
837
+ and plain :class:`Grob` are all handled.
838
+
839
+ Parameters
840
+ ----------
841
+ x : Grob or GTree or GList
842
+ A graphical object.
843
+ closed : bool, optional
844
+ Whether to compute closed-shape coordinates. Defaults to the
845
+ result of :func:`is_closed`.
846
+
847
+ Returns
848
+ -------
849
+ GridGrobCoords or GridGTreeCoords
850
+ The computed coordinates.
851
+ """
852
+ if closed is None:
853
+ closed = is_closed(x)
854
+
855
+ if isinstance(x, GList):
856
+ return _grob_coords_glist(x, closed)
857
+ if isinstance(x, GTree):
858
+ return _grob_coords_gtree(x, closed)
859
+ if isinstance(x, Grob):
860
+ # Check for custom grob_coords method first
861
+ if hasattr(x, "grob_coords"):
862
+ result = x.grob_coords(closed=closed)
863
+ if result is not None:
864
+ return result
865
+ return _grob_coords_grob(x, closed)
866
+
867
+ raise TypeError(f"grob_coords does not support {type(x).__name__}")
868
+
869
+
870
+ def _grob_coords_grob(x: Grob, closed: bool) -> GridGrobCoords:
871
+ """Compute coordinates for a plain grob.
872
+
873
+ Port of R ``grobCoords.grob`` (coords.R:272-304).
874
+ Does full drawing setup:
875
+ 1. Save state (DL, gpar)
876
+ 2. preDraw (makeContext + push vp/gp)
877
+ 3. makeContent
878
+ 4. Call grobPoints to get coordinates
879
+ 5. If vp changed: toDevice → postDraw → fromDevice
880
+ 6. Otherwise: postDraw
881
+ """
882
+ import copy
883
+ from ._state import get_state
884
+
885
+ state = get_state()
886
+ renderer = state.get_renderer()
887
+
888
+ # Fallback when no renderer is available (no drawing context)
889
+ if renderer is None:
890
+ return grob_points(x, closed)
891
+
892
+ # Save current transform for fromDevice (R coords.R:274)
893
+ cur_transform = None
894
+ if renderer is not None and hasattr(renderer, "_vp_transform_stack"):
895
+ cur_transform = renderer._vp_transform_stack[-1].transform.copy()
896
+
897
+ original_vp = x.vp
898
+
899
+ # Same setup as drawGrob (R coords.R:276-284)
900
+ saved_dl_on = state._dl_on
901
+ state.set_display_list_on(False)
902
+ saved_gpar = copy.copy(state.get_gpar())
903
+
904
+ try:
905
+ # preDraw: makeContext + push vp/gp + preDrawDetails
906
+ x = x.make_context()
907
+ from ._draw import _push_vp_gp
908
+ _push_vp_gp(x)
909
+ x.pre_draw_details()
910
+
911
+ # makeContent
912
+ x = x.make_content()
913
+
914
+ # Check if vp changed (R coords.R:287)
915
+ vpgrob = (x.vp is not None) or (original_vp is not x.vp)
916
+
917
+ # Get points (R coords.R:290)
918
+ pts = grob_points(x, closed)
919
+
920
+ if vpgrob and not pts.is_empty():
921
+ # Transform to device coordinates (R coords.R:292-294)
922
+ pts = _to_device_grob_coords(pts, state)
923
+
924
+ # postDraw: postDrawDetails + pop vp
925
+ x.post_draw_details()
926
+ if x.vp is not None:
927
+ from ._draw import _pop_grob_vp
928
+ _pop_grob_vp(x.vp)
929
+
930
+ if vpgrob and not pts.is_empty() and cur_transform is not None:
931
+ # Transform back from device (R coords.R:299-301)
932
+ pts = _from_device_grob_coords(pts, cur_transform)
933
+
934
+ finally:
935
+ state.replace_gpar(saved_gpar)
936
+ state.set_display_list_on(saved_dl_on)
937
+
938
+ return pts
939
+
940
+
941
+ def _grob_coords_glist(x: GList, closed: bool) -> GridGTreeCoords:
942
+ """Compute coordinates for a GList (mirrors R's grobCoords.gList)."""
943
+ from ._grob import grob_name as _grob_name
944
+
945
+ children = [grob_coords(child, closed) for child in x]
946
+ return GridGTreeCoords(children, name=_grob_name())
947
+
948
+
949
+ def _grob_coords_gtree(x: GTree, closed: bool) -> GridGTreeCoords:
950
+ """Compute coordinates for a gTree.
951
+
952
+ Port of R ``grobCoords.gTree`` (coords.R:312-346).
953
+ Does full drawing setup like drawGTree, then recursively
954
+ calls grobCoords on children.
955
+ """
956
+ import copy
957
+ from ._state import get_state
958
+
959
+ state = get_state()
960
+ renderer = state.get_renderer()
961
+
962
+ # Fallback when no renderer is available (no drawing context)
963
+ if renderer is None:
964
+ children_order = getattr(x, "_children_order", [])
965
+ children = getattr(x, "_children", {})
966
+ if children and len(children) > 0:
967
+ ordered = [children[k] for k in children_order if k in children]
968
+ pts = [grob_coords(child, closed) for child in ordered]
969
+ return GridGTreeCoords(pts, name=x.name)
970
+ return empty_gtree_coords(x.name)
971
+
972
+ cur_transform = None
973
+ if renderer is not None and hasattr(renderer, "_vp_transform_stack"):
974
+ cur_transform = renderer._vp_transform_stack[-1].transform.copy()
975
+
976
+ original_vp = x.vp
977
+
978
+ # Same setup as drawGTree (R coords.R:316-322)
979
+ saved_dl_on = state._dl_on
980
+ state.set_display_list_on(False)
981
+ saved_gpar = copy.copy(state.get_gpar())
982
+ saved_current_grob = getattr(state, "_current_grob", None)
983
+
984
+ try:
985
+ # preDraw.gTree: makeContext + setCurrentGrob + push vp/gp + childrenvp
986
+ x = x.make_context()
987
+ state._current_grob = x
988
+
989
+ from ._draw import _push_vp_gp
990
+ _push_vp_gp(x)
991
+
992
+ # Push children viewport if present (R grob.R:1792-1801)
993
+ children_vp = getattr(x, "childrenvp", None) or getattr(x, "children_vp", None)
994
+ if children_vp is not None:
995
+ from ._viewport import push_viewport, up_viewport
996
+ from ._draw import _vp_depth
997
+ temp_gp = copy.copy(state.get_gpar())
998
+ push_viewport(children_vp, recording=False)
999
+ up_viewport(_vp_depth(children_vp), recording=False)
1000
+ state.set_gpar(temp_gp)
1001
+
1002
+ x.pre_draw_details()
1003
+
1004
+ # makeContent (R coords.R:327)
1005
+ x = x.make_content()
1006
+
1007
+ # Check if vp changed (R coords.R:330)
1008
+ vpgrob = (x.vp is not None) or (original_vp is not x.vp)
1009
+
1010
+ # Get children coords (R coords.R:332-334)
1011
+ children_order = x._children_order if hasattr(x, "_children_order") else []
1012
+ children = x._children if hasattr(x, "_children") else {}
1013
+
1014
+ if children and len(children) > 0:
1015
+ ordered = [children[k] for k in children_order if k in children]
1016
+ child_pts = [grob_coords(child, closed) for child in ordered]
1017
+ pts = GridGTreeCoords(child_pts, name=x.name)
1018
+ else:
1019
+ pts = empty_gtree_coords(x.name)
1020
+
1021
+ if vpgrob and not pts.is_empty():
1022
+ pts = _to_device_gtree_coords(pts, state)
1023
+
1024
+ # postDraw
1025
+ x.post_draw_details()
1026
+ if x.vp is not None:
1027
+ from ._draw import _pop_grob_vp
1028
+ _pop_grob_vp(x.vp)
1029
+
1030
+ if vpgrob and not pts.is_empty() and cur_transform is not None:
1031
+ pts = _from_device_gtree_coords(pts, cur_transform)
1032
+
1033
+ finally:
1034
+ state.replace_gpar(saved_gpar)
1035
+ state._current_grob = saved_current_grob
1036
+ state.set_display_list_on(saved_dl_on)
1037
+
1038
+ return pts
1039
+
1040
+
1041
+ # ---------------------------------------------------------------------------
1042
+ # toDevice / fromDevice helpers for grobCoords
1043
+ # Port of R coords.R:157-195
1044
+ # ---------------------------------------------------------------------------
1045
+
1046
+
1047
+ def _to_device_grob_coords(
1048
+ pts: GridGrobCoords, state: Any
1049
+ ) -> GridGrobCoords:
1050
+ """Convert grob coordinates to device inches via deviceLoc."""
1051
+ from ._units import device_loc, Unit
1052
+
1053
+ new_coords = []
1054
+ for coord in pts:
1055
+ if coord.is_empty():
1056
+ new_coords.append(coord)
1057
+ continue
1058
+ # R coords.R:163-165: deviceLoc(unit(x,"in"), unit(y,"in"), valueOnly=TRUE)
1059
+ result = device_loc(
1060
+ Unit(coord.x, "inches"),
1061
+ Unit(coord.y, "inches"),
1062
+ value_only=True,
1063
+ )
1064
+ new_coords.append(GridCoords(result["x"], result["y"], name=coord.name))
1065
+ return GridGrobCoords(new_coords, name=pts.name, rule=pts.rule)
1066
+
1067
+
1068
+ def _from_device_grob_coords(
1069
+ pts: GridGrobCoords, transform: np.ndarray
1070
+ ) -> GridGrobCoords:
1071
+ """Transform grob coordinates from device space.
1072
+
1073
+ R coords.R:182-184: cbind(x,y,1) %*% solve(trans)
1074
+ """
1075
+ inv = np.linalg.solve(transform, np.eye(3))
1076
+ new_coords = []
1077
+ for coord in pts:
1078
+ if coord.is_empty():
1079
+ new_coords.append(coord)
1080
+ continue
1081
+ pts_matrix = np.column_stack([coord.x, coord.y, np.ones(len(coord.x))])
1082
+ result = pts_matrix @ inv
1083
+ new_coords.append(GridCoords(result[:, 0], result[:, 1], name=coord.name))
1084
+ return GridGrobCoords(new_coords, name=pts.name, rule=pts.rule)
1085
+
1086
+
1087
+ def _to_device_gtree_coords(
1088
+ pts: GridGTreeCoords, state: Any
1089
+ ) -> GridGTreeCoords:
1090
+ """Convert gTree coordinates to device inches."""
1091
+ new_children = {}
1092
+ for key, child in pts._children.items():
1093
+ if isinstance(child, GridGrobCoords):
1094
+ new_children[key] = _to_device_grob_coords(child, state)
1095
+ elif isinstance(child, GridGTreeCoords):
1096
+ new_children[key] = _to_device_gtree_coords(child, state)
1097
+ else:
1098
+ new_children[key] = child
1099
+ return GridGTreeCoords(new_children, name=pts.name)
1100
+
1101
+
1102
+ def _from_device_gtree_coords(
1103
+ pts: GridGTreeCoords, transform: np.ndarray
1104
+ ) -> GridGTreeCoords:
1105
+ """Transform gTree coordinates from device space."""
1106
+ new_children = {}
1107
+ for key, child in pts._children.items():
1108
+ if isinstance(child, GridGrobCoords):
1109
+ new_children[key] = _from_device_grob_coords(child, transform)
1110
+ elif isinstance(child, GridGTreeCoords):
1111
+ new_children[key] = _from_device_gtree_coords(child, transform)
1112
+ else:
1113
+ new_children[key] = child
1114
+ return GridGTreeCoords(new_children, name=pts.name)
1115
+
1116
+
1117
+ def grob_points(x: Any, closed: Optional[bool] = None) -> GridGrobCoords:
1118
+ """Get boundary points of a grob without drawing set-up.
1119
+
1120
+ This is for internal use when the drawing context is already
1121
+ established. Mirrors R's ``grobPoints()``.
1122
+
1123
+ Parameters
1124
+ ----------
1125
+ x : Grob or GTree or GList
1126
+ A graphical object.
1127
+ closed : bool, optional
1128
+ Whether to compute closed-shape coordinates. Defaults to the
1129
+ result of :func:`is_closed`.
1130
+
1131
+ Returns
1132
+ -------
1133
+ GridGrobCoords or GridGTreeCoords
1134
+ The computed boundary points.
1135
+ """
1136
+ if closed is None:
1137
+ closed = is_closed(x)
1138
+
1139
+ if isinstance(x, GList):
1140
+ return _grob_points_glist(x, closed)
1141
+ if isinstance(x, GTree):
1142
+ return _grob_points_gtree(x, closed)
1143
+
1144
+ # Dispatch by _grid_class to per-primitive implementations
1145
+ # (port of R coords.R:356-673)
1146
+ grid_class = getattr(x, "_grid_class", None) or ""
1147
+ dispatch_fn = _GROB_POINTS_DISPATCH.get(grid_class)
1148
+ if dispatch_fn is not None:
1149
+ return dispatch_fn(x, closed)
1150
+
1151
+ # Allow grobs to provide their own custom implementation
1152
+ if hasattr(x, "grob_points"):
1153
+ result = x.grob_points(closed=closed)
1154
+ if result is not None:
1155
+ return result
1156
+
1157
+ # Default: return empty
1158
+ name = getattr(x, "name", "unknown")
1159
+ return empty_grob_coords(name)
1160
+
1161
+
1162
+ def _grob_points_glist(x: GList, closed: bool) -> GridGTreeCoords:
1163
+ """Compute points for a GList (mirrors R's grobPoints.gList)."""
1164
+ from ._grob import grob_name as _grob_name
1165
+
1166
+ if len(x) > 0:
1167
+ return GridGTreeCoords(
1168
+ [grob_coords(child, closed) for child in x],
1169
+ name=_grob_name(),
1170
+ )
1171
+ return empty_gtree_coords(_grob_name())
1172
+
1173
+
1174
+ def _grob_points_gtree(x: GTree, closed: bool) -> GridGTreeCoords:
1175
+ """Compute points for a gTree (mirrors R's grobPoints.gTree)."""
1176
+ children_order = getattr(x, "children_order", None)
1177
+ children = getattr(x, "children", None)
1178
+
1179
+ if children is not None and len(children) > 0:
1180
+ if children_order is not None:
1181
+ ordered = [children[k] for k in children_order]
1182
+ else:
1183
+ ordered = list(children)
1184
+ pts = [grob_coords(child, closed) for child in ordered]
1185
+ return GridGTreeCoords(pts, name=x.name)
1186
+ return empty_gtree_coords(x.name)
1187
+
1188
+
1189
+ # ---------------------------------------------------------------------------
1190
+ # Per-primitive grobPoints implementations
1191
+ # Port of R coords.R:356-673
1192
+ # ---------------------------------------------------------------------------
1193
+
1194
+
1195
+ def _grob_points_circle(x: Grob, closed: bool = True, n: int = 100) -> GridGrobCoords:
1196
+ """grobPoints.circle -- R coords.R:368-388.
1197
+
1198
+ Returns n points around each circle perimeter (closed=True)
1199
+ or empty (closed=False).
1200
+ """
1201
+ if not closed:
1202
+ return empty_grob_coords(x.name)
1203
+
1204
+ from ._units import convert_x, convert_y, convert_width, convert_height
1205
+
1206
+ cx = convert_x(x.x, "inches", valueOnly=True)
1207
+ cy = convert_y(x.y, "inches", valueOnly=True)
1208
+ # R: r = pmin(convertWidth(r), convertHeight(r))
1209
+ rw = convert_width(x.r, "inches", valueOnly=True)
1210
+ rh = convert_height(x.r, "inches", valueOnly=True)
1211
+ r = np.minimum(rw, rh)
1212
+
1213
+ t = np.linspace(0, 2 * np.pi, n + 1)[:-1]
1214
+
1215
+ # Recycle via broadcasting (R: cbind(cx, cy, r))
1216
+ data = np.column_stack([
1217
+ np.broadcast_to(cx, max(len(cx), len(cy), len(r))),
1218
+ np.broadcast_to(cy, max(len(cx), len(cy), len(r))),
1219
+ np.broadcast_to(r, max(len(cx), len(cy), len(r))),
1220
+ ])
1221
+ ncirc = data.shape[0]
1222
+ pts = []
1223
+ for i in range(ncirc):
1224
+ px = data[i, 0] + data[i, 2] * np.cos(t)
1225
+ py = data[i, 1] + data[i, 2] * np.sin(t)
1226
+ pts.append(GridCoords(px, py, name=str(i + 1)))
1227
+ return GridGrobCoords(pts, name=x.name)
1228
+
1229
+
1230
+ def _grob_points_rect(x: Grob, closed: bool = True) -> GridGrobCoords:
1231
+ """grobPoints.rect -- R coords.R:529-547.
1232
+
1233
+ Returns 4 corners of each rect (closed=True) or empty.
1234
+ """
1235
+ if not closed:
1236
+ return empty_grob_coords(x.name)
1237
+
1238
+ from ._just import resolve_hjust, resolve_vjust
1239
+ from ._units import convert_x, convert_y, convert_width, convert_height
1240
+
1241
+ just = getattr(x, "just", None) or "centre"
1242
+ hjust = resolve_hjust(just, getattr(x, "hjust", None))
1243
+ vjust = resolve_vjust(just, getattr(x, "vjust", None))
1244
+
1245
+ w = convert_width(x.width, "inches", valueOnly=True)
1246
+ h = convert_height(x.height, "inches", valueOnly=True)
1247
+ xc = convert_x(x.x, "inches", valueOnly=True)
1248
+ yc = convert_y(x.y, "inches", valueOnly=True)
1249
+
1250
+ left = xc - hjust * w
1251
+ bottom = yc - vjust * h
1252
+ right = left + w
1253
+ top = bottom + h
1254
+
1255
+ # Recycle via column_stack (R: cbind(left, right, bottom, top))
1256
+ rects = np.column_stack([
1257
+ np.broadcast_to(left, max(len(left), len(right), len(bottom), len(top))),
1258
+ np.broadcast_to(right, max(len(left), len(right), len(bottom), len(top))),
1259
+ np.broadcast_to(bottom, max(len(left), len(right), len(bottom), len(top))),
1260
+ np.broadcast_to(top, max(len(left), len(right), len(bottom), len(top))),
1261
+ ])
1262
+ pts = []
1263
+ for i in range(rects.shape[0]):
1264
+ # R: xyListFromMatrix(rects, c(1,1,2,2), c(3,4,4,3))
1265
+ # Corners: (left,bottom), (left,top), (right,top), (right,bottom)
1266
+ px = np.array([rects[i, 0], rects[i, 0], rects[i, 1], rects[i, 1]])
1267
+ py = np.array([rects[i, 2], rects[i, 3], rects[i, 3], rects[i, 2]])
1268
+ pts.append(GridCoords(px, py, name=str(i + 1)))
1269
+ return GridGrobCoords(pts, name=x.name)
1270
+
1271
+
1272
+ def _grob_points_lines(x: Grob, closed: bool = False) -> GridGrobCoords:
1273
+ """grobPoints.lines -- R coords.R:390-400."""
1274
+ if closed:
1275
+ return empty_grob_coords(x.name)
1276
+
1277
+ from ._units import convert_x, convert_y
1278
+
1279
+ xx = convert_x(x.x, "inches", valueOnly=True)
1280
+ yy = convert_y(x.y, "inches", valueOnly=True)
1281
+ # Recycle via column_stack
1282
+ lines = np.column_stack([
1283
+ np.broadcast_to(xx, max(len(xx), len(yy))),
1284
+ np.broadcast_to(yy, max(len(xx), len(yy))),
1285
+ ])
1286
+ return GridGrobCoords(
1287
+ [GridCoords(lines[:, 0], lines[:, 1], name="1")],
1288
+ name=x.name,
1289
+ )
1290
+
1291
+
1292
+ def _grob_points_polyline(x: Grob, closed: bool = False) -> GridGrobCoords:
1293
+ """grobPoints.polyline -- R coords.R:402-429."""
1294
+ if closed:
1295
+ return empty_grob_coords(x.name)
1296
+
1297
+ from ._units import convert_x, convert_y
1298
+
1299
+ xx = convert_x(x.x, "inches", valueOnly=True)
1300
+ yy = convert_y(x.y, "inches", valueOnly=True)
1301
+
1302
+ grob_id = getattr(x, "id", None)
1303
+ grob_id_lengths = getattr(x, "id_lengths", None)
1304
+
1305
+ if grob_id is None and grob_id_lengths is None:
1306
+ return GridGrobCoords(
1307
+ [GridCoords(xx, yy, name="1")], name=x.name,
1308
+ )
1309
+
1310
+ if grob_id is None:
1311
+ n = len(grob_id_lengths)
1312
+ grob_id = np.repeat(np.arange(1, n + 1), grob_id_lengths)
1313
+ else:
1314
+ grob_id = np.asarray(grob_id)
1315
+
1316
+ unique_ids = np.unique(grob_id)
1317
+ if len(unique_ids) > 1:
1318
+ pts = []
1319
+ for uid in unique_ids:
1320
+ mask = grob_id == uid
1321
+ pts.append(GridCoords(xx[mask], yy[mask], name=str(uid)))
1322
+ return GridGrobCoords(pts, name=x.name)
1323
+ return GridGrobCoords(
1324
+ [GridCoords(xx, yy, name="1")], name=x.name,
1325
+ )
1326
+
1327
+
1328
+ def _grob_points_polygon(x: Grob, closed: bool = True) -> GridGrobCoords:
1329
+ """grobPoints.polygon -- R coords.R:437-464."""
1330
+ if not closed:
1331
+ return empty_grob_coords(x.name)
1332
+
1333
+ from ._units import convert_x, convert_y
1334
+
1335
+ xx = convert_x(x.x, "inches", valueOnly=True)
1336
+ yy = convert_y(x.y, "inches", valueOnly=True)
1337
+
1338
+ grob_id = getattr(x, "id", None)
1339
+ grob_id_lengths = getattr(x, "id_lengths", None)
1340
+
1341
+ if grob_id is None and grob_id_lengths is None:
1342
+ return GridGrobCoords(
1343
+ [GridCoords(xx, yy, name="1")], name=x.name,
1344
+ )
1345
+
1346
+ if grob_id is None:
1347
+ n = len(grob_id_lengths)
1348
+ grob_id = np.repeat(np.arange(1, n + 1), grob_id_lengths)
1349
+ else:
1350
+ grob_id = np.asarray(grob_id)
1351
+
1352
+ unique_ids = np.unique(grob_id)
1353
+ if len(unique_ids) > 1:
1354
+ pts = []
1355
+ for uid in unique_ids:
1356
+ mask = grob_id == uid
1357
+ pts.append(GridCoords(xx[mask], yy[mask], name=str(uid)))
1358
+ return GridGrobCoords(pts, name=x.name)
1359
+ return GridGrobCoords(
1360
+ [GridCoords(xx, yy, name="1")], name=x.name,
1361
+ )
1362
+
1363
+
1364
+ def _grob_points_segments(x: Grob, closed: bool = False) -> GridGrobCoords:
1365
+ """grobPoints.segments -- R coords.R:549-563."""
1366
+ if closed:
1367
+ return empty_grob_coords(x.name)
1368
+
1369
+ from ._units import convert_x, convert_y
1370
+
1371
+ x0 = convert_x(x.x0, "inches", valueOnly=True)
1372
+ x1 = convert_x(x.x1, "inches", valueOnly=True)
1373
+ y0 = convert_y(x.y0, "inches", valueOnly=True)
1374
+ y1 = convert_y(x.y1, "inches", valueOnly=True)
1375
+
1376
+ # Recycle via column_stack (R: cbind(x0, x1, y0, y1))
1377
+ maxlen = max(len(x0), len(x1), len(y0), len(y1))
1378
+ xy = np.column_stack([
1379
+ np.broadcast_to(x0, maxlen),
1380
+ np.broadcast_to(x1, maxlen),
1381
+ np.broadcast_to(y0, maxlen),
1382
+ np.broadcast_to(y1, maxlen),
1383
+ ])
1384
+ pts = []
1385
+ for i in range(xy.shape[0]):
1386
+ # R: xyListFromMatrix(xy, 1:2, 3:4)
1387
+ px = np.array([xy[i, 0], xy[i, 1]])
1388
+ py = np.array([xy[i, 2], xy[i, 3]])
1389
+ pts.append(GridCoords(px, py, name=str(i + 1)))
1390
+ return GridGrobCoords(pts, name=x.name)
1391
+
1392
+
1393
+ def _grob_points_pathgrob(x: Grob, closed: bool = True) -> GridGrobCoords:
1394
+ """grobPoints.pathgrob -- R coords.R:474-527.
1395
+
1396
+ Handles pathId and id for multiple paths/shapes.
1397
+ """
1398
+ if not closed:
1399
+ return empty_grob_coords(x.name)
1400
+
1401
+ from ._units import convert_x, convert_y
1402
+
1403
+ xx = convert_x(x.x, "inches", valueOnly=True)
1404
+ yy = convert_y(x.y, "inches", valueOnly=True)
1405
+ rule = getattr(x, "rule", None)
1406
+
1407
+ grob_id = getattr(x, "id", None)
1408
+ grob_id_lengths = getattr(x, "id_lengths", None)
1409
+
1410
+ if grob_id is None and grob_id_lengths is None:
1411
+ return GridGrobCoords(
1412
+ [GridCoords(xx, yy, name="1")],
1413
+ name=x.name, rule=rule,
1414
+ )
1415
+
1416
+ if grob_id is None:
1417
+ n = len(grob_id_lengths)
1418
+ grob_id = np.repeat(np.arange(1, n + 1), grob_id_lengths)
1419
+ else:
1420
+ grob_id = np.asarray(grob_id)
1421
+
1422
+ unique_ids = np.unique(grob_id)
1423
+ pts = []
1424
+ for uid in unique_ids:
1425
+ mask = grob_id == uid
1426
+ pts.append(GridCoords(xx[mask], yy[mask], name=str(uid)))
1427
+ return GridGrobCoords(pts, name=x.name, rule=rule)
1428
+
1429
+
1430
+ def _grob_points_text(x: Grob, closed: bool = True) -> GridGrobCoords:
1431
+ """grobPoints.text -- R coords.R:591-612.
1432
+
1433
+ Returns bounding box (4 corners) if closed, empty otherwise.
1434
+ Uses string metrics to estimate the text bounds.
1435
+ """
1436
+ if not closed:
1437
+ return empty_grob_coords(x.name)
1438
+
1439
+ from ._just import resolve_hjust, resolve_vjust
1440
+ from ._units import convert_x, convert_y, Unit
1441
+ from ._font_metrics import get_font_backend
1442
+
1443
+ label = getattr(x, "label", "")
1444
+ if isinstance(label, (list, tuple)):
1445
+ label = label[0] if label else ""
1446
+
1447
+ just = getattr(x, "just", None) or "centre"
1448
+ hjust = resolve_hjust(just, getattr(x, "hjust", None))
1449
+ vjust = resolve_vjust(just, getattr(x, "vjust", None))
1450
+
1451
+ # Get text position in inches
1452
+ xpos = convert_x(x.x, "inches", valueOnly=True)
1453
+ ypos = convert_y(x.y, "inches", valueOnly=True)
1454
+
1455
+ # Measure text
1456
+ backend = get_font_backend()
1457
+ gp = getattr(x, "gp", None)
1458
+ metric = backend.measure(str(label), gp)
1459
+
1460
+ w = metric.get("width", 0.0)
1461
+ asc = metric.get("ascent", 0.0)
1462
+ desc = metric.get("descent", 0.0)
1463
+ h = asc + desc
1464
+
1465
+ if w == 0 and h == 0:
1466
+ return empty_grob_coords(x.name)
1467
+
1468
+ # Compute bounds based on justification
1469
+ left = float(xpos[0]) - hjust * w
1470
+ bottom = float(ypos[0]) - vjust * h
1471
+ right = left + w
1472
+ top = bottom + h
1473
+
1474
+ return GridGrobCoords(
1475
+ [GridCoords(
1476
+ np.array([left, left, right, right]),
1477
+ np.array([bottom, top, top, bottom]),
1478
+ name="1",
1479
+ )],
1480
+ name=x.name,
1481
+ )
1482
+
1483
+
1484
+ def _grob_points_xspline(x: Grob, closed: Optional[bool] = None) -> GridGrobCoords:
1485
+ """grobPoints.xspline -- R coords.R:565-586.
1486
+
1487
+ Returns traced spline points. Falls back to control points if
1488
+ the spline tracing function is unavailable.
1489
+ """
1490
+ is_open = getattr(x, "open", True)
1491
+ if closed is None:
1492
+ closed = not is_open
1493
+
1494
+ if (closed and not is_open) or (not closed and is_open):
1495
+ from ._units import convert_x, convert_y
1496
+ xx = convert_x(x.x, "inches", valueOnly=True)
1497
+ yy = convert_y(x.y, "inches", valueOnly=True)
1498
+ return GridGrobCoords(
1499
+ [GridCoords(xx, yy, name="1")],
1500
+ name=x.name,
1501
+ )
1502
+ return empty_grob_coords(x.name)
1503
+
1504
+
1505
+ def _grob_points_rastergrob(x: Grob, closed: bool = True) -> GridGrobCoords:
1506
+ """grobPoints.rastergrob -- R coords.R:639-641. Always empty."""
1507
+ return empty_grob_coords(x.name)
1508
+
1509
+
1510
+ def _grob_points_null(x: Grob, closed: bool = True) -> GridGrobCoords:
1511
+ """grobPoints.null -- R coords.R:647-649. Always empty."""
1512
+ return empty_grob_coords(x.name)
1513
+
1514
+
1515
+ # ---------------------------------------------------------------------------
1516
+ # Dispatcher: map _grid_class → grobPoints implementation
1517
+ # ---------------------------------------------------------------------------
1518
+
1519
+ _GROB_POINTS_DISPATCH: Dict[str, Any] = {
1520
+ "circle": _grob_points_circle,
1521
+ "rect": _grob_points_rect,
1522
+ "lines": _grob_points_lines,
1523
+ "polyline": _grob_points_polyline,
1524
+ "polygon": _grob_points_polygon,
1525
+ "segments": _grob_points_segments,
1526
+ "pathgrob": _grob_points_pathgrob,
1527
+ "text": _grob_points_text,
1528
+ "xspline": _grob_points_xspline,
1529
+ "rastergrob": _grob_points_rastergrob,
1530
+ "null": _grob_points_null,
1531
+ "move.to": lambda x, closed=True: empty_grob_coords(x.name),
1532
+ "line.to": lambda x, closed=True: empty_grob_coords(x.name),
1533
+ "clip": lambda x, closed=True: empty_grob_coords(x.name),
1534
+ }