iplotx 0.1.0__py3-none-any.whl → 0.2.0__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.
iplotx/tree.py ADDED
@@ -0,0 +1,285 @@
1
+ from typing import (
2
+ Optional,
3
+ Sequence,
4
+ )
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ import matplotlib as mpl
9
+
10
+ from .style import (
11
+ get_style,
12
+ rotate_style,
13
+ )
14
+ from .utils.matplotlib import (
15
+ _stale_wrapper,
16
+ _forwarder,
17
+ _build_cmap_fun,
18
+ )
19
+ from .ingest import (
20
+ ingest_tree_data,
21
+ )
22
+ from .vertex import (
23
+ VertexCollection,
24
+ )
25
+ from .edge import (
26
+ EdgeCollection,
27
+ make_stub_patch as make_undirected_edge_patch,
28
+ )
29
+ from .network import (
30
+ _update_from_internal,
31
+ )
32
+
33
+
34
+ @_forwarder(
35
+ (
36
+ "set_clip_path",
37
+ "set_clip_box",
38
+ "set_snap",
39
+ "set_sketch_params",
40
+ "set_animated",
41
+ "set_picker",
42
+ )
43
+ )
44
+ class TreeArtist(mpl.artist.Artist):
45
+ """Artist for plotting trees."""
46
+
47
+ def __init__(
48
+ self,
49
+ tree,
50
+ layout="horizontal",
51
+ orientation="right",
52
+ directed: bool | str = False,
53
+ vertex_labels: Optional[list | dict | pd.Series] = None,
54
+ edge_labels: Optional[Sequence] = None,
55
+ transform: mpl.transforms.Transform = mpl.transforms.IdentityTransform(),
56
+ offset_transform: Optional[mpl.transforms.Transform] = None,
57
+ ):
58
+ self.tree = tree
59
+
60
+ self._ipx_internal_data = ingest_tree_data(
61
+ tree,
62
+ layout,
63
+ orientation=orientation,
64
+ directed=directed,
65
+ vertex_labels=vertex_labels,
66
+ edge_labels=edge_labels,
67
+ )
68
+
69
+ super().__init__()
70
+
71
+ # This is usually the identity (which scales poorly with dpi)
72
+ self.set_transform(transform)
73
+
74
+ # This is usually transData
75
+ self.set_offset_transform(offset_transform)
76
+
77
+ zorder = get_style(".network").get("zorder", 1)
78
+ self.set_zorder(zorder)
79
+
80
+ self._add_vertices()
81
+ self._add_edges()
82
+
83
+ def get_children(self):
84
+ return (self._vertices, self._edges)
85
+
86
+ def set_figure(self, figure):
87
+ super().set_figure(figure)
88
+ for child in self.get_children():
89
+ child.set_figure(figure)
90
+
91
+ def get_offset_transform(self):
92
+ """Get the offset transform (for vertices/edges)."""
93
+ return self._offset_transform
94
+
95
+ def set_offset_transform(self, offset_transform):
96
+ """Set the offset transform (for vertices/edges)."""
97
+ self._offset_transform = offset_transform
98
+
99
+ def get_layout(self, kind="vertex"):
100
+ """Get vertex or edge layout."""
101
+ layout_columns = [
102
+ f"_ipx_layout_{i}" for i in range(self._ipx_internal_data["ndim"])
103
+ ]
104
+
105
+ if kind == "vertex":
106
+ layout = self._ipx_internal_data["vertex_df"][layout_columns]
107
+ return layout
108
+
109
+ elif kind == "edge":
110
+ return self._ipx_internal_data["edge_df"][layout_columns]
111
+ else:
112
+ raise ValueError(f"Unknown layout kind: {kind}. Use 'vertex' or 'edge'.")
113
+
114
+ def get_datalim(self, transData, pad=0.15):
115
+ """Get limits on x/y axes based on the graph layout data.
116
+
117
+ Parameters:
118
+ transData (Transform): The transform to use for the data.
119
+ pad (float): Padding to add to the limits. Default is 0.05.
120
+ Units are a fraction of total axis range before padding.
121
+ """
122
+ layout = self.get_layout().values
123
+
124
+ if len(layout) == 0:
125
+ return mpl.transforms.Bbox([[0, 0], [1, 1]])
126
+
127
+ bbox = self._vertices.get_datalim(transData)
128
+
129
+ edge_bbox = self._edges.get_datalim(transData)
130
+ bbox = mpl.transforms.Bbox.union([bbox, edge_bbox])
131
+
132
+ bbox = bbox.expanded(sw=(1.0 + pad), sh=(1.0 + pad))
133
+ return bbox
134
+
135
+ def _get_label_series(self, kind):
136
+ if "label" in self._ipx_internal_data[f"{kind}_df"].columns:
137
+ return self._ipx_internal_data[f"{kind}_df"]["label"]
138
+ else:
139
+ return None
140
+
141
+ def get_vertices(self):
142
+ """Get VertexCollection artist."""
143
+ return self._vertices
144
+
145
+ def get_edges(self):
146
+ """Get EdgeCollection artist."""
147
+ return self._edges
148
+
149
+ def get_vertex_labels(self):
150
+ """Get list of vertex label artists."""
151
+ return self._vertices.get_labels()
152
+
153
+ def get_edge_labels(self):
154
+ """Get list of edge label artists."""
155
+ return self._edges.get_labels()
156
+
157
+ def _add_vertices(self):
158
+ """Add vertices to the tree."""
159
+ self._vertices = VertexCollection(
160
+ layout=self.get_layout(),
161
+ style=get_style(".vertex"),
162
+ labels=self._get_label_series("vertex"),
163
+ layout_coordinate_system=self._ipx_internal_data.get(
164
+ "layout_coordinate_system",
165
+ "catesian",
166
+ ),
167
+ transform=self.get_transform(),
168
+ offset_transform=self.get_offset_transform(),
169
+ )
170
+
171
+ def _add_edges(self):
172
+ """Add edges to the network artist.
173
+
174
+ NOTE: UndirectedEdgeCollection and ArrowCollection are both subclasses of
175
+ PatchCollection. When used with a cmap/norm, they set their facecolor
176
+ according to the cmap, even though most likely we only want the edgecolor
177
+ set that way. It can make for funny looking plots that are not uninteresting
178
+ but mostly niche at this stage. Therefore we sidestep the whole cmap thing
179
+ here.
180
+ """
181
+
182
+ labels = self._get_label_series("edge")
183
+ edge_style = get_style(".edge")
184
+
185
+ if "cmap" in edge_style:
186
+ cmap_fun = _build_cmap_fun(
187
+ edge_style["color"],
188
+ edge_style["cmap"],
189
+ )
190
+ else:
191
+ cmap_fun = None
192
+
193
+ edge_df = self._ipx_internal_data["edge_df"].set_index(
194
+ ["_ipx_source", "_ipx_target"]
195
+ )
196
+
197
+ if "cmap" in edge_style:
198
+ colorarray = []
199
+ edgepatches = []
200
+ adjacent_vertex_ids = []
201
+ waypoints = []
202
+ for i, (vid1, vid2) in enumerate(edge_df.index):
203
+ edge_stylei = rotate_style(edge_style, index=i, key=(vid1, vid2))
204
+
205
+ # FIXME:: Improve this logic. We have three layers of priority:
206
+ # 1. Explicitely set in the style of "plot"
207
+ # 2. Internal through network attributes
208
+ # 3. Default styles
209
+ # Because 1 and 3 are merged as a style context on the way in,
210
+ # it's hard to squeeze 2 in the middle. For now, we will assume
211
+ # the priority order is 2-1-3 instead (internal property is
212
+ # highest priority).
213
+ # This is also why we cannot shift this logic further into the
214
+ # EdgeCollection class, which is oblivious of NetworkArtist's
215
+ # internal data. In fact, one would argue this needs to be
216
+ # pushed outwards to deal with the wrong ordering.
217
+ _update_from_internal(edge_stylei, edge_df.iloc[i], kind="edge")
218
+
219
+ if cmap_fun is not None:
220
+ colorarray.append(edge_stylei["color"])
221
+ edge_stylei["color"] = cmap_fun(edge_stylei["color"])
222
+
223
+ # Tree layout determines waypoints
224
+ waypointsi = edge_stylei.pop("waypoints", None)
225
+ if waypointsi is None:
226
+ layout_name = self._ipx_internal_data["layout_name"]
227
+ if layout_name == "horizontal":
228
+ waypointsi = "x0y1"
229
+ elif layout_name == "vertical":
230
+ waypointsi = "y0y0"
231
+ elif layout_name == "radial":
232
+ waypointsi = "r0a1"
233
+ else:
234
+ waypointsi = "none"
235
+ waypoints.append(waypointsi)
236
+
237
+ # These are not the actual edges drawn, only stubs to establish
238
+ # the styles which are then fed into the dynamic, optimised
239
+ # factory (the collection) below
240
+ patch = make_undirected_edge_patch(
241
+ **edge_stylei,
242
+ )
243
+ edgepatches.append(patch)
244
+ adjacent_vertex_ids.append((vid1, vid2))
245
+
246
+ if "cmap" in edge_style:
247
+ vmin = np.min(colorarray)
248
+ vmax = np.max(colorarray)
249
+ norm = mpl.colors.Normalize(vmin=vmin, vmax=vmax)
250
+ edge_style["norm"] = norm
251
+
252
+ edge_style["waypoints"] = waypoints
253
+
254
+ # NOTE: Trees are directed is their "directed" property is True, "child", or "parent"
255
+ self._edges = EdgeCollection(
256
+ edgepatches,
257
+ vertex_ids=adjacent_vertex_ids,
258
+ vertex_collection=self._vertices,
259
+ layout=self.get_layout(kind="vertex"),
260
+ layout_coordinate_system=self._ipx_internal_data.get(
261
+ "layout_coordinate_system",
262
+ "cartesian",
263
+ ),
264
+ labels=labels,
265
+ transform=self.get_offset_transform(),
266
+ style=edge_style,
267
+ directed=bool(self._ipx_internal_data["directed"]),
268
+ )
269
+ if "cmap" in edge_style:
270
+ self._edges.set_array(colorarray)
271
+
272
+ @_stale_wrapper
273
+ def draw(self, renderer):
274
+ """Draw each of the children, with some buffering mechanism."""
275
+ if not self.get_visible():
276
+ return
277
+
278
+ # FIXME: Callbacks on stale vertices/edges??
279
+
280
+ # NOTE: looks like we have to manage the zorder ourselves
281
+ # this is kind of funny actually
282
+ children = list(self.get_children())
283
+ children.sort(key=lambda x: x.zorder)
284
+ for art in children:
285
+ art.draw(renderer)
iplotx/typing.py CHANGED
@@ -1,41 +1,36 @@
1
- from typing import Union, Sequence
2
- from numpy import ndarray
3
- from pandas import DataFrame
1
+ from typing import (
2
+ Union,
3
+ Sequence,
4
+ Any,
5
+ )
6
+ import numpy as np
7
+ import pandas as pd
4
8
 
