bsplot 0.0.2__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.
Files changed (89) hide show
  1. bsplot/__init__.py +23 -0
  2. bsplot/anat.py +55 -0
  3. bsplot/animate.py +194 -0
  4. bsplot/bioicons.py +777 -0
  5. bsplot/brain.py +188 -0
  6. bsplot/colors.py +1935 -0
  7. bsplot/data/HCP_avg-SC.txt +379 -0
  8. bsplot/data/MNI152.rh.pial +0 -0
  9. bsplot/data/STN_lh.nii.gz +0 -0
  10. bsplot/data/STN_rh.nii.gz +0 -0
  11. bsplot/data/__init__.py +22 -0
  12. bsplot/data/left_electrode.ply +257270 -0
  13. bsplot/data/parcellations/HCP-MMP1.L.label.gii +442 -0
  14. bsplot/data/parcellations/HCP-MMP1.R.label.gii +442 -0
  15. bsplot/data/parcellations/code/split_hcpmmp1.sh +4 -0
  16. bsplot/data/right_electrode.ply +257270 -0
  17. bsplot/data/surface.py +112 -0
  18. bsplot/data/tpl-MNI152NLin2009b_atlas-hcpmmp1_desc-ordered_dseg.nii.gz +0 -0
  19. bsplot/figure.py +177 -0
  20. bsplot/graph/__init__.py +31 -0
  21. bsplot/graph/edges.py +312 -0
  22. bsplot/graph/flowchart.py +762 -0
  23. bsplot/graph/layout.py +201 -0
  24. bsplot/graph/network.py +974 -0
  25. bsplot/graph/nodes.py +452 -0
  26. bsplot/panels.py +110 -0
  27. bsplot/scientific_color_maps/__init__.py +0 -0
  28. bsplot/scientific_color_maps/_sync_from_zenodo_download.sh +1 -0
  29. bsplot/scientific_color_maps/batlowK.txt +256 -0
  30. bsplot/scientific_color_maps/batlowW.txt +256 -0
  31. bsplot/scientific_color_maps/cyclic/bamO.txt +256 -0
  32. bsplot/scientific_color_maps/cyclic/brocO.txt +256 -0
  33. bsplot/scientific_color_maps/cyclic/corkO.txt +256 -0
  34. bsplot/scientific_color_maps/cyclic/romaO.txt +256 -0
  35. bsplot/scientific_color_maps/cyclic/vikO.txt +256 -0
  36. bsplot/scientific_color_maps/diverging/bam.txt +256 -0
  37. bsplot/scientific_color_maps/diverging/berlin.txt +256 -0
  38. bsplot/scientific_color_maps/diverging/broc.txt +256 -0
  39. bsplot/scientific_color_maps/diverging/cork.txt +256 -0
  40. bsplot/scientific_color_maps/diverging/lisbon.txt +256 -0
  41. bsplot/scientific_color_maps/diverging/managua.txt +256 -0
  42. bsplot/scientific_color_maps/diverging/roma.txt +256 -0
  43. bsplot/scientific_color_maps/diverging/tofino.txt +256 -0
  44. bsplot/scientific_color_maps/diverging/vanimo.txt +256 -0
  45. bsplot/scientific_color_maps/diverging/vik.txt +256 -0
  46. bsplot/scientific_color_maps/multisequential/bukavu.txt +256 -0
  47. bsplot/scientific_color_maps/multisequential/fes.txt +256 -0
  48. bsplot/scientific_color_maps/multisequential/oleron.txt +256 -0
  49. bsplot/scientific_color_maps/naviaW.txt +256 -0
  50. bsplot/scientific_color_maps/sequential/acton.txt +256 -0
  51. bsplot/scientific_color_maps/sequential/bamako.txt +256 -0
  52. bsplot/scientific_color_maps/sequential/batlow.txt +256 -0
  53. bsplot/scientific_color_maps/sequential/bilbao.txt +256 -0
  54. bsplot/scientific_color_maps/sequential/buda.txt +256 -0
  55. bsplot/scientific_color_maps/sequential/davos.txt +256 -0
  56. bsplot/scientific_color_maps/sequential/devon.txt +256 -0
  57. bsplot/scientific_color_maps/sequential/glasgow.txt +256 -0
  58. bsplot/scientific_color_maps/sequential/grayC.txt +256 -0
  59. bsplot/scientific_color_maps/sequential/hawaii.txt +256 -0
  60. bsplot/scientific_color_maps/sequential/imola.txt +256 -0
  61. bsplot/scientific_color_maps/sequential/lajolla.txt +256 -0
  62. bsplot/scientific_color_maps/sequential/lapaz.txt +256 -0
  63. bsplot/scientific_color_maps/sequential/lipari.txt +256 -0
  64. bsplot/scientific_color_maps/sequential/navia.txt +256 -0
  65. bsplot/scientific_color_maps/sequential/nuuk.txt +256 -0
  66. bsplot/scientific_color_maps/sequential/oslo.txt +256 -0
  67. bsplot/scientific_color_maps/sequential/tokyo.txt +256 -0
  68. bsplot/scientific_color_maps/sequential/turku.txt +256 -0
  69. bsplot/streamlines.py +314 -0
  70. bsplot/style.py +791 -0
  71. bsplot/styles/black.mplstyle +78 -0
  72. bsplot/styles/bss.mplstyle +50 -0
  73. bsplot/styles/bwcomp.mplstyle +30 -0
  74. bsplot/styles/nature.mplstyle +57 -0
  75. bsplot/styles/transparent.mplstyle +16 -0
  76. bsplot/styles/tvbo.mplstyle +57 -0
  77. bsplot/surface.py +2370 -0
  78. bsplot/templates.py +270 -0
  79. bsplot/text.py +199 -0
  80. bsplot/text2obj.py +59 -0
  81. bsplot/timeseries.py +10 -0
  82. bsplot/utils.py +13 -0
  83. bsplot/volume.py +958 -0
  84. bsplot-0.0.2.dist-info/METADATA +60 -0
  85. bsplot-0.0.2.dist-info/RECORD +89 -0
  86. bsplot-0.0.2.dist-info/WHEEL +5 -0
  87. bsplot-0.0.2.dist-info/entry_points.txt +2 -0
  88. bsplot-0.0.2.dist-info/licenses/LICENSE +193 -0
  89. bsplot-0.0.2.dist-info/top_level.txt +1 -0
