iplotx 0.3.1__py3-none-any.whl → 0.5.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/__init__.py +5 -0
- iplotx/artists.py +24 -0
- iplotx/cascades.py +22 -31
- iplotx/edge/__init__.py +122 -49
- iplotx/edge/arrow.py +44 -3
- iplotx/edge/geometry.py +30 -21
- iplotx/edge/leaf.py +117 -0
- iplotx/edge/ports.py +3 -2
- iplotx/groups.py +1 -3
- iplotx/ingest/__init__.py +6 -20
- iplotx/ingest/heuristics.py +4 -36
- iplotx/ingest/providers/network/igraph.py +20 -18
- iplotx/ingest/providers/network/networkx.py +20 -24
- iplotx/ingest/providers/network/simple.py +114 -0
- iplotx/ingest/providers/tree/biopython.py +15 -5
- iplotx/ingest/providers/tree/cogent3.py +9 -5
- iplotx/ingest/providers/tree/ete4.py +2 -5
- iplotx/ingest/providers/tree/simple.py +97 -0
- iplotx/ingest/providers/tree/skbio.py +2 -5
- iplotx/ingest/typing.py +109 -19
- iplotx/label.py +42 -12
- iplotx/layout.py +5 -1
- iplotx/network.py +69 -18
- iplotx/plotting.py +9 -9
- iplotx/{style.py → style/__init__.py} +150 -187
- iplotx/style/leaf_info.py +44 -0
- iplotx/style/library.py +324 -0
- iplotx/tree.py +279 -51
- iplotx/typing.py +2 -0
- iplotx/utils/geometry.py +32 -40
- iplotx/utils/matplotlib.py +43 -22
- iplotx/utils/style.py +17 -1
- iplotx/version.py +1 -1
- iplotx/vertex.py +63 -15
- {iplotx-0.3.1.dist-info → iplotx-0.5.0.dist-info}/METADATA +2 -1
- iplotx-0.5.0.dist-info/RECORD +38 -0
- iplotx-0.3.1.dist-info/RECORD +0 -32
- {iplotx-0.3.1.dist-info → iplotx-0.5.0.dist-info}/WHEEL +0 -0
|
@@ -1,155 +1,33 @@
|
|
|
1
1
|
from typing import (
|
|
2
2
|
Any,
|
|
3
|
+
Iterable,
|
|
3
4
|
Optional,
|
|
4
5
|
Sequence,
|
|
5
6
|
)
|
|
7
|
+
from collections import defaultdict
|
|
6
8
|
from collections.abc import Hashable
|
|
7
|
-
import copy
|
|
8
9
|
from contextlib import contextmanager
|
|
9
10
|
import numpy as np
|
|
10
11
|
import pandas as pd
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"edgecolor",
|
|
18
|
-
"facecolor",
|
|
19
|
-
"linewidth",
|
|
20
|
-
"linestyle",
|
|
21
|
-
"alpha",
|
|
22
|
-
"zorder",
|
|
23
|
-
"tension",
|
|
24
|
-
"looptension",
|
|
25
|
-
"loopmaxangle",
|
|
26
|
-
"paralleloffset",
|
|
27
|
-
"offset",
|
|
28
|
-
"rotate",
|
|
29
|
-
"marker",
|
|
30
|
-
"waypoints",
|
|
31
|
-
"horizontalalignment",
|
|
32
|
-
"verticalalignment",
|
|
33
|
-
"boxstyle",
|
|
34
|
-
"hpadding",
|
|
35
|
-
"vpadding",
|
|
36
|
-
"hmargin",
|
|
37
|
-
"vmargin",
|
|
38
|
-
"ports",
|
|
39
|
-
"extend",
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
# These properties are not allowed to be rotated (global throughout the graph).
|
|
43
|
-
# This might change in the future as the API improves.
|
|
44
|
-
nonrotating_leaves = (
|
|
45
|
-
"paralleloffset",
|
|
46
|
-
"looptension",
|
|
47
|
-
"loopmaxangle",
|
|
48
|
-
"vertexpadding",
|
|
49
|
-
"extend",
|
|
13
|
+
from ..utils.style import copy_with_deep_values
|
|
14
|
+
from .library import style_library
|
|
15
|
+
from .leaf_info import (
|
|
16
|
+
style_leaves,
|
|
17
|
+
nonrotating_leaves,
|
|
50
18
|
)
|
|
51
19
|
|
|
52
20
|
|
|
53
|
-
default
|
|
54
|
-
|
|
55
|
-
"size": 20,
|
|
56
|
-
"facecolor": "black",
|
|
57
|
-
"marker": "o",
|
|
58
|
-
"label": {
|
|
59
|
-
"color": "white",
|
|
60
|
-
"horizontalalignment": "center",
|
|
61
|
-
"verticalalignment": "center",
|
|
62
|
-
"hpadding": 18,
|
|
63
|
-
"vpadding": 12,
|
|
64
|
-
},
|
|
65
|
-
},
|
|
66
|
-
"edge": {
|
|
67
|
-
"linewidth": 1.5,
|
|
68
|
-
"linestyle": "-",
|
|
69
|
-
"color": "black",
|
|
70
|
-
"curved": False,
|
|
71
|
-
"paralleloffset": 3,
|
|
72
|
-
"tension": 1,
|
|
73
|
-
"looptension": 4,
|
|
74
|
-
"loopmaxangle": 60,
|
|
75
|
-
"label": {
|
|
76
|
-
"horizontalalignment": "center",
|
|
77
|
-
"verticalalignment": "center",
|
|
78
|
-
"rotate": False,
|
|
79
|
-
"bbox": {
|
|
80
|
-
"boxstyle": "round",
|
|
81
|
-
"facecolor": "white",
|
|
82
|
-
"edgecolor": "none",
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
"arrow": {
|
|
86
|
-
"marker": "|>",
|
|
87
|
-
"width": 8,
|
|
88
|
-
},
|
|
89
|
-
},
|
|
90
|
-
"grouping": {
|
|
91
|
-
"facecolor": ["grey", "steelblue", "tomato"],
|
|
92
|
-
"edgecolor": "black",
|
|
93
|
-
"linewidth": 1.5,
|
|
94
|
-
"alpha": 0.5,
|
|
95
|
-
"vertexpadding": 18,
|
|
96
|
-
},
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def copy_with_deep_values(style):
|
|
101
|
-
"""Make a deep copy of the style dict but do not create copies of the keys."""
|
|
102
|
-
newdict = {}
|
|
103
|
-
for key, value in style.items():
|
|
104
|
-
if isinstance(value, dict):
|
|
105
|
-
newdict[key] = copy_with_deep_values(value)
|
|
106
|
-
else:
|
|
107
|
-
newdict[key] = copy.copy(value)
|
|
108
|
-
return newdict
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
hollow = copy_with_deep_values(default)
|
|
112
|
-
hollow["vertex"]["color"] = None
|
|
113
|
-
hollow["vertex"]["facecolor"] = "none"
|
|
114
|
-
hollow["vertex"]["edgecolor"] = "black"
|
|
115
|
-
hollow["vertex"]["linewidth"] = 1.5
|
|
116
|
-
hollow["vertex"]["marker"] = "r"
|
|
117
|
-
hollow["vertex"]["size"] = "label"
|
|
118
|
-
hollow["vertex"]["label"]["color"] = "black"
|
|
119
|
-
|
|
120
|
-
tree = copy_with_deep_values(default)
|
|
121
|
-
tree["vertex"]["size"] = 0
|
|
122
|
-
tree["vertex"]["alpha"] = 0
|
|
123
|
-
tree["edge"]["linewidth"] = 2.5
|
|
124
|
-
tree["vertex"]["label"]["bbox"] = {
|
|
125
|
-
"boxstyle": "square,pad=0.5",
|
|
126
|
-
"facecolor": "white",
|
|
127
|
-
"edgecolor": "none",
|
|
128
|
-
}
|
|
129
|
-
tree["vertex"]["label"]["color"] = "black"
|
|
130
|
-
tree["vertex"]["label"]["size"] = 12
|
|
131
|
-
tree["vertex"]["label"]["verticalalignment"] = "center"
|
|
132
|
-
tree["vertex"]["label"]["hmargin"] = 10
|
|
133
|
-
|
|
134
|
-
|
|
21
|
+
# Prepopulate default style, it's used later as a backbone for everything else
|
|
22
|
+
default = style_library["default"]
|
|
135
23
|
styles = {
|
|
136
24
|
"default": default,
|
|
137
|
-
"hollow": hollow,
|
|
138
|
-
"tree": tree,
|
|
139
25
|
}
|
|
140
26
|
|
|
141
27
|
|
|
142
|
-
stylename = "default"
|
|
143
|
-
|
|
144
|
-
|
|
145
28
|
current = copy_with_deep_values(styles["default"])
|
|
146
29
|
|
|
147
30
|
|
|
148
|
-
def get_stylename():
|
|
149
|
-
"""Return the name of the current iplotx style."""
|
|
150
|
-
return str(stylename)
|
|
151
|
-
|
|
152
|
-
|
|
153
31
|
def get_style(name: str = "", *args) -> dict[str, Any]:
|
|
154
32
|
"""Get a *deep copy* of the chosen style.
|
|
155
33
|
|
|
@@ -194,36 +72,33 @@ def get_style(name: str = "", *args) -> dict[str, Any]:
|
|
|
194
72
|
return style
|
|
195
73
|
|
|
196
74
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
"""
|
|
201
|
-
|
|
202
|
-
The style name of 'default' is reserved for reverting back to
|
|
203
|
-
the default style settings.
|
|
75
|
+
def merge_styles(
|
|
76
|
+
styles: Sequence[str | dict[str, Any]] | Iterable[str | dict[str, Any]],
|
|
77
|
+
) -> dict[str, Any]:
|
|
78
|
+
"""Merge a sequence of styles into a single one.
|
|
204
79
|
|
|
205
80
|
Parameters:
|
|
206
|
-
|
|
207
|
-
or a dict with
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
**kwargs: Additional style changes to be applied at the end of any style.
|
|
81
|
+
styles: Sequence (list, tuple, etc.) of styles, each either the name of an internal
|
|
82
|
+
style or a dict-like with custom properties.
|
|
83
|
+
Returns:
|
|
84
|
+
The composite style as a dict.
|
|
211
85
|
"""
|
|
212
86
|
try:
|
|
213
87
|
import networkx as nx
|
|
214
88
|
except ImportError:
|
|
215
89
|
nx = None
|
|
216
90
|
|
|
217
|
-
global current
|
|
218
|
-
|
|
219
91
|
def _sanitize_leaves(style: dict):
|
|
220
92
|
for key, value in style.items():
|
|
221
93
|
if key in style_leaves:
|
|
94
|
+
# Networkx has a few lazy data structures
|
|
95
|
+
# TODO: move this code to provider
|
|
222
96
|
if nx is not None:
|
|
223
97
|
if isinstance(value, nx.classes.reportviews.NodeView):
|
|
224
98
|
style[key] = dict(value)
|
|
225
99
|
elif isinstance(value, nx.classes.reportviews.EdgeViewABC):
|
|
226
100
|
style[key] = [v for *e, v in value]
|
|
101
|
+
|
|
227
102
|
elif isinstance(value, dict):
|
|
228
103
|
_sanitize_leaves(value)
|
|
229
104
|
|
|
@@ -233,37 +108,97 @@ def use(style: Optional[str | dict | Sequence] = None, **kwargs):
|
|
|
233
108
|
current[key] = value
|
|
234
109
|
continue
|
|
235
110
|
|
|
236
|
-
# Style leaves are
|
|
237
|
-
if
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
111
|
+
# Style non-leaves are either recurred into or deleted
|
|
112
|
+
if key not in style_leaves:
|
|
113
|
+
if isinstance(value, dict):
|
|
114
|
+
_update(value, current[key])
|
|
115
|
+
elif value is None:
|
|
116
|
+
del current[key]
|
|
117
|
+
else:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"Setting non-leaf style value to a non-dict: {key}, {value}",
|
|
120
|
+
)
|
|
241
121
|
else:
|
|
242
|
-
|
|
122
|
+
# Style leaves could be incomplete, ensure a sensible default
|
|
123
|
+
if value is None:
|
|
124
|
+
del current[key]
|
|
125
|
+
continue
|
|
243
126
|
|
|
244
|
-
|
|
127
|
+
if not isinstance(value, dict):
|
|
128
|
+
current[key] = value
|
|
129
|
+
continue
|
|
245
130
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
131
|
+
if hasattr(value, "default_factory"):
|
|
132
|
+
current[key] = value
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
if hasattr(current[key], "default_factory"):
|
|
136
|
+
default_value = current[key].default_factory()
|
|
137
|
+
else:
|
|
138
|
+
default_value = current[key]
|
|
139
|
+
current[key] = defaultdict(
|
|
140
|
+
lambda: default_value,
|
|
141
|
+
value,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
merged = {}
|
|
145
|
+
for style in styles:
|
|
146
|
+
if isinstance(style, str):
|
|
147
|
+
style = get_style(style)
|
|
251
148
|
else:
|
|
252
|
-
|
|
149
|
+
_sanitize_leaves(style)
|
|
150
|
+
unflatten_style(style)
|
|
151
|
+
_update(style, merged)
|
|
253
152
|
|
|
254
|
-
|
|
255
|
-
styles.append(kwargs)
|
|
153
|
+
return merged
|
|
256
154
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
155
|
+
|
|
156
|
+
# The following is inspired by matplotlib's style library
|
|
157
|
+
# https://github.com/matplotlib/matplotlib/blob/v3.10.3/lib/matplotlib/style/core.py#L45
|
|
158
|
+
def use(
|
|
159
|
+
style: Optional[
|
|
160
|
+
str | dict[str, Any] | Sequence[str | dict[str, Any]] | Iterable[str | dict[str, Any]]
|
|
161
|
+
] = None,
|
|
162
|
+
**kwargs,
|
|
163
|
+
):
|
|
164
|
+
"""Use iplotx style setting for a style specification.
|
|
165
|
+
|
|
166
|
+
The style name of 'default' is reserved for reverting back to
|
|
167
|
+
the default style settings.
|
|
168
|
+
|
|
169
|
+
Parameters:
|
|
170
|
+
style: A style specification, currently either a name of an existing style
|
|
171
|
+
or a dict with specific parts of the style to override. The string
|
|
172
|
+
"default" resets the style to the default one. If this is a sequence,
|
|
173
|
+
each style is applied in order.
|
|
174
|
+
**kwargs: Additional style changes to be applied at the end of any style.
|
|
175
|
+
"""
|
|
176
|
+
global current
|
|
177
|
+
|
|
178
|
+
styles = []
|
|
179
|
+
if isinstance(style, (dict, str)):
|
|
180
|
+
styles.append(style)
|
|
181
|
+
elif style is not None:
|
|
182
|
+
styles.extend(list(style))
|
|
183
|
+
if kwargs:
|
|
184
|
+
styles.append(kwargs)
|
|
185
|
+
|
|
186
|
+
# Discard empty styles for speed
|
|
187
|
+
styles = [style for style in styles if style]
|
|
188
|
+
|
|
189
|
+
if len(styles) == 0:
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
# If the first style is a string (internal style), apply it cold. If it's a
|
|
193
|
+
# dict, apply it hot (on top of the current style(. Any style after the first
|
|
194
|
+
# is always applied hot - otherwise it would invalidate previous styles.
|
|
195
|
+
if not isinstance(styles[0], str):
|
|
196
|
+
# hot insertion on top of current
|
|
197
|
+
styles.insert(0, current)
|
|
198
|
+
|
|
199
|
+
old_style = copy_with_deep_values(current)
|
|
200
|
+
try:
|
|
201
|
+
current = merge_styles(styles)
|
|
267
202
|
except:
|
|
268
203
|
current = old_style
|
|
269
204
|
raise
|
|
@@ -276,7 +211,12 @@ def reset() -> None:
|
|
|
276
211
|
|
|
277
212
|
|
|
278
213
|
@contextmanager
|
|
279
|
-
def context(
|
|
214
|
+
def context(
|
|
215
|
+
style: Optional[
|
|
216
|
+
str | dict[str, Any] | Sequence[str | dict[str, Any]] | Iterable[str | dict[str, Any]]
|
|
217
|
+
] = None,
|
|
218
|
+
**kwargs,
|
|
219
|
+
):
|
|
280
220
|
"""Create a style context for iplotx.
|
|
281
221
|
|
|
282
222
|
Parameters:
|
|
@@ -336,9 +276,7 @@ def unflatten_style(
|
|
|
336
276
|
|
|
337
277
|
# top-level adjustments
|
|
338
278
|
if "zorder" in style_flat:
|
|
339
|
-
style_flat["network_zorder"] = style_flat["grouping_zorder"] = style_flat.pop(
|
|
340
|
-
"zorder"
|
|
341
|
-
)
|
|
279
|
+
style_flat["network_zorder"] = style_flat["grouping_zorder"] = style_flat.pop("zorder")
|
|
342
280
|
|
|
343
281
|
# Begin recursion
|
|
344
282
|
_inner(style_flat)
|
|
@@ -348,6 +286,7 @@ def rotate_style(
|
|
|
348
286
|
style: dict[str, Any],
|
|
349
287
|
index: Optional[int] = None,
|
|
350
288
|
key: Optional[Hashable] = None,
|
|
289
|
+
key2: Optional[Hashable] = None,
|
|
351
290
|
props: Optional[Sequence[str]] = None,
|
|
352
291
|
) -> dict[str, Any]:
|
|
353
292
|
"""Rotate leaves of a style for a certain index or key.
|
|
@@ -357,6 +296,9 @@ def rotate_style(
|
|
|
357
296
|
index: The integer to rotate the style leaves into.
|
|
358
297
|
key: For dict-like leaves (e.g. vertex properties specified as a dict-like object over the
|
|
359
298
|
vertices themselves), the key to use for rotation (e.g. the vertex itself).
|
|
299
|
+
key2: For dict-like leaves, a backup key in case the first key fails. If this is None
|
|
300
|
+
or also a failure (i.e. KeyError), default to the empty type constructor for the
|
|
301
|
+
first value of the dict-like style leaf.
|
|
360
302
|
props: The properties to rotate, usually all leaf properties.
|
|
361
303
|
|
|
362
304
|
Returns:
|
|
@@ -369,9 +311,7 @@ def rotate_style(
|
|
|
369
311
|
{'vertex': {'size': 10}}
|
|
370
312
|
"""
|
|
371
313
|
if (index is None) and (key is None):
|
|
372
|
-
raise ValueError(
|
|
373
|
-
"At least one of 'index' or 'key' must be provided to rotate_style."
|
|
374
|
-
)
|
|
314
|
+
raise ValueError("At least one of 'index' or 'key' must be provided to rotate_style.")
|
|
375
315
|
|
|
376
316
|
if props is None:
|
|
377
317
|
props = tuple(prop for prop in style_leaves if prop not in nonrotating_leaves)
|
|
@@ -383,23 +323,46 @@ def rotate_style(
|
|
|
383
323
|
if val is None:
|
|
384
324
|
continue
|
|
385
325
|
# Try integer indexing for ordered types
|
|
386
|
-
if (index is not None) and isinstance(
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
style[prop] =
|
|
326
|
+
if (index is not None) and isinstance(val, (tuple, list, np.ndarray, pd.Index, pd.Series)):
|
|
327
|
+
# NOTE: cannot cast to ndarray because rotation might involve
|
|
328
|
+
# cross-type lists (e.g. ["none", False])
|
|
329
|
+
style[prop] = list(val)[index % len(val)]
|
|
390
330
|
# Try key indexing for unordered, dict-like types
|
|
391
331
|
if (
|
|
392
332
|
(key is not None)
|
|
393
333
|
and (not isinstance(val, (str, tuple, list, np.ndarray)))
|
|
394
334
|
and hasattr(val, "__getitem__")
|
|
395
335
|
):
|
|
396
|
-
# If only a subset of keys is provided,
|
|
397
|
-
#
|
|
398
|
-
# empty strings).
|
|
336
|
+
# If only a subset of keys is provided, try the default value a la
|
|
337
|
+
# defaultdict. If that fails, use an empty constructor
|
|
399
338
|
if key in val:
|
|
400
|
-
|
|
339
|
+
newval = val[key]
|
|
340
|
+
elif key2 is not None and (key2 in val):
|
|
341
|
+
newval = val[key2]
|
|
401
342
|
else:
|
|
402
|
-
|
|
403
|
-
|
|
343
|
+
try:
|
|
344
|
+
newval = val[key]
|
|
345
|
+
except KeyError:
|
|
346
|
+
valtype = type(next(iter(val.values())))
|
|
347
|
+
newval = valtype()
|
|
348
|
+
style[prop] = newval
|
|
404
349
|
|
|
405
350
|
return style
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def add_style(name: str, style: dict[str, Any]) -> None:
|
|
354
|
+
"""Add a style to the default dictionary of styles.
|
|
355
|
+
|
|
356
|
+
Parameters:
|
|
357
|
+
name: The name of the style to add.
|
|
358
|
+
style: A dictionary with the style properties to add.
|
|
359
|
+
"""
|
|
360
|
+
with context(["default", style]):
|
|
361
|
+
styles[name] = get_style()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# Populate style library (default is already there)
|
|
365
|
+
for name, style in style_library.items():
|
|
366
|
+
if name != "default":
|
|
367
|
+
add_style(name, style)
|
|
368
|
+
del name, style
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# These properties are at the bottom of a style dictionary. Values corresponding
|
|
2
|
+
# to these keys are rotatable.
|
|
3
|
+
rotating_leaves = (
|
|
4
|
+
"cmap",
|
|
5
|
+
"color",
|
|
6
|
+
"size",
|
|
7
|
+
"edgecolor",
|
|
8
|
+
"facecolor",
|
|
9
|
+
"linewidth",
|
|
10
|
+
"linestyle",
|
|
11
|
+
"alpha",
|
|
12
|
+
"zorder",
|
|
13
|
+
"tension",
|
|
14
|
+
"offset",
|
|
15
|
+
"rotate",
|
|
16
|
+
"marker",
|
|
17
|
+
"waypoints",
|
|
18
|
+
"horizontalalignment",
|
|
19
|
+
"verticalalignment",
|
|
20
|
+
"boxstyle",
|
|
21
|
+
"hpadding",
|
|
22
|
+
"vpadding",
|
|
23
|
+
"hmargin",
|
|
24
|
+
"vmargin",
|
|
25
|
+
"ports",
|
|
26
|
+
"width",
|
|
27
|
+
"height",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# These properties are also terminal style properties, but they cannot be rotated.
|
|
31
|
+
# This might change in the future as the API improves.
|
|
32
|
+
nonrotating_leaves = (
|
|
33
|
+
"paralleloffset",
|
|
34
|
+
"looptension",
|
|
35
|
+
"loopmaxangle",
|
|
36
|
+
"vertexpadding",
|
|
37
|
+
"extend",
|
|
38
|
+
"deep",
|
|
39
|
+
"angular",
|
|
40
|
+
"curved",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Union of all style leaves (rotating and nonrotating)
|
|
44
|
+
style_leaves = tuple(list(rotating_leaves) + list(nonrotating_leaves))
|