5
- from .importing import igraph, networkx
6
9
 
10
+ # NOTE: GraphType is supposed to indicate any kind of graph object that is accepted by
11
+ # iplotx's functions, e.g. igraph.Graph or networkx.Graph and subclasses. It is not
12
+ # quite possible to really statically type it because providers can add their own
13
+ # types - together with protocols to process them - at runtime.
14
+ # Nonetheless, for increased readibility we define separately-named types in this
15
+ # module to be used throughout the codebase.
16
+ GraphType = Any
17
+ TreeType = Any
7
18
 
8
- igraphGraph = igraph.Graph if igraph is None else None
9
- if networkx is not None:
10
- from networkx import Graph as networkxGraph
11
- from networkx import DiGraph as networkxDiGraph
12
- from networkx import MultiGraph as networkxMultiGraph
13
- from networkx import MultiDiGraph as networkxMultiDiGraph
14
-
15
- networkxOmniGraph = Union[
16
- networkxGraph, networkxDiGraph, networkxMultiGraph, networkxMultiDiGraph
17
- ]
18
- else:
19
- networkxOmniGraph = None
20
-
21
- if igraphGraph is not None and networkxOmniGraph is not None:
22
- GraphType = Union[igraphGraph, networkxOmniGraph]
23
- elif igraphGraph is not None:
24
- GraphType = igraphGraph
25
- else:
26
- GraphType = networkxOmniGraph
27
-
28
- LayoutType = Union[str, Sequence[Sequence[float]], ndarray, DataFrame]
29
-
30
- if (igraph is not None) and (networkx is not None):
31
- # networkx returns generators of sets, igraph has its own classes
32
- # additionally, one can put list of memberships
33
- GroupingType = Union[
34
- Sequence[set],
35
- igraph.clustering.Clustering,
36
- igraph.clustering.VertexClustering,
37
- igraph.clustering.Cover,
38
- igraph.clustering.VertexCover,
39
- Sequence[int],
40
- Sequence[str],
41
- ]
19
+ # NOTE: The commented ones are not a mistake: they are supported but cannot be
20
+ # statically typed if the user has no igraph installed (it's a soft dependency).
21
+ LayoutType = Union[
22
+ str,
23
+ Sequence[Sequence[float]],
24
+ np.ndarray,
25
+ pd.DataFrame,
26
+ # igraph.Layout,
27
+ ]
28
+ GroupingType = Union[
29
+ Sequence[set],
30
+ Sequence[int],
31
+ Sequence[str],
32
+ # igraph.clustering.Clustering,
33
+ # igraph.clustering.VertexClustering,
34
+ # igraph.clustering.Cover,
35
+ # igraph.clustering.VertexCover,
36
+ ]
iplotx/utils/geometry.py CHANGED
@@ -1,4 +1,4 @@
1
- from math import tan, atan2
1
+ from math import atan2
2
2
  import numpy as np
