iorec 1.1.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.
iorec/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """IO for .rec meshes in 3D or 2D.
2
+
3
+ Matthieu Perez (2023)
4
+ """
5
+
6
+ from iorec._validation import RecFormatError
7
+ from iorec.rec_2d import load_2d_rec, load_2d_rec_with_metadata, save_2d_rec
8
+ from iorec.rec_3d import load_rec, load_rec_with_metadata, save_rec
9
+ from iorec.rec_appendix import load_rec_appendix, write_rec_appendix
10
+
11
+ __all__ = (
12
+ "RecFormatError",
13
+ "load_2d_rec",
14
+ "load_2d_rec_with_metadata",
15
+ "load_rec",
16
+ "load_rec_appendix",
17
+ "load_rec_with_metadata",
18
+ "save_2d_rec",
19
+ "save_rec",
20
+ "write_rec_appendix",
21
+ )
iorec/_utils.py ADDED
@@ -0,0 +1,139 @@
1
+ """Shared helpers for .rec/.arec files, used by both the 2D and 3D readers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from pathlib import Path
7
+ from typing import Any, Callable, TextIO
8
+
9
+ import numpy as np
10
+ from numpy.typing import NDArray
11
+
12
+ from iorec._validation import RecFormatError, _parse_count
13
+ from iorec.rec_appendix import _iorec_version, write_rec_appendix
14
+
15
+
16
+ def _resolve_rec_path(filename: str | Path) -> Path:
17
+ filename = Path(filename).resolve()
18
+ if not filename.exists():
19
+ error = f"{filename} not found."
20
+ raise FileNotFoundError(error)
21
+ if filename.suffix not in (".rec", ".arec"):
22
+ error = f"{filename} is not a .rec or .arec file."
23
+ raise ValueError(error)
24
+ return filename
25
+
26
+
27
+ def _is_binary_bytes(data: bytes, chunk_size: int = 64) -> bool:
28
+ return b"\x00" in data[:chunk_size]
29
+
30
+
31
+ def _is_binary(file_name: Path, chunk_size: int = 64) -> bool:
32
+ with Path.open(file_name, "rb") as f:
33
+ return _is_binary_bytes(f.read(chunk_size), chunk_size)
34
+
35
+
36
+ def _write_geometry_with_appendix(
37
+ filename: str | Path,
38
+ write_geometry: Callable[[], None],
39
+ metadata: dict[str, Any] | None,
40
+ written_by: str | None = None,
41
+ ) -> None:
42
+ """Write geometry, then an optional trailing metadata appendix.
43
+
44
+ `write_geometry` must write the full geometry file at `filename` (binary or text
45
+ mode); the appendix, if any, is appended afterwards so it stays trailing.
46
+
47
+ `written_by` names the program that produced the mesh. When `None`, iorec's own
48
+ version is stamped instead, unchanged from before this parameter existed. When
49
+ given, iorec's own version is stamped on a separate `writer_library` line instead,
50
+ so both facts survive.
51
+ """
52
+ appendix_bytes = None
53
+ if metadata is not None:
54
+ if written_by is None:
55
+ appendix_bytes = write_rec_appendix(metadata, written_by=_iorec_version())
56
+ else:
57
+ appendix_bytes = write_rec_appendix(
58
+ metadata,
59
+ written_by=written_by,
60
+ writer_library=f"iorec {_iorec_version()}",
61
+ )
62
+
63
+ write_geometry()
64
+
65
+ if appendix_bytes is not None:
66
+ with Path(filename).open("ab") as file:
67
+ file.write(appendix_bytes)
68
+
69
+
70
+ def _read_text_count(
71
+ f: TextIO,
72
+ filename: Path | str,
73
+ what: str,
74
+ *,
75
+ validate: bool,
76
+ ) -> int:
77
+ """Read one count line of a text .rec file."""
78
+ line = f.readline()
79
+ if validate:
80
+ return _parse_count(filename, line, what)
81
+ return int(line.strip())
82
+
83
+
84
+ def _read_text_rows(
85
+ f: TextIO,
86
+ count: int,
87
+ dtype: type[np.float64] | type[np.int64],
88
+ columns: int,
89
+ filename: Path | str,
90
+ what: str,
91
+ *,
92
+ validate: bool,
93
+ ) -> NDArray[Any]:
94
+ """Read `count` rows of a text .rec block, stopping exactly after the last one.
95
+
96
+ Without `validate`, behaviour is unchanged from earlier versions: `np.loadtxt`
97
+ errors propagate as they are, and a short block is returned short. With
98
+ `validate`, parse errors become `RecFormatError` and the caller checks the shape.
99
+ """
100
+ if count <= 0:
101
+ return np.empty((0, columns), dtype=dtype)
102
+ if not validate:
103
+ return np.loadtxt(f, max_rows=count, dtype=dtype, ndmin=2)
104
+ try:
105
+ with warnings.catch_warnings():
106
+ # An empty block is reported by the shape check, with the file name.
107
+ warnings.simplefilter("ignore", UserWarning)
108
+ return np.loadtxt(f, max_rows=count, dtype=dtype, ndmin=2)
109
+ except ValueError as exc:
110
+ error = f"{filename}: invalid {what} block: {exc}"
111
+ raise RecFormatError(error) from exc
112
+
113
+
114
+ def _format_text_rec(
115
+ points: NDArray[Any],
116
+ cells: NDArray[Any],
117
+ labels: NDArray[Any],
118
+ dimension: int,
119
+ ) -> str:
120
+ """Return the text of a .rec file: counts, one row per point and per cell.
121
+
122
+ Each value is written with `repr` (the shortest text that reads back to the same
123
+ float64, or the integer's digits), exactly as formatting each NumPy scalar in an
124
+ f-string did in earlier versions; `tolist()` turns the scalars into Python numbers
125
+ without changing their values. Each block is built with one `%` operation instead
126
+ of a Python loop over rows.
127
+ """
128
+ points = np.asarray(points)[:, :dimension]
129
+ records = np.empty((len(cells), dimension + 2), dtype=object)
130
+ records[:, :dimension] = np.asarray(cells)[:, :dimension]
131
+ records[:, dimension:] = np.asarray(labels)[:, :2]
132
+ point_row = " ".join(["%r"] * dimension) + "\n"
133
+ record_row = " ".join(["%r"] * (dimension + 2)) + "\n"
134
+ return (
135
+ f"{len(points)}\n"
136
+ + (point_row * len(points)) % tuple(points.reshape(-1).tolist())
137
+ + f"{len(records)}\n"
138
+ + (record_row * len(records)) % tuple(records.reshape(-1).tolist())
139
+ )
iorec/_validation.py ADDED
@@ -0,0 +1,153 @@
1
+ """Opt-in structural checks for .rec/.arec files, run by the readers on `validate=True`.
2
+
3
+ Without `validate=True` the readers keep their historical, permissive behaviour: a
4
+ truncated file, a 2D file read as 3D, or out-of-range indices are returned as they come.
5
+ With it, any such problem raises `RecFormatError`, a `ValueError` subclass, naming the
6
+ file and the first offending record.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+ from numpy.typing import NDArray
15
+
16
+ from iorec.rec_appendix import _appendix_marker
17
+
18
+
19
+ class RecFormatError(ValueError):
20
+ """Raised by the readers, with `validate=True`, for a malformed .rec/.arec file."""
21
+
22
+
23
+ def _binary_geometry_size(
24
+ number_of_points: int,
25
+ number_of_cells: int,
26
+ dimension: int,
27
+ record_itemsize: int,
28
+ ) -> int:
29
+ """Return the exact byte size of a binary geometry block (without appendix)."""
30
+ return 8 + 8 * dimension * number_of_points + 8 + record_itemsize * number_of_cells
31
+
32
+
33
+ def _check_binary_room(
34
+ filename: Path | str,
35
+ available: int,
36
+ required: int,
37
+ what: str,
38
+ ) -> None:
39
+ """Raise if fewer than `required` bytes are available for the block `what`."""
40
+ if required > available:
41
+ error = (
42
+ f"{filename}: truncated binary {what} "
43
+ f"({available} bytes available, {required} needed)."
44
+ )
45
+ raise RecFormatError(error)
46
+
47
+
48
+ def _check_binary_tail(filename: Path | str, tail: bytes) -> None:
49
+ """Raise unless the bytes after the geometry are empty or a metadata appendix."""
50
+ if tail and not tail.startswith(_appendix_marker()):
51
+ error = (
52
+ f"{filename}: {len(tail)} unexpected trailing bytes after the binary "
53
+ "geometry (not a rec-appendix block)."
54
+ )
55
+ raise RecFormatError(error)
56
+
57
+
58
+ def _check_text_tail(filename: Path | str, tail: str) -> None:
59
+ """Raise unless the text after the geometry is blank or a metadata appendix."""
60
+ stripped = tail.lstrip()
61
+ if stripped and not stripped.startswith(_appendix_marker().decode("ascii")):
62
+ error = (
63
+ f"{filename}: unexpected content after the last record "
64
+ "(not a rec-appendix block)."
65
+ )
66
+ raise RecFormatError(error)
67
+
68
+
69
+ def _parse_count(filename: Path | str, line: str, what: str) -> int:
70
+ """Parse a text count line strictly."""
71
+ try:
72
+ value = int(line.strip())
73
+ except ValueError as exc:
74
+ error = f"{filename}: invalid {what} count {line.strip()!r}."
75
+ raise RecFormatError(error) from exc
76
+ if value < 0:
77
+ error = f"{filename}: {what} count must be non-negative, got {value}."
78
+ raise RecFormatError(error)
79
+ return value
80
+
81
+
82
+ def _check_mesh(
83
+ filename: Path | str,
84
+ points: NDArray[np.float64],
85
+ cells: NDArray[np.int64],
86
+ labels: NDArray[np.int64],
87
+ dimension: int,
88
+ counts: tuple[int, int],
89
+ ) -> None:
90
+ """Check shapes, coordinates, indices and labels of a parsed mesh.
91
+
92
+ Args:
93
+ filename: used in error messages only.
94
+ points: parsed points, expected shape `(counts[0], dimension)`.
95
+ cells: parsed triangles (3D) or segments (2D), expected shape
96
+ `(counts[1], dimension)`.
97
+ labels: parsed labels, expected shape `(counts[1], 2)`.
98
+ dimension: 3 for triangle meshes, 2 for segment meshes.
99
+ counts: the point and cell counts declared in the file.
100
+
101
+ Raises:
102
+ RecFormatError: on the first violated condition.
103
+ """
104
+ number_of_points, number_of_cells = counts
105
+ cell_name = "triangle" if dimension == 3 else "segment"
106
+ expected = {
107
+ "points": (points, (number_of_points, dimension)),
108
+ f"{cell_name}s": (cells, (number_of_cells, dimension)),
109
+ "labels": (labels, (number_of_cells, 2)),
110
+ }
111
+ for name, (array, shape) in expected.items():
112
+ if array.shape != shape:
113
+ error = (
114
+ f"{filename}: {name} have shape {array.shape}, expected {shape} "
115
+ f"(file declares {number_of_points} points and {number_of_cells} "
116
+ f"{cell_name}s in {dimension}D)."
117
+ )
118
+ raise RecFormatError(error)
119
+
120
+ not_finite = np.flatnonzero(~np.isfinite(points).all(axis=1))
121
+ if not_finite.size:
122
+ error = f"{filename}: point {not_finite[0]} has a non-finite coordinate."
123
+ raise RecFormatError(error)
124
+
125
+ if number_of_cells:
126
+ out_of_range = np.flatnonzero(
127
+ ((cells < 0) | (cells >= number_of_points)).any(axis=1),
128
+ )
129
+ if out_of_range.size:
130
+ first = out_of_range[0]
131
+ error = (
132
+ f"{filename}: {cell_name} {first} has a vertex index outside "
133
+ f"[0, {number_of_points}): {cells[first].tolist()}."
134
+ )
135
+ raise RecFormatError(error)
136
+
137
+ negative = np.flatnonzero((labels < 0).any(axis=1))
138
+ if negative.size:
139
+ first = negative[0]
140
+ error = (
141
+ f"{filename}: {cell_name} {first} has a negative label: "
142
+ f"{labels[first].tolist()}."
143
+ )
144
+ raise RecFormatError(error)
145
+
146
+ same = np.flatnonzero(labels[:, 0] == labels[:, 1])
147
+ if same.size:
148
+ first = same[0]
149
+ error = (
150
+ f"{filename}: {cell_name} {first} separates label "
151
+ f"{labels[first, 0]} from itself."
152
+ )
153
+ raise RecFormatError(error)
iorec/rec_2d.py ADDED
@@ -0,0 +1,390 @@
1
+ """IO for 2D .rec meshes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import struct
7
+ from pathlib import Path
8
+ from typing import Any, TextIO
9
+
10
+ import numpy as np
11
+ from numpy.typing import NDArray
12
+
13
+ from iorec._utils import (
14
+ _format_text_rec,
15
+ _is_binary,
16
+ _is_binary_bytes,
17
+ _read_text_count,
18
+ _read_text_rows,
19
+ _resolve_rec_path,
20
+ _write_geometry_with_appendix,
21
+ )
22
+ from iorec._validation import (
23
+ RecFormatError,
24
+ _binary_geometry_size,
25
+ _check_binary_room,
26
+ _check_binary_tail,
27
+ _check_mesh,
28
+ _check_text_tail,
29
+ )
30
+ from iorec.rec_appendix import _appendix_marker, _split_rec_bytes
31
+
32
+
33
+ def load_2d_rec(
34
+ filename: str | Path,
35
+ *,
36
+ validate: bool = False,
37
+ ) -> tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.int64]]:
38
+ """Read a 2D rec mesh file.
39
+
40
+ Args:
41
+ filename (str | Path): The path to the file on disk.
42
+ validate (bool, optional): Check the file's structure and content, as
43
+ `load_rec` does with `validate=True`, for segments instead of triangles.
44
+ Defaults to False.
45
+
46
+ Raises:
47
+ ValueError: If the path given is not a .rec or .arec file.
48
+ FileNotFoundError: If the file doesn't exist.
49
+ RecFormatError: With `validate=True`, if the file is malformed (a
50
+ `ValueError` subclass).
51
+
52
+ Returns:
53
+ tuple[np.array, np.array, np.array]: (points, segments, labels), with:
54
+ - points: np.array of shape (num_points, 2) (float64)
55
+ - segments: np.array of shape (num_segments, 2) (int64)
56
+ - labels: np.array of shape (num_segments, 2) (int64)
57
+ """
58
+ filename = _resolve_rec_path(filename)
59
+ return _load_2d_rec_file(filename, validate=validate)
60
+
61
+
62
+ def load_2d_rec_with_metadata(
63
+ filename: str | Path,
64
+ *,
65
+ validate: bool = False,
66
+ ) -> tuple[
67
+ NDArray[np.float64],
68
+ NDArray[np.int64],
69
+ NDArray[np.int64],
70
+ dict[str, Any] | None,
71
+ ]:
72
+ """Read a 2D rec mesh file together with its metadata appendix, if any.
73
+
74
+ Reads the file once and splits geometry from metadata in memory -- unlike calling
75
+ `load_2d_rec` and `load_rec_appendix` separately, which would each read the whole
76
+ file on their own.
77
+
78
+ Args:
79
+ filename (str | Path): The path to the file on disk.
80
+ validate (bool, optional): Check the geometry as `load_2d_rec` does with
81
+ `validate=True`. Defaults to False.
82
+
83
+ Raises:
84
+ ValueError: If the path given is not a .rec or .arec file.
85
+ FileNotFoundError: If the file doesn't exist.
86
+ RecFormatError: With `validate=True`, if the file is malformed (a
87
+ `ValueError` subclass).
88
+
89
+ Returns:
90
+ tuple[np.array, np.array, np.array, dict | None]: (points, segments, labels,
91
+ metadata), with points/segments/labels as in `load_2d_rec`, and metadata as in
92
+ `load_rec_appendix` (`None` if the file has no appendix).
93
+ """
94
+ filename = _resolve_rec_path(filename)
95
+ geometry_bytes, metadata = _split_rec_bytes(filename.read_bytes())
96
+
97
+ if _is_binary_bytes(geometry_bytes):
98
+ points, segments, labels = _parse_2d_binary_rec_bytes(
99
+ geometry_bytes,
100
+ validate=validate,
101
+ filename=filename,
102
+ )
103
+ else:
104
+ points, segments, labels = _parse_2d_text_rec_bytes(
105
+ geometry_bytes,
106
+ validate=validate,
107
+ filename=filename,
108
+ )
109
+
110
+ return points, segments, labels, metadata
111
+
112
+
113
+ # On-disk layout of binary files: little-endian whatever the machine. Existing files
114
+ # come from little-endian machines (x86-64, arm64), where this changes nothing; on a
115
+ # big-endian machine, files stay readable and portable instead of byte-swapped.
116
+ _DTYPE_SEGMENTS_AND_LABELS = np.dtype(
117
+ [("segments", "<i8", (2,)), ("labels", "<i4", (2,))],
118
+ )
119
+ _DTYPE_POINTS = np.dtype("<f8")
120
+
121
+
122
+ def _load_2d_rec_file(
123
+ filename: Path,
124
+ *,
125
+ validate: bool = False,
126
+ ) -> tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.int64]]:
127
+ if _is_binary(filename):
128
+ return _load_2d_binary_rec_file(filename, validate=validate)
129
+ else:
130
+ return _load_2d_text_rec_file(filename, validate=validate)
131
+
132
+
133
+ def _load_2d_binary_rec_file(
134
+ filename: Path,
135
+ *,
136
+ validate: bool = False,
137
+ ) -> tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.int64]]:
138
+ size = filename.stat().st_size if validate else 0
139
+ with Path.open(filename, "rb") as f:
140
+ # First we read the number of vertices
141
+ header = f.read(8)
142
+ if validate:
143
+ _check_binary_room(filename, len(header), 8, "point count")
144
+ (number_of_points,) = struct.unpack("<Q", header)
145
+ if validate:
146
+ _check_binary_room(filename, size - 8, 16 * number_of_points + 8, "points")
147
+ # Then we read the vertices : they are in a 2D space
148
+ points = (
149
+ np.fromfile(f, count=2 * number_of_points, dtype=_DTYPE_POINTS)
150
+ .reshape((number_of_points, 2))
151
+ .astype(np.float64, copy=False)
152
+ )
153
+
154
+ # Number of segments
155
+ (number_of_segments,) = struct.unpack("<Q", f.read(8))
156
+ geometry_size = _binary_geometry_size(
157
+ number_of_points,
158
+ number_of_segments,
159
+ 2,
160
+ _DTYPE_SEGMENTS_AND_LABELS.itemsize,
161
+ )
162
+ if validate:
163
+ _check_binary_room(filename, size, geometry_size, "segments")
164
+ # Each line defines a segment and 2 regions labels
165
+ segments_and_labels = np.fromfile(
166
+ f,
167
+ count=number_of_segments,
168
+ dtype=_DTYPE_SEGMENTS_AND_LABELS,
169
+ )
170
+ if validate:
171
+ _check_binary_tail(filename, f.read(len(_appendix_marker())))
172
+
173
+ segments = segments_and_labels["segments"].astype(np.int64)
174
+ labels = segments_and_labels["labels"].astype(np.int64)
175
+ if validate:
176
+ counts = (number_of_points, number_of_segments)
177
+ _check_mesh(filename, points, segments, labels, 2, counts)
178
+ return points, segments, labels
179
+
180
+
181
+ def _load_2d_text_rec_file(
182
+ filename: Path,
183
+ *,
184
+ validate: bool = False,
185
+ ) -> tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.int64]]:
186
+ with Path.open(filename) as f:
187
+ return _read_2d_text_rec(f, filename, validate=validate)
188
+
189
+
190
+ def _read_2d_text_rec(
191
+ f: TextIO,
192
+ filename: Path | str,
193
+ *,
194
+ validate: bool,
195
+ ) -> tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.int64]]:
196
+ """Read 2D text-mode geometry from an open stream (a file or a buffer)."""
197
+ # First we read the number of vertices
198
+ number_of_points = _read_text_count(f, filename, "point", validate=validate)
199
+ # Then we read the vertices : they are in a 2D space
200
+ points = _read_text_rows(
201
+ f,
202
+ number_of_points,
203
+ np.float64,
204
+ 2,
205
+ filename,
206
+ "point",
207
+ validate=validate,
208
+ )
209
+
210
+ # Number of segments
211
+ number_of_segments = _read_text_count(f, filename, "segment", validate=validate)
212
+ # Each line defines a segment and 2 regions labels
213
+ segments_and_labels = _read_text_rows(
214
+ f,
215
+ number_of_segments,
216
+ np.int64,
217
+ 4,
218
+ filename,
219
+ "segment",
220
+ validate=validate,
221
+ )
222
+ segments = segments_and_labels[:, 0:2]
223
+ labels = segments_and_labels[:, 2:4]
224
+
225
+ if validate:
226
+ if segments_and_labels.shape[1:] != (4,):
227
+ error = (
228
+ f"{filename}: segment records have {segments_and_labels.shape[1]} "
229
+ "columns, expected 4 (two vertex indices and two labels)."
230
+ )
231
+ raise RecFormatError(error)
232
+ _check_text_tail(filename, f.read())
233
+ counts = (number_of_points, number_of_segments)
234
+ _check_mesh(filename, points, segments, labels, 2, counts)
235
+ return points, segments, labels
236
+
237
+
238
+ def _parse_2d_binary_rec_bytes(
239
+ data: bytes,
240
+ *,
241
+ validate: bool = False,
242
+ filename: Path | str = "<buffer>",
243
+ ) -> tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.int64]]:
244
+ """Parse geometry from an in-memory buffer.
245
+
246
+ E.g. the geometry half of a buffer already split by
247
+ `iorec.rec_appendix._split_rec_bytes`.
248
+
249
+ Kept separate from `_load_2d_binary_rec_file` (which streams from disk with
250
+ `np.fromfile`) rather than unifying the two: unifying would make plain
251
+ `load_2d_rec` hold the whole file in memory as `bytes` *and* as the parsed array at
252
+ once, which for very large files is a real peak-memory regression that
253
+ `load_2d_rec` shouldn't pay just so `load_2d_rec_with_metadata` can avoid a second
254
+ read.
255
+ """
256
+ if validate:
257
+ _check_binary_room(filename, len(data), 8, "point count")
258
+ (number_of_points,) = struct.unpack_from("<Q", data)
259
+ offset = 8
260
+ points_count = 2 * number_of_points
261
+ if validate:
262
+ _check_binary_room(filename, len(data) - 8, 8 * points_count + 8, "points")
263
+ points = (
264
+ np.frombuffer(data, dtype=_DTYPE_POINTS, count=points_count, offset=offset)
265
+ .reshape((number_of_points, 2))
266
+ .astype(np.float64) # a native-endian copy, not a view on `data`
267
+ )
268
+ offset += points_count * 8
269
+
270
+ (number_of_segments,) = struct.unpack_from("<Q", data, offset)
271
+ offset += 8
272
+ if validate:
273
+ geometry_size = _binary_geometry_size(
274
+ number_of_points,
275
+ number_of_segments,
276
+ 2,
277
+ _DTYPE_SEGMENTS_AND_LABELS.itemsize,
278
+ )
279
+ _check_binary_room(filename, len(data), geometry_size, "segments")
280
+ _check_binary_tail(filename, data[geometry_size:])
281
+ segments_and_labels = np.frombuffer(
282
+ data,
283
+ dtype=_DTYPE_SEGMENTS_AND_LABELS,
284
+ count=number_of_segments,
285
+ offset=offset,
286
+ )
287
+
288
+ segments = segments_and_labels["segments"].astype(np.int64)
289
+ labels = segments_and_labels["labels"].astype(np.int64)
290
+ if validate:
291
+ counts = (number_of_points, number_of_segments)
292
+ _check_mesh(filename, points, segments, labels, 2, counts)
293
+ return points, segments, labels
294
+
295
+
296
+ def _parse_2d_text_rec_bytes(
297
+ data: bytes,
298
+ *,
299
+ validate: bool = False,
300
+ filename: Path | str = "<buffer>",
301
+ ) -> tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.int64]]:
302
+ """Parse geometry from an in-memory buffer.
303
+
304
+ Shares `_read_2d_text_rec` with `_load_2d_text_rec_file`: text parsing reads line
305
+ by line from a stream in both cases, so there is no peak-memory reason to keep two
306
+ copies.
307
+ """
308
+ with io.StringIO(data.decode()) as f:
309
+ return _read_2d_text_rec(f, filename, validate=validate)
310
+
311
+
312
+ def save_2d_rec(
313
+ filename: str | Path,
314
+ points: NDArray[np.float64],
315
+ segments: NDArray[np.int64],
316
+ labels: NDArray[np.int64],
317
+ binary_mode: bool = False,
318
+ *,
319
+ metadata: dict[str, Any] | None = None,
320
+ written_by: str | None = None,
321
+ ) -> None:
322
+ """Save a 2D rec mesh file at filename. Binary or text mode are available.
323
+
324
+ Binary mode is more compact but might not be universally understood.
325
+
326
+ Both modes store every value exactly. Binary mode writes float64 coordinates,
327
+ int64 indices and int32 labels, little-endian on every machine, converting arrays
328
+ of other dtypes. Text mode writes each value with `repr`, the shortest text that
329
+ reads back to the same float64.
330
+
331
+ Args:
332
+ filename (str | Path): where to save the mesh file.
333
+ points (NDArray[np.float64]): Mesh points.
334
+ segments (NDArray[np.int64]): Mesh segments (topology).
335
+ labels (NDArray[np.int64]): Segments multimaterial labels.
336
+ binary_mode (bool, optional): Choose binary mode. Defaults to False.
337
+ metadata (dict[str, Any] | None, optional): appendix fields, e.g.
338
+ `coordinate_frame`, `axis_order`, `spacing`, `spacing_unit`,
339
+ `source_shape`, `crop_offset`. Defaults to `None`, which writes nothing
340
+ extra -- byte-for-byte the same file as before this option existed. When
341
+ supplied, a trailing metadata appendix is appended after the geometry data;
342
+ see `iorec.rec_appendix.write_rec_appendix`.
343
+ written_by (str | None, optional): a version string identifying the program
344
+ that produced the mesh, stamped on the appendix's `written_by` line.
345
+ Defaults to `None`, which stamps iorec's own version there instead --
346
+ unchanged from before this parameter existed. When supplied, iorec's own
347
+ version is stamped on a separate `writer_library` line instead, so both
348
+ facts survive: what produced the mesh, and what serialised it. Has no
349
+ effect if `metadata` is `None`, since no appendix is written in that case.
350
+ """
351
+
352
+ def write_geometry() -> None:
353
+ if binary_mode:
354
+ _save_2d_rec_binary(filename, points, segments, labels)
355
+ else:
356
+ _save_2d_rec_text(filename, points, segments, labels)
357
+
358
+ _write_geometry_with_appendix(filename, write_geometry, metadata, written_by)
359
+
360
+
361
+ def _save_2d_rec_binary(
362
+ filename: str | Path,
363
+ points: NDArray[np.float64],
364
+ segments: NDArray[np.int64],
365
+ labels: NDArray[np.int64],
366
+ ) -> None:
367
+ """Save a 2D rec file at filename. Binary only."""
368
+ records = np.empty(len(segments), dtype=_DTYPE_SEGMENTS_AND_LABELS)
369
+ records["segments"] = segments
370
+ records["labels"] = labels
371
+
372
+ bytes_content = struct.pack("<Q", len(points))
373
+ bytes_content += np.ascontiguousarray(points, dtype=_DTYPE_POINTS).tobytes()
374
+ bytes_content += struct.pack("<Q", len(segments))
375
+ bytes_content += records.tobytes()
376
+ with Path(filename).open("wb") as file:
377
+ file.write(bytes_content)
378
+
379
+
380
+ def _save_2d_rec_text(
381
+ filename: str | Path,
382
+ points: NDArray[np.float64],
383
+ segments: NDArray[np.int64],
384
+ labels: NDArray[np.int64],
385
+ ) -> None:
386
+ """Save a 2D rec file at filename. Text only."""
387
+ content = _format_text_rec(points, segments, labels, 2)
388
+
389
+ with Path(filename).open("w") as file:
390
+ file.write(content)