bsplot/templates.py ADDED
@@ -0,0 +1,270 @@
1
+ """Neuroimaging template volumes, downloaded on first access to ~/.bsplot/templates/."""
2
+
3
+ import urllib.request
4
+ from pathlib import Path
5
+
6
+ import templateflow.api as tflow
7
+
8
+ from bsplot import datadir
9
+
10
+ _CACHE_DIR = Path.home() / ".bsplot" / "templates"
11
+ _LEGACY_DIR = Path(datadir)
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Core infrastructure
16
+ # ---------------------------------------------------------------------------
17
+
18
+
19
+ def _download(url: str, dest: Path, label: str = "") -> None:
20
+ """Download *url* → *dest* with a progress bar."""
21
+ dest.parent.mkdir(parents=True, exist_ok=True)
22
+ tmp = dest.with_suffix(".part")
23
+ print(f"Downloading {label} from\n {url}")
24
+ try:
25
+ resp = urllib.request.urlopen(url)
26
+ total = int(resp.headers.get("Content-Length", 0))
27
+ done = 0
28
+ with open(tmp, "wb") as f:
29
+ while chunk := resp.read(1 << 20):
30
+ f.write(chunk)
31
+ done += len(chunk)
32
+ if total:
33
+ print(f"\r {done/1e6:.1f}/{total/1e6:.1f} MB ({done/total*100:.0f}%)",
34
+ end="", flush=True)
35
+ print()
36
+ tmp.rename(dest)
37
+ except Exception:
38
+ tmp.unlink(missing_ok=True)
39
+ raise
40
+
41
+
42
+ class Template:
43
+ """A remote neuroimaging template with multiple resolutions.
44
+
45
+ Acts as an ``os.PathLike`` for the default resolution (lazy download),
46
+ so it can be passed directly to ``nibabel.load()`` or ``plot_slice()``.
47
+
48
+ Parameters
49
+ ----------
50
+ name : str
51
+ Human-readable name shown in progress messages and repr.
52
+ files : dict[str, tuple[str, str]]
53
+ ``{resolution: (filename, url)}`` for every available variant.
54
+ default : str
55
+ Key into *files* used when the template is accessed as a path.
56
+ migrate_legacy : bool
57
+ If True, check ``bsplot/data/`` for a bundled copy before downloading.
58
+ postprocess : callable or None
59
+ ``f(raw_path) -> derived_path``. Called once after the first
60
+ download to create a derived file (e.g. background-masked).
61
+ The *derived* path is what ``get()`` returns.
62
+ """
63
+
64
+ def __init__(self, name: str, files: dict, default: str, *,
65
+ migrate_legacy: bool = False, postprocess=None):
66
+ self._name = name
67
+ self._files = files
68
+ self._default = default
69
+ self._migrate = migrate_legacy
70
+ self._postprocess = postprocess
71
+ self._resolved: dict[str, Path] = {}
72
+
73
+ # -- public API ---------------------------------------------------------
74
+
75
+ @property
76
+ def resolutions(self) -> list[str]:
77
+ """Available resolution strings."""
78
+ return list(self._files)
79
+
80
+ def get(self, resolution: str | None = None) -> Path:
81
+ """Return the path for *resolution*, downloading if necessary.
82
+
83
+ If a ``postprocess`` function was provided, returns the derived
84
+ (post-processed) path. Use :meth:`get_raw` for the original.
85
+ """
86
+ res = resolution or self._default
87
+ if res in self._resolved:
88
+ return self._resolved[res]
89
+ raw = self._get_raw(res)
90
+
91
+ # Post-processing: create a derived file once, return it instead
92
+ if self._postprocess is not None:
93
+ derived = self._postprocess(raw)
94
+ self._resolved[res] = derived
95
+ return derived
96
+
97
+ self._resolved[res] = raw
98
+ return raw
99
+
100
+ def get_raw(self, resolution: str | None = None) -> Path:
101
+ """Return the raw (unprocessed) path, bypassing any postprocess."""
102
+ return self._get_raw(resolution or self._default)
103
+
104
+ def _get_raw(self, res: str) -> Path:
105
+ if res not in self._files:
106
+ raise ValueError(
107
+ f"Unknown resolution '{res}' for {self._name}. "
108
+ f"Choose from {self.resolutions}."
109
+ )
110
+ fname, url = self._files[res]
111
+ path = _CACHE_DIR / fname
112
+
113
+ if not path.exists():
114
+ legacy = _LEGACY_DIR / fname
115
+ if self._migrate and legacy.exists():
116
+ path.parent.mkdir(parents=True, exist_ok=True)
117
+ legacy.rename(path)
118
+ else:
119
+ _download(url, path, label=f"{self._name} ({res})")
120
+
121
+ return path
122
+
123
+ # -- os.PathLike (default resolution, lazy) -----------------------------
124
+
125
+ def __fspath__(self) -> str:
126
+ return str(self.get())
127
+
128
+ def __str__(self) -> str:
129
+ return str(self.get())
130
+
131
+ def __repr__(self) -> str:
132
+ return f"Template({self._name!r}, default={self._default!r})"
133
+
134
+ def __truediv__(self, other):
135
+ return self.get() / other
136
+
137
+
138
+ class TFlowTemplate:
139
+ """A TemplateFlow-backed template, lazily resolved on first access.
140
+
141
+ Acts as ``os.PathLike`` so it can be passed directly to ``plot_slice()``
142
+ or ``nibabel.load()``.
143
+
144
+ Parameters
145
+ ----------
146
+ name : str
147
+ Human-readable name shown in repr.
148
+ template : str
149
+ TemplateFlow template ID, e.g. ``"MNI152NLin2009cAsym"``.
150
+ suffix : str
151
+ BIDS suffix, e.g. ``"T1w"``, ``"T2w"``, ``"mask"``.
152
+ desc : str or None
153
+ BIDS desc entity, e.g. ``"brain"``.
154
+ resolution : int
155
+ Resolution index (TemplateFlow convention).
156
+ """
157
+
158
+ def __init__(self, name: str, template: str, suffix: str,
159
+ desc: str | None = None, resolution: int = 1):
160
+ self._name = name
161
+ self._template = template
162
+ self._suffix = suffix
163
+ self._desc = desc
164
+ self._resolution = resolution
165
+ self._path: Path | None = None
166
+
167
+ def get(self, resolution: int | None = None) -> Path:
168
+ """Return the path, downloading via TemplateFlow if necessary."""
169
+ res = resolution or self._resolution
170
+ # Always re-resolve if a different resolution is requested
171
+ if self._path is not None and res == self._resolution:
172
+ return self._path
173
+ kwargs = dict(
174
+ resolution=res, suffix=self._suffix, extension=".nii.gz"
175
+ )
176
+ if self._desc is not None:
177
+ kwargs["desc"] = self._desc
178
+ result = tflow.get(self._template, **kwargs)
179
+ if isinstance(result, list):
180
+ result = result[0]
181
+ path = Path(result)
182
+ if res == self._resolution:
183
+ self._path = path
184
+ return path
185
+
186
+ def __fspath__(self) -> str:
187
+ return str(self.get())
188
+
189
+ def __str__(self) -> str:
190
+ return str(self.get())
191
+
192
+ def __repr__(self) -> str:
193
+ parts = [self._template, self._suffix]
194
+ if self._desc:
195
+ parts.append(f"desc-{self._desc}")
196
+ return f"TFlowTemplate({', '.join(parts)})"
197
+
198
+ def __truediv__(self, other):
199
+ return self.get() / other
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # Template definitions
204
+ # ---------------------------------------------------------------------------
205
+
206
+ _BB = "https://ftp.bigbrainproject.org/bigbrain-ftp/BigBrainRelease.2015/3D_Volumes/MNI-ICBM152_Space/nii"
207
+ _DD = "https://datadryad.org/downloads/file_stream"
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # MNI 152 templates (via TemplateFlow — handles its own caching)
211
+ # ---------------------------------------------------------------------------
212
+
213
+ # MNI152NLin2009cAsym (ICBM 2009c, asymmetric — most common)
214
+ mni09c = TFlowTemplate("MNI152 2009c T1w brain", "MNI152NLin2009cAsym", "T1w", desc="brain")
215
+ mni09c_T1w = mni09c # alias
216
+ mni09c_T2w = TFlowTemplate("MNI152 2009c T2w", "MNI152NLin2009cAsym", "T2w")
217
+ mni09c_mask = TFlowTemplate("MNI152 2009c brain mask", "MNI152NLin2009cAsym", "mask", desc="brain")
218
+
219
+ # MNI152NLin2009cSym (ICBM 2009c, symmetric)
220
+ mni09c_sym = TFlowTemplate("MNI152 2009c sym T1w brain", "MNI152NLin2009cSym", "T1w", desc="brain")
221
+ mni09c_sym_T2w = TFlowTemplate("MNI152 2009c sym T2w", "MNI152NLin2009cSym", "T2w")
222
+
223
+ # MNI152NLin6Asym (6th generation — FSL/SPM default)
224
+ mni6 = TFlowTemplate("MNI152 6th gen T1w brain", "MNI152NLin6Asym", "T1w", desc="brain")
225
+ mni6_T1w = mni6 # alias
226
+ mni6_T2w = TFlowTemplate("MNI152 6th gen T2w", "MNI152NLin6Asym", "T2w")
227
+ mni6_mask = TFlowTemplate("MNI152 6th gen brain mask","MNI152NLin6Asym", "mask", desc="brain")
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # BigBrain histological volume (8-bit, MNI ICBM 152 2009b sym)
231
+ # Amunts et al. (2013) Science 340(6139):1472–1475
232
+ # The raw volume has the background at max intensity — postprocess replaces
233
+ # max → NaN so the background is transparent when plotting.
234
+
235
+ def _bigbrain_mask_background(raw_path: Path) -> Path:
236
+ """Replace background (max intensity) with NaN; cache result on disk."""
237
+ import nibabel as nib
238
+ import numpy as np
239
+
240
+ masked_path = raw_path.with_name(raw_path.name.replace(".nii", "_masked.nii"))
241
+ if masked_path.exists():
242
+ return masked_path
243
+ print(f" Masking BigBrain background (max → NaN) …")
244
+ img = nib.load(raw_path)
245
+ data = img.get_fdata()
246
+ out = np.where(data == data.max(), np.nan, data).astype(np.float32)
247
+ new_img = nib.Nifti1Image(out, img.affine)
248
+ new_img.header.set_data_dtype(np.float32)
249
+ nib.save(new_img, masked_path)
250
+ return masked_path
251
+
252
+
253
+ bigbrain = Template("BigBrain", {
254
+ "100um": ("full8_100um_2009b_sym.nii.gz", f"{_BB}/full8_100um_2009b_sym.nii.gz"), # 1.1 GB
255
+ "200um": ("full8_200um_2009b_sym.nii.gz", f"{_BB}/full8_200um_2009b_sym.nii.gz"), # 149 MB
256
+ "300um": ("full8_300um_2009b_sym.nii.gz", f"{_BB}/full8_300um_2009b_sym.nii.gz"), # 46 MB
257
+ "400um": ("full8_400um_2009b_sym.nii.gz", f"{_BB}/full8_400um_2009b_sym.nii.gz"), # 20 MB
258
+ }, default="300um", postprocess=_bigbrain_mask_background)
259
+
260
+ # Synthesized FLASH25 ex-vivo 7T MRI in MNI space (CC0)
261
+ # Edlow et al. (2019) Scientific Data 6:244
262
+ flash25 = Template("FLASH25", {
263
+ "100um": ("Synthesized_FLASH25_in_MNI_v2_100um.nii.gz", f"{_DD}/182487"), # 4.7 GB
264
+ "200um": ("Synthesized_FLASH25_in_MNI_v2_200um.nii.gz", f"{_DD}/182488"), # 1.15 GB
265
+ "500um": ("Synthesized_FLASH25_in_MNI_v2_500um.nii.gz", f"{_DD}/182489"), # 74 MB
266
+ }, default="500um", migrate_legacy=True)
267
+
268
+ # Convenience aliases for backwards compatibility
269
+ get_bigbrain = bigbrain.get
270
+ get_flash25 = flash25.get
bsplot/text.py ADDED
@@ -0,0 +1,199 @@
1
+ import black
2
+ import matplotlib.pyplot as plt
3
+ from pygments.lexers import PythonLexer, get_lexer_by_name
4
+ from pygments.styles import get_style_by_name
5
+ import textwrap
6
+
7
+
8
+ def _wrap_lines(text: str, line_length: int) -> str:
9
+ """Wrap long lines in text while preserving structure for non-Python code."""
10
+ lines = text.splitlines()
11
+ wrapped_lines = []
12
+
13
+ for line in lines:
14
+ if len(line) <= line_length:
15
+ wrapped_lines.append(line)
16
+ else:
17
+ wrapped = textwrap.fill(
18
+ line,
19
+ width=line_length,
20
+ break_long_words=True,
21
+ break_on_hyphens=True,
22
+ expand_tabs=False,
23
+ replace_whitespace=False,
24
+ )
25
+ wrapped_lines.extend(wrapped.splitlines())
26
+
27
+ return "\n".join(wrapped_lines)
28
+
29
+
30
+ def draw_code(
31
+ ax,
32
+ code: str,
33
+ fontsize=7,
34
+ bg=None,
35
+ style="monokai",
36
+ language: str = "python",
37
+ line_length=60,
38
+ line_height=None,
39
+ char_width=0.015,
40
+ ):
41
+ """Render syntax-highlighted code (Python by default) inside a matplotlib axis.
42
+
43
+ Parameters
44
+ ----------
45
+ ax : matplotlib.axes.Axes
46
+ Axis to draw on.
47
+ code : str
48
+ Source code string to render.
49
+ fontsize : int, optional
50
+ Font size for text (default 7).
51
+ bg : str | None, optional
52
+ Background face color. If None, uses the selected Pygments style's
53
+ background color; if unavailable, falls back to the current Matplotlib
54
+ axes facecolor (rcParams["axes.facecolor"]).
55
+ style : str, optional
56
+ Pygments style name (default 'monokai'). See ``pygments.styles.STYLE_MAP``.
57
+ language : str, optional
58
+ Lexer language (``python``, ``yaml``, ``json``, ``bash``, etc.). If ``python``
59
+ the code is first formatted with *black*. For other languages the string is
60
+ used as-is (black is skipped to avoid syntax errors).
61
+ line_length : int, optional
62
+ Maximum line length for formatting (default 60). For Python, this is passed
63
+ to black formatter. For other languages, long lines are wrapped at word
64
+ boundaries when possible.
65
+ line_height : float, optional
66
+ Fixed line height as fraction of axis height (each row's vertical advance).
67
+ If None, the available space between equal top/bottom padding is divided
68
+ equally over the number of rendered lines.
69
+ char_width : float, optional
70
+ Approximate width advance per character (fraction of axis width). If None, defaults to 0.015.
71
+
72
+ Notes
73
+ -----
74
+ - Backwards compatible: existing calls without new parameters behave identically.
75
+ - For long code blocks, consider reducing ``fontsize`` or adjusting ``line_length``.
76
+ """
77
+ # Setup
78
+ ax.set_xlim(0, 1)
79
+ ax.set_ylim(0, 1)
80
+
81
+ # Resolve Pygments style early (avoid shadowing the style parameter)
82
+ style_name = style
83
+ pyg_style = get_style_by_name(style_name)
84
+
85
+ # Resolve background color preference: Pygments style -> Matplotlib rcParams -> fallback
86
+ if bg is None:
87
+ bg_color = getattr(pyg_style, "background_color", None)
88
+ if not bg_color:
89
+ bg_color = plt.rcParams.get("axes.facecolor", "#ffffff")
90
+ else:
91
+ bg_color = bg
92
+
93
+ ax.add_patch(
94
+ plt.Rectangle((0, 0), 1, 1, facecolor=bg_color, edgecolor=None, linewidth=0.5)
95
+ )
96
+
97
+ # Select lexer & format code
98
+ if language.lower() == "python":
99
+ formatted_code = black.format_str(
100
+ code, mode=black.Mode(line_length=line_length)
101
+ )
102
+ lexer = PythonLexer()
103
+ else:
104
+ # Apply line wrapping for non-Python languages
105
+ formatted_code = _wrap_lines(code, line_length)
106
+ lexer = get_lexer_by_name(language.lower())
107
+
108
+ tokens = list(lexer.get_tokens(formatted_code))
109
+
110
+ # Use built-in Pygments style (already resolved as pyg_style)
111
+
112
+ def get_token_color(token_type):
113
+ """Get hex color for a token using the active Pygments style.
114
+
115
+ Uses normalized style attributes (pyg_style._styles) and walks up the
116
+ token hierarchy. Returns a hex color string or None if unspecified.
117
+ """
118
+ from pygments.token import Token as PygToken
119
+
120
+ def normalize(col: str | None) -> str | None:
121
+ if not col:
122
+ return None
123
+ return col if col.startswith("#") else f"#{col}"
124
+
125
+ cur = token_type
126
+ while cur is not None:
127
+ attrs = pyg_style._styles.get(cur)
128
+ if attrs:
129
+ col = normalize(attrs[0]) # first field is the foreground color
130
+ if col:
131
+ return col
132
+ cur = cur.parent
133
+
134
+ # Try the generic Token entry
135
+ attrs = pyg_style._styles.get(PygToken)
136
+ if attrs:
137
+ col = normalize(attrs[0])
138
+ if col:
139
+ return col
140
+
141
+ return None
142
+
143
+ # Build lines from tokens
144
+ def build_lines(tokens):
145
+ lines, current_line = [], []
146
+ for token_type, value in tokens:
147
+ if "\n" not in value:
148
+ current_line.append((token_type, value))
149
+ else:
150
+ parts = value.split("\n")
151
+ if parts[0]:
152
+ current_line.append((token_type, parts[0]))
153
+ lines.append(current_line)
154
+
155
+ for part in parts[1:-1]:
156
+ lines.append([(token_type, part)] if part else [])
157
+
158
+ current_line = [(token_type, parts[-1])] if parts[-1] else []
159
+
160
+ if current_line:
161
+ lines.append(current_line)
162
+ return lines
163
+
164
+ # Render with adaptive spacing
165
+ lines = build_lines(tokens)
166
+ if not lines:
167
+ return
168
+
169
+ padding = 0.02
170
+ x_start = padding
171
+ y_start = 1 - padding
172
+
173
+ if char_width is None:
174
+ char_width = 0.015
175
+
176
+ if line_height is None:
177
+ usable_height = max(0.0, 1 - 2 * padding)
178
+ row_height = usable_height / len(lines) if len(lines) else usable_height
179
+ else:
180
+ row_height = line_height
181
+
182
+ for i, line_tokens in enumerate(lines):
183
+ y_pos = y_start - i * row_height
184
+ x_pos = x_start
185
+ for token_type, value in line_tokens:
186
+ # Preserve indentation & spaces: still render empty strings for alignment
187
+ if value:
188
+ color = get_token_color(token_type)
189
+ ax.text(
190
+ x_pos,
191
+ y_pos,
192
+ value,
193
+ fontfamily="monospace",
194
+ fontsize=fontsize,
195
+ color=color,
196
+ va="top",
197
+ ha="left",
198
+ )
199
+ x_pos += len(value) * char_width
bsplot/text2obj.py ADDED
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import sys
4
+ from os.path import join, isfile
5
+
6
+ def main(base_path):
7
+ """
8
+ Converts vertex, normal, and triangle data into an OBJ file format.
9
+
10
+ Parameters:
11
+ base_path (str): The base directory containing vertices.txt, normals.txt, and triangles.txt.
12
+
13
+ Outputs:
14
+ surface.obj file in the specified directory.
15
+ """
16
+ vertices_file = join(base_path, "vertices.txt")
17
+ normals_file = join(base_path, "normals.txt")
18
+ triangles_file = join(base_path, "triangles.txt")
19
+ output_file = join(base_path, "surface.obj")
20
+
21
+ # Check if normals file exists
22
+ normals_exist = isfile(normals_file)
23
+
24
+ # Reading vertices and triangles
25
+ with open(vertices_file, "r") as vf, open(triangles_file, "r") as tf:
26
+ vertices = [line.strip().split() for line in vf]
27
+ triangles = [line.strip().split() for line in tf]
28
+
29
+ # Read normals only if they exist
30
+ if normals_exist:
31
+ with open(normals_file, "r") as nf:
32
+ normals = [line.strip().split() for line in nf]
33
+
34
+ # Writing to OBJ format
35
+ with open(output_file, "w") as of:
36
+ # Write vertices
37
+ for v in vertices:
38
+ of.write(f"v {' '.join(v)}\n")
39
+
40
+ # Write normals if available
41
+ if normals_exist:
42
+ for n in normals:
43
+ of.write(f"vn {' '.join(n)}\n")
44
+
45
+ # Write faces with or without normals
46
+ for t in triangles:
47
+ indices = [str(int(idx) + 1) for idx in t]
48
+ if normals_exist:
49
+ of.write(
50
+ f"f {indices[0]}//{indices[0]} {indices[1]}//{indices[1]} {indices[2]}//{indices[2]}\n"
51
+ )
52
+ else:
53
+ of.write(f"f {indices[0]} {indices[1]} {indices[2]}\n")
54
+
55
+ if __name__ == "__main__":
56
+ if len(sys.argv) != 2:
57
+ print("Usage: python text2obj.py <base_path>")
58
+ else:
59
+ main(sys.argv[1])
bsplot/timeseries.py ADDED
@@ -0,0 +1,10 @@
1
+ import matplotlib.pyplot as plt
2
+
3
+
4
+ def plot_ts(ax, time, data, label, data_label="X", time_unit="ms", box_aspect=1):
5
+ ax.plot(time, data, label=label)
6
+ ax.set_xlabel(f"Time in {time_unit}")
7
+ ax.set_ylabel(data_label)
8
+
9
+ ax.set_box_aspect(box_aspect)
10
+
bsplot/utils.py ADDED
@@ -0,0 +1,13 @@
1
+ """Utility helpers for plotting and figure management."""
2
+
3
+
4
+ def autoscale_axis(ax):
5
+ """Recompute axis limits after adding artists like LineCollection.
6
+
7
+ Useful when plotting line collections, which do not always update limits
8
+ automatically. Call this only when you want the axis limits to be reset
9
+ from current artists.
10
+ """
11
+ ax.autoscale(enable=True)
12
+ ax.autoscale_view()
13
+ return ax