3
3
 
4
4
 
@@ -21,22 +21,56 @@ def _evaluate_cubic_bezier(points, t):
21
21
  )
22
22
 
23
23
 
24
+ def _evaluate_cubic_bezier_derivative(points, t):
25
+ """Evaluate the derivative of a cubic Bezier curve at t."""
26
+ p0, p1, p2, p3 = points
27
+ # (dx / dt, dy / dt) is the parametric gradient
28
+ # to get the angle from this, one can just atanh(dy/dt, dx/dt)
29
+ # This is equivalent to computing the actual bezier curve
30
+ # at low t, of course, which is the geometric interpretation
31
+ # (obviously, division by t is irrelenant)
32
+ return (
33
+ 3 * p0 * (1 - t) ** 2
34
+ + 3 * p1 * (1 - t) * (-3 * t + 1)
35
+ + 3 * p2 * t * (2 - 3 * t)
36
+ + 3 * p3 * t**2
37
+ )
38
+
39
+
24
40
  def convex_hull(points):
25
- """Compute the convex hull of a set of 2D points."""
26
- from ..importing import igraph
41
+ """Compute the convex hull of a set of 2D points.
42
+
43
+ This is guaranteed to return the vertices clockwise.
27
44
 
45
+ (Therefore, (v[i+1] - v[i]) rotated *left* by pi/2 points *outwards* of the convex hull.)
46
+ """
28
47
  points = np.asarray(points)
48
+ if len(points) < 3:
49
+ return np.arange(len(points))
50
+
51
+ hull_idx = None
29
52
 
30
53
  # igraph's should be faster in 2D
31
- if igraph is not None:
54
+ try:
55
+ import igraph
56
+
32
57
  hull_idx = igraph.convex_hull(list(points))
33
- else:
34
- try:
35
- from scipy.spatial import ConvexHull
58
+ except ImportError:
59
+ pass
36
60
 
37
- hull_idx = ConvexHull(points).vertices
38
- except ImportError:
39
- hull_idx = _convex_hull_Graham_scan(points)
61
+ # Otherwise, try scipy
62
+ try:
63
+ from scipy.spatial import ConvexHull
64
+
65
+ # NOTE: scipy guarantees counterclockwise ordering in 2D
66
+ # https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.ConvexHull.html
67
+ hull_idx = ConvexHull(points).vertices[::-1]
68
+ except ImportError:
69
+ pass
70
+
71
+ # Last resort: our own Graham scan
72
+ if hull_idx is None:
73
+ hull_idx = _convex_hull_Graham_scan(points)
40
74
 
41
75
  return hull_idx
42
76
 
@@ -45,11 +79,10 @@ def convex_hull(points):
45
79
  # Compared to that C implementation, this is a bit more vectorised and messes less with memory as usual when
46
80
  # optimising Python/numpy code
47
81
  def _convex_hull_Graham_scan(points):
48
- """Compute the indices for the convex hull of a set of 2D points using Graham's scan algorithm."""
49
- if len(points) < 4:
50
- # NOTE: for an exact triangle, this does not guarantee chirality. Should be ok anyway
51
- return np.arange(len(points))
82
+ """Compute the indices for the convex hull of a set of 2D points using Graham's scan algorithm.
52
83
 
84
+ NOTE: This works from 3 points upwards, guaranteed clockwise.
85
+ """
53
86
  points = np.asarray(points)
54
87
 
55
88
  # Find pivot (bottom left corner)
@@ -136,9 +169,13 @@ def _convex_hull_Graham_scan(points):
136
169
 
137
170
 
138
171
  def _compute_group_path_with_vertex_padding(
172
+ hull,
139
173
  points,
140
174
  transform,
141
175
  vertexpadding=10,
176
+ points_per_curve=30,
177
+ # TODO: check how dpi affects this
178
+ dpi=72.0,
142
179
  ):
143
180
  """Offset path for a group based on vertex padding.
144
181
 
@@ -146,82 +183,92 @@ def _compute_group_path_with_vertex_padding(
146
183
 
147
184
  # NOTE: this would look better as a cubic Bezier, but ok for now.
148
185
  """
186
+ # Short form
187
+ ppc = points_per_curve
188
+
189
+ # No padding, set degenerate path
190
+ if vertexpadding == 0:
191
+ for j, point in enumerate(hull):
192
+ points[ppc * j : ppc * (j + 1)] = point
193
+ points[-1] = points[0]
194
+ return points
149
195
 
150
196
  # Transform into figure coordinates
151
197
  trans = transform.transform
152
198
  trans_inv = transform.inverted().transform
153
199
  points = trans(points)
154
200
 
155
- # Find the vertex centers, to recompute the offsets from scratch
156
- # Independent whether this is a first call or a later draw,
157
- # finding the vertex center can be done at once
158
- # 0. .->.vcenter
159
- # | | ^
160
- # | | |
161
- # 1.--.2 .--.
162
- # singleton group
163
- s2 = 0.96
164
- if len(points) == 9:
165
- points[:] = 0.5 * (points[0] + points[4])
166
- points[0] += np.array([0, -1]) * vertexpadding
167
- points[1] += np.array([-s2, -s2]) * vertexpadding
168
- points[2] += np.array([-1, 0]) * vertexpadding
169
- points[3] += np.array([-s2, s2]) * vertexpadding
170
- points[4] += np.array([0, 1]) * vertexpadding
171
- points[5] += np.array([s2, s2]) * vertexpadding
172
- points[6] += np.array([1, 0]) * vertexpadding
173
- points[7] += np.array([s2, -s2]) * vertexpadding
174
- points[8] += np.array([0, -1]) * vertexpadding
175
- else:
176
- # doublet group are a bit different from triangles+
177
- if len(points) == 11:
178
- # points per vertex
179
- ppv = 5
180
- points[:-1:ppv] = 0.5 * (points[:-1:ppv] + points[ppv - 1 : -1 : ppv])
181
- else:
182
- ppv = 3
183
- points[:-1:ppv] = (
184
- points[:-1:ppv] + points[ppv - 1 : -1 : ppv] - points[1:-1:ppv]
185
- )
186
- for j in range(1, ppv):
187
- points[j:-1:ppv] = points[:-1:ppv]
201
+ # Singleton: draw a circle around it
202
+ if len(hull) == 1:
203
+
204
+ # NOTE: linspace is double inclusive, which covers CLOSEPOLY
205
+ thetas = np.linspace(
206
+ -np.pi,
207
+ np.pi,
208
+ len(points),
209
+ )
210
+ # NOTE: dpi scaling might need to happen here
211
+ perimeter = vertexpadding * np.vstack([np.cos(thetas), np.sin(thetas)]).T
212
+ return trans_inv(trans(hull[0]) + perimeter)
213
+
214
+ # Doublet: draw two semicircles
215
+ if len(hull) == 2:
216
+
217
+ # Unit vector connecting the two points
218
+ dv = trans(hull[0]) - trans(hull[1])
219
+ dv = dv / np.sqrt((dv**2).sum())
220
+
221
+ # Draw a semicircle
222
+ angles = np.linspace(-0.5 * np.pi, 0.5 * np.pi, 30)
223
+ vs = np.array([np.cos(angles), -np.sin(angles), np.sin(angles), np.cos(angles)])
224
+ vs = vs.T.reshape((len(angles), 2, 2))
225
+ vs = np.matmul(dv, vs)
226
+
227
+ # NOTE: dpi scaling might need to happen here
228
+ semicircle1 = vertexpadding * vs
229
+ semicircle2 = vertexpadding * np.matmul(vs, -np.diag((1, 1)))
230
+
231
+ # Put it together
232
+ vs1 = trans_inv(trans(hull[0]) + semicircle1)
233
+ vs2 = trans_inv(trans(hull[1]) + semicircle2)
234
+ points[:ppc] = vs1
235
+ points[ppc:-1] = vs2
188
236
  points[-1] = points[0]
237
+ return points
189
238
 
190
- # Compute all shift vectors by diff, arctan2, then add 90 degrees, tan, norm
191
- # This maintains chirality
192
- # NOTE: the last point is just going back to the beginning, this
193
- # is a quirk or how mpl's closed paths work
194
-
195
- # Normalised diff
196
- vpoints = points[:-1:ppv].copy()
197
- vpoints[0] -= points[-2]
198
- vpoints[1:] -= points[:-1:ppv][:-1]
199
- vpoints = (vpoints.T / np.sqrt((vpoints**2).sum(axis=1))).T
200
-
201
- # Rotate by 90 degrees
202
- vpads = vpoints @ np.array([[0, 1], [-1, 0]])
203
-
204
- # Permute diff for the end
205
- vpads_perm = np.zeros_like(vpads)
206
- vpads_perm[:-1] = vpads[1:]
207
- vpads_perm[-1] = vpads[0]
208
-
209
- # Shift the points
210
- if ppv == 3:
211
- points[:-1:ppv] += vpads * vertexpadding
212
- points[1:-1:ppv] += (vpads + vpads_perm) * vertexpadding
213
- points[2:-1:ppv] += vpads_perm * vertexpadding
214
- else:
215
- points[:-1:ppv] += vpads * vertexpadding
216
- points[1:-1:ppv] += (vpads + vpoints) * vertexpadding
217
- points[2:-1:ppv] += vpoints * vertexpadding
218
- points[3:-1:ppv] += (vpads_perm + vpoints) * vertexpadding
219
- points[4:-1:ppv] += vpads_perm * vertexpadding
239
+ # At least three points, i.e. a nondegenerate convex hull
240
+ nsides = len(hull)
241
+ for i, point1 in enumerate(hull):
242
+ point0 = hull[i - 1]
243
+ point2 = hull[(i + 1) % nsides]
220
244
 
221
- # mpl's quirky closed-path thing
222
- points[-1] = points[0]
245
+ # NOTE: this can be optimised by computing things once
246
+ # unit vector to previous point
247
+ dv0 = trans(point1) - trans(point0)
248
+ dv0 = dv0 / np.sqrt((dv0**2).sum())
223
249
 
224
- # Transform back to data coordinates
225
- points = trans_inv(points)
250
+ # unit vector to next point
251
+ dv2 = trans(point2) - trans(point1)
252
+ dv2 = dv2 / np.sqrt((dv2**2).sum())
226
253
 
254
+ # span the angles
255
+ theta0 = atan2(dv0[1], dv0[0])
256
+ theta2 = atan2(dv2[1], dv2[0])
257
+
258
+ # The worst that can happen is that we go exactly backwards, i.e. theta2 == theta0 + np.pi
259
+ # if it's more than that, we are on the inside of the convex hull due to the periodicity of atan2
260
+ if theta2 - theta0 > np.pi:
261
+ theta2 -= 2 * np.pi
262
+
263
+ # angles is from the point of view of the first vector, dv0
264
+ angles = np.linspace(theta0 + np.pi / 2, theta2 + np.pi / 2, ppc)
265
+ vs = np.array([np.cos(angles), np.sin(angles)]).T
266
+
267
+ # NOTE: dpi scaling might need to happen here
268
+ chunkcircle = vertexpadding * vs
269
+
270
+ vs1 = trans_inv(trans(point1) + chunkcircle)
271
+ points[i * ppc : (i + 1) * ppc] = vs1
272
+
273
+ points[-1] = points[0]
227
274
  return points
@@ -0,0 +1,3 @@
1
+ def _make_layout_columns(ndim):
2
+ columns = [f"_ipx_layout_{i}" for i in range(ndim)]
3
+ return columns