import datetime
import enum
import io
import os
import struct
import zipfile
from collections.abc import Generator
from typing import (
IO,
TYPE_CHECKING,
Any,
Literal as L, # noqa: N817
cast,
)
from xml.etree import ElementTree as ET
import numpy as np
# typing.assert_never requires Python 3.11; typing_extensions is a
# guaranteed runtime dependency via python-utils.
from typing_extensions import assert_never
from . import (
__about__ as metadata,
base,
)
from ._compat import (
ascii_read as _ascii_read,
ascii_write as _ascii_write,
)
from .utils import b
if TYPE_CHECKING:
from typing import Protocol, type_check_only
from _typeshed import SupportsWrite
from typing_extensions import Buffer, Self, Writer
from .base import _data_1d
_Name = bytes | str
@type_check_only
class _StatefulWriter(Writer[Buffer], Protocol):
def tell(self) -> int: ...
def seekable(self) -> bool: ...
[docs]
class Mode(enum.IntEnum):
"""STL file format mode for loading and saving.
Use :attr:`AUTOMATIC` for auto-detection (default),
:attr:`ASCII` to force ASCII format, or
:attr:`BINARY` to force binary format.
"""
#: Automatically detect whether the output is a TTY, if so, write ASCII
#: otherwise write BINARY
AUTOMATIC = 0
#: Force writing ASCII
ASCII = 1
#: Force writing BINARY
BINARY = 2
# For backwards compatibility, leave the original references
AUTOMATIC: L[Mode.AUTOMATIC] = Mode.AUTOMATIC
ASCII: L[Mode.ASCII] = Mode.ASCII
BINARY: L[Mode.BINARY] = Mode.BINARY
#: Amount of bytes to read while using buffered reading
BUFFER_SIZE: int = 4096
#: The amount of bytes in the header field
HEADER_SIZE: int = 80
#: The amount of bytes in the count field
COUNT_SIZE: int = 4
#: The maximum amount of triangles we can read from binary files
MAX_COUNT: float = 1e8
#: The header format, can be safely monkeypatched. Limited to 80 characters
HEADER_FORMAT: str = '{package_name} ({version}) {now} {name}'
def _ensure_seekable(fh: IO[Any], mode: Mode = AUTOMATIC) -> IO[Any]:
"""Return a seekable handle, buffering pipe-like streams fully.
Format auto-detection needs to rewind and the binary loader needs
seek/tell, which streams such as stdin do not support. Explicit
ASCII parsing streams in bounded chunks and is left untouched to
avoid buffering large piped files in memory.
"""
if mode is ASCII:
return fh
# Duck-typed file-likes may not implement seekable() at all;
# treat those like pipes and buffer them.
seekable = getattr(fh, 'seekable', None)
if seekable is not None and seekable():
return fh
return io.BytesIO(b(fh.read()))
def _3mf_index(
attributes: dict[str, int],
key: str,
vertices: 'list[list[float]]',
) -> int:
"""Validate and return a triangle vertex index from a 3MF file."""
try:
index: int = attributes[key]
except KeyError as exception:
raise ValueError(
f'Triangle is missing attribute {key!r}'
) from exception
if not 0 <= index < len(vertices):
raise ValueError(
f'Triangle vertex index {key}={index} is out of range '
f'(0..{len(vertices) - 1})'
)
return index
class BaseStl(base.BaseMesh):
@classmethod
def load(
cls,
fh: IO[Any],
mode: 'Mode | int' = AUTOMATIC,
speedups: bool = True,
) -> 'tuple[bytes, _data_1d] | Any':
"""Load mesh data from an open STL file handle.
Auto-detects binary vs ASCII format unless
``mode`` is explicitly set.
Args:
fh: Open binary file handle.
mode: Force a specific format or use
:attr:`Mode.AUTOMATIC` (default). Plain
ints are accepted and converted to
:class:`Mode`.
speedups: Use Cython speedups for ASCII
parsing. Defaults to True.
Returns:
A (name, data) tuple, or None if the file
is empty.
Raises:
ValueError: If ``mode`` is not a valid
:class:`Mode` value.
"""
mode = Mode(mode)
header = fh.read(HEADER_SIZE)
if not header:
return None
if isinstance(header, str): # pragma: no branch
header = b(header)
if mode is AUTOMATIC:
if header.lstrip().lower().startswith(b'solid'):
try:
name, data = cls._load_ascii(fh, header, speedups=speedups)
except RuntimeError as exception:
(recoverable, _) = exception.args
# If we didn't read beyond the header the stream is still
# readable through the binary reader
if recoverable:
name, data = cls._load_binary(
fh, header, check_size=False
)
else:
# Apparently we've read beyond the header. Let's try
# seeking :)
# Note that this fails when reading from stdin, we
# can't recover from that.
fh.seek(HEADER_SIZE)
# Since we know this is a seekable file now and we're
# not 100% certain it's binary, check the size while
# reading
name, data = cls._load_binary(
fh, header, check_size=True
)
else:
name, data = cls._load_binary(fh, header)
elif mode is ASCII:
name, data = cls._load_ascii(fh, header, speedups=speedups)
elif mode is BINARY:
name, data = cls._load_binary(fh, header)
else: # pragma: no cover - exhaustiveness guard for new modes
assert_never(mode)
return name, data
@classmethod
def _load_binary(
cls,
fh: IO[bytes],
header: bytes,
check_size: bool = False,
) -> tuple[bytes, '_data_1d']:
# Read the triangle count
count_data = fh.read(COUNT_SIZE)
if len(count_data) != COUNT_SIZE:
count = 0
else:
# The triangle count is an unsigned 32-bit integer per the
# STL specification; '<i' would turn large counts negative
# and slip past the size check below.
(count,) = struct.unpack('<I', b(count_data))
assert count < MAX_COUNT, (
f'File too large, got {count} triangles which '
f'exceeds the maximum of {MAX_COUNT}'
)
if check_size:
try:
# Check the size of the file
fh.seek(0, os.SEEK_END)
raw_size = fh.tell() - HEADER_SIZE - COUNT_SIZE
expected_count = int(raw_size / cls.dtype.itemsize)
assert expected_count == count, (
f'Expected {expected_count} vectors but header indicates '
f'{count}'
)
fh.seek(HEADER_SIZE + COUNT_SIZE)
except OSError: # pragma: no cover
pass
name = header.strip()
# Read the rest of the binary data
try:
return name, np.fromfile(fh, dtype=cls.dtype, count=count)
except io.UnsupportedOperation:
data = np.frombuffer(fh.read(), dtype=cls.dtype, count=count)
# Copy to make the buffer writable
return name, data.copy()
@staticmethod
def _ascii_reader( # noqa: C901
fh: IO[bytes], header: bytes
) -> Generator[
'bytes | tuple[list[float], tuple[bytes, bytes, bytes], int]',
None,
None,
]:
if b'\n' in header:
recoverable = [True]
else:
recoverable = [False]
header += b(fh.read(BUFFER_SIZE))
lines = b(header).split(b'\n')
def get(prefix: '_Name' = '') -> 'bytes | list[float]':
prefix = b(prefix).lower()
# Skip blank lines with a loop; recursing per line would
# exhaust the stack on files with long blank-line runs.
line = b('')
raw_line = b('')
while not line:
if lines:
raw_line = lines.pop(0)
else:
raise RuntimeError(
recoverable[0], 'Unable to find more lines'
)
if not lines:
recoverable[0] = False
# Read more lines and make sure we prepend any old data
lines[:] = b(fh.read(BUFFER_SIZE)).split(b'\n')
raw_line += lines.pop(0)
raw_line = raw_line.strip()
line = raw_line.lower()
if prefix:
if line.startswith(prefix):
values = line.replace(prefix, b(''), 1).strip().split()
elif line.startswith((b('endsolid'), b('end solid'))):
# go back to the beginning of new solid part
size_unprocessedlines = (
sum(len(line) + 1 for line in lines) - 1
)
if size_unprocessedlines > 0:
position = fh.tell()
fh.seek(position - size_unprocessedlines)
raise StopIteration()
else:
raise RuntimeError(
recoverable[0],
f'{line!r} should start with {prefix!r}',
)
if len(values) == 3:
return [float(v) for v in values]
else: # pragma: no cover
raise RuntimeError(
recoverable[0], f'Incorrect value {line!r}'
)
else:
return b(raw_line)
line = get()
if not lines:
raise RuntimeError(
recoverable[0], 'No lines found, impossible to read'
)
# Yield the name
yield cast('bytes', line[5:]).strip()
while True:
# Read from the header lines first, until that point we can recover
# and go to the binary option. After that we cannot due to
# unseekable files such as sys.stdin
#
# Numpy doesn't support any non-file types so wrapping with a
# buffer and/or StringIO does not work.
try:
normals = cast('list[float]', get('facet normal'))
assert cast('bytes', get()).lower() == b('outer loop')
v0 = cast('bytes', get('vertex'))
v1 = cast('bytes', get('vertex'))
v2 = cast('bytes', get('vertex'))
assert cast('bytes', get()).lower() == b('endloop')
assert cast('bytes', get()).lower() == b('endfacet')
attrs = 0
yield (normals, (v0, v1, v2), attrs)
except AssertionError as e: # pragma: no cover # noqa: PERF203
raise RuntimeError(recoverable[0], e) from e
except StopIteration:
return
@classmethod
def _load_ascii(
cls,
fh: IO[bytes],
header: bytes,
speedups: bool = True,
) -> tuple[bytes, '_data_1d']:
# Speedups does not support non file-based streams. Pipes such
# as stdin have a file descriptor but cannot seek, which the C
# reader requires as well. Duck-typed file-likes may implement
# neither method, hence the AttributeError.
try:
fh.fileno()
speedups = speedups and fh.seekable()
except (AttributeError, io.UnsupportedOperation):
speedups = False
if _ascii_read is not None and speedups:
return _ascii_read(fh, header)
else:
iterator = cls._ascii_reader(fh, header)
name = cast('bytes', next(iterator))
return name, np.fromiter(iterator, dtype=cls.dtype)
def save( # noqa: C901
self,
filename: '_Name',
fh: 'IO[bytes] | None' = None,
mode: 'Mode | int' = AUTOMATIC,
update_normals: bool = True,
) -> None:
"""Save the mesh to an STL file.
If mode is :attr:`Mode.AUTOMATIC`, writes binary
unless the output is a TTY.
Args:
filename: Output file path. Required even when
``fh`` is provided (used for STL header).
fh: Optional pre-opened binary file handle.
mode: Output format. Defaults to
:attr:`Mode.AUTOMATIC`.
update_normals: Whether to recalculate normals
before saving. Defaults to True.
Raises:
TypeError: If ``fh`` is a text-mode handle.
ValueError: If ``mode`` is not a valid
:class:`Mode` value.
Example:
>>> import numpy as np
>>> from stl import mesh
>>> data = np.zeros(1, dtype=mesh.Mesh.dtype)
>>> data['vectors'][0] = [[0, 0, 0], [1, 0, 0], [0, 1, 0]]
>>> m = mesh.Mesh(data, remove_empty_areas=False)
>>> m.save('/tmp/_numpy_stl_test.stl')
Warning:
Even for ASCII output, the file handle must be
opened in binary mode (``'wb'``). A text-mode
handle raises ``TypeError``.
"""
assert filename, 'Filename is required for the STL headers'
mode = Mode(mode)
if update_normals:
self.update_normals()
if mode is AUTOMATIC:
# Try to determine if the file is a TTY.
if fh:
try:
if os.isatty(fh.fileno()): # pragma: no cover
write = self._write_ascii
else:
write = self._write_binary
except OSError:
# If TTY checking fails then it's an io.BytesIO() (or one
# of its siblings from io). Assume binary.
write = self._write_binary
else:
write = self._write_binary
elif mode is BINARY:
write = self._write_binary
elif mode is ASCII:
write = self._write_ascii
else: # pragma: no cover - exhaustiveness guard for new modes
assert_never(mode)
if isinstance(fh, io.TextIOBase):
# Provide a more helpful error if the user mistakenly
# assumes ASCII files should be text files.
raise TypeError(
'File handles should be in binary mode - even when'
' writing an ASCII STL.'
)
name = self.name
if not name:
name = os.path.split(filename)[-1]
if fh:
write(fh, name)
else:
with open(filename, 'wb') as fh:
write(fh, name)
def _write_ascii(self, fh: IO[bytes], name: '_Name') -> None:
try:
fh.fileno()
# The C writer needs a real, seekable file; pipes such as
# stdout have a file descriptor but cannot seek. Duck-typed
# file-likes may implement neither method, hence the
# AttributeError.
speedups = self.speedups and fh.seekable()
except (AttributeError, io.UnsupportedOperation):
speedups = False
if _ascii_write is not None and speedups:
_ascii_write(fh, b(name), self.data)
else:
def p(s: '_Name', file: 'SupportsWrite[bytes]') -> None:
file.write(b(s) + b'\n')
p(b'solid ' + b(name), file=fh)
# 9 significant digits round-trip any float32 exactly; '{:f}'
# (fixed 6 decimals) would silently write tiny values as 0.
for row in self.data:
# Explicitly convert each component to standard float for
# normals and vertices to be compatible with numpy 2.x
normals = tuple(float(n) for n in row['normals'])
vectors = row['vectors']
p(
'facet normal {:.9g} {:.9g} {:.9g}'.format(*normals),
file=fh,
)
p(' outer loop', file=fh)
p(
' vertex {:.9g} {:.9g} {:.9g}'.format(
*tuple(float(v) for v in vectors[0])
),
file=fh,
)
p(
' vertex {:.9g} {:.9g} {:.9g}'.format(
*tuple(float(v) for v in vectors[1])
),
file=fh,
)
p(
' vertex {:.9g} {:.9g} {:.9g}'.format(
*tuple(float(v) for v in vectors[2])
),
file=fh,
)
p(' endloop', file=fh)
p('endfacet', file=fh)
p(b'endsolid ' + b(name), file=fh)
def get_header(self, name: '_Name') -> str:
"""Build the 80-byte binary STL header string.
Args:
name: Solid name to embed in the header.
Returns:
Header string truncated to 80 bytes.
"""
# Format the header
header: str = HEADER_FORMAT.format(
package_name=metadata.__package_name__,
version=metadata.__version__,
now=datetime.datetime.now(),
name=name,
)
# Make it exactly 80 characters
return header[:80].ljust(80, ' ')
def _write_binary(
self,
fh: '_StatefulWriter | io.TextIOWrapper',
name: '_Name',
) -> None:
header = self.get_header(name)
packed = struct.pack('<I', self.data.size)
if isinstance(fh, io.TextIOWrapper): # pragma: no cover
fh.write(header)
fh.write(str(packed))
else:
fh.write(b(header))
fh.write(b(packed))
if isinstance(fh, io.BufferedWriter) and fh.seekable():
# Write to a true file. numpy's tofile() needs to query the
# file position, which fails on pipes such as stdout.
self.data.tofile(fh)
else:
# Write to a pseudo buffer (e.g. BytesIO or a pipe).
cast('_StatefulWriter', fh).write(self.data.data)
# In theory this should no longer be possible but I'll leave it here
# anyway... Note that tell() is unavailable on pipes such as
# stdout, hence the seekable() guard.
if self.data.size and fh.seekable(): # pragma: no cover
assert fh.tell() > 84, (
'numpy silently refused to write our file. Note that writing '
'to `StringIO` objects is not supported by `numpy`'
)
@classmethod
def from_file(
cls,
filename: str,
calculate_normals: bool = True,
fh: 'IO[bytes] | None' = None,
mode: Mode = Mode.AUTOMATIC,
speedups: bool = True,
**kwargs: Any,
) -> 'Self':
"""Load a mesh from an STL file.
Reads binary or ASCII STL files. Format is
auto-detected unless ``mode`` is explicitly set.
Args:
filename: Path to the STL file.
calculate_normals: Whether to recalculate
normals after loading. Defaults to True.
fh: Optional pre-opened binary file handle.
If provided, ``filename`` is used only
for the mesh name.
mode: Force ASCII or BINARY loading, or
AUTOMATIC detection (default).
speedups: Use Cython speedups for ASCII
parsing when available. Defaults to True.
**kwargs: Additional arguments passed to
the Mesh constructor.
Returns:
A new Mesh instance containing the loaded data.
Raises:
ValueError: If the file is empty.
Example:
>>> from stl import mesh
>>> m = mesh.Mesh.from_file('tests/stl_binary/HalfDonut.stl')
>>> len(m.data) > 0
True
Note:
When ``speedups`` is True and the speedups
package is installed, ASCII parsing uses a
fast C implementation. Speedups are
automatically disabled for non-seekable
streams (e.g., stdin).
"""
mode = Mode(mode)
if fh:
result = cls.load(
_ensure_seekable(fh, mode), mode=mode, speedups=speedups
)
else:
with open(filename, 'rb') as fh:
result = cls.load(fh, mode=mode, speedups=speedups)
if result is None:
raise ValueError(f'STL file is empty: {filename}')
name, data = result
# pyrefly: ignore[bad-return]
return cls(
data, calculate_normals, name=name, speedups=speedups, **kwargs
)
@classmethod
def from_multi_file(
cls,
filename: str,
calculate_normals: bool = True,
fh: 'IO[bytes] | None' = None,
mode: Mode = Mode.AUTOMATIC,
speedups: bool = True,
**kwargs: Any,
) -> Generator['Self', None, None]:
"""Load multiple solids from a single STL file.
Yields one Mesh per ``solid`` block found.
Args:
filename: Path to the STL file.
calculate_normals: Whether to recalculate
normals. Defaults to True.
fh: Optional pre-opened binary file handle.
mode: Format mode. Defaults to
:attr:`Mode.AUTOMATIC`.
speedups: Use Cython speedups when available.
**kwargs: Additional arguments passed to
the Mesh constructor.
Yields:
Mesh instances, one per solid block.
Example:
>>> from stl import mesh
>>> # Single-solid file yields one mesh
>>> solids = list(
... mesh.Mesh.from_multi_file('tests/stl_ascii/HalfDonut.stl')
... )
>>> len(solids) >= 1
True
Note:
Multi-solid loading only works with ASCII
STL files. Binary STL files always contain
a single solid.
"""
if fh:
fh = _ensure_seekable(fh)
close = False
else:
fh = open(filename, 'rb') # noqa: SIM115
close = True
try:
raw_data = cls.load(fh, mode=mode, speedups=speedups)
while raw_data:
name, data = raw_data
# pyrefly: ignore[invalid-yield]
yield cls(
data,
calculate_normals,
name=name,
speedups=speedups,
**kwargs,
)
raw_data = cls.load(fh, mode=ASCII, speedups=speedups)
finally:
if close:
fh.close()
@classmethod
def from_files(
cls,
filenames: 'list[str]',
calculate_normals: bool = True,
mode: Mode = Mode.AUTOMATIC,
speedups: bool = True,
**kwargs: Any,
) -> 'Self':
"""Load and merge multiple STL files into one mesh.
Args:
filenames: List of STL file paths.
calculate_normals: Whether to recalculate
normals. Defaults to True.
mode: Format mode for each file.
speedups: Use Cython speedups when available.
**kwargs: Additional arguments passed to
the Mesh constructor.
Returns:
A single Mesh with data from all files.
Example:
>>> from stl import mesh
>>> m = mesh.Mesh.from_files(['tests/stl_binary/HalfDonut.stl'])
>>> len(m.data) > 0
True
"""
meshes = [
cls.from_file(
filename,
calculate_normals=calculate_normals,
mode=mode,
speedups=speedups,
**kwargs,
)
for filename in filenames
]
data = np.concatenate([mesh.data for mesh in meshes])
# pyrefly: ignore[bad-return]
return cls(data, calculate_normals=calculate_normals, **kwargs)
@classmethod
def from_3mf_file(
cls,
filename: str,
calculate_normals: bool = True,
**kwargs: object,
) -> Generator['Self', None, None]:
"""Load meshes from a 3MF file (read-only).
Parses the 3MF ZIP archive and yields one Mesh
per ``<mesh>`` element found.
Args:
filename: Path to the .3mf file.
calculate_normals: Whether to recalculate
normals. Defaults to True.
**kwargs: Additional arguments.
Yields:
Mesh instances, one per 3MF mesh element.
Example:
>>> from stl import mesh
>>> meshes = list(mesh.Mesh.from_3mf_file('tests/3mf/Moon.3mf'))
>>> len(meshes) > 0
True
Note:
3MF support is experimental and read-only.
Not all 3MF features are supported.
"""
with zipfile.ZipFile(filename) as zip:
with zip.open('_rels/.rels') as rels_fh:
model = None
root = ET.parse(rels_fh).getroot()
for child in root: # pragma: no branch
type_ = child.attrib.get('Type', '')
if type_.endswith('3dmodel'): # pragma: no branch
model = child.attrib.get('Target', '')
break
assert model, f'No 3D model found in {filename}'
with zip.open(model.lstrip('/')) as fh:
root = ET.parse(fh).getroot()
elements = root.findall('./{*}resources/{*}object/{*}mesh')
for mesh_element in elements: # pragma: no branch
triangles: list[list[list[float]]] = []
vertices: list[list[float]] = []
for element in mesh_element:
tag = element.tag
if tag.endswith('vertices'):
# Collect all the vertices
for vertice in element:
a = {
k: float(v)
for k, v in vertice.attrib.items()
}
try:
vertices.append([a['x'], a['y'], a['z']])
except KeyError as exception:
raise ValueError(
f'Vertex in {filename} is missing '
f'attribute {exception}'
) from exception
elif tag.endswith('triangles'): # pragma: no branch
# Map the triangles to the vertices and collect
for triangle in element:
a = {
k: int(v)
for k, v in triangle.attrib.items()
}
triangles.append(
[
vertices[
_3mf_index(a, 'v1', vertices)
],
vertices[
_3mf_index(a, 'v2', vertices)
],
vertices[
_3mf_index(a, 'v3', vertices)
],
]
)
mesh = cls(np.zeros(len(triangles), dtype=cls.dtype))
# pyrefly: ignore[missing-attribute]
mesh.vectors[:] = np.array(triangles)
# pyrefly: ignore[invalid-yield]
yield mesh
@classmethod
def from_ply_file(
cls,
filename: str,
calculate_normals: bool = True,
fh: 'IO[bytes] | None' = None,
**kwargs: Any,
) -> 'Self':
"""Load a mesh from a PLY file.
Supports ASCII and binary PLY formats
(little-endian and big-endian).
Args:
filename: Path to the .ply file.
calculate_normals: Whether to recalculate
normals. Defaults to True.
fh: Optional pre-opened binary file handle.
**kwargs: Additional arguments passed to
the Mesh constructor.
Returns:
A Mesh instance.
Example:
>>> from stl import mesh
>>> m = mesh.Mesh.from_ply_file('tests/ply_ascii/Cube.ply')
>>> len(m.data) == 12
True
"""
from .ply import read_ply
if fh:
data, name = read_ply(fh, cls.dtype)
else:
with open(filename, 'rb') as fh:
data, name = read_ply(fh, cls.dtype)
# pyrefly: ignore[bad-return]
return cls(
data,
calculate_normals,
name=name,
**kwargs,
)
def save_ply(
self,
filename: str,
fh: 'IO[bytes] | None' = None,
mode: str = 'binary_little_endian',
update_normals: bool = True,
) -> None:
"""Save the mesh to a PLY file.
Args:
filename: Output file path.
fh: Optional pre-opened binary file handle.
mode: PLY format. One of ``'ascii'``,
``'binary_little_endian'`` (default),
``'binary_big_endian'``.
update_normals: Whether to recalculate normals
before saving. Defaults to True.
Example:
>>> from stl import mesh
>>> m = mesh.Mesh.from_file('tests/stl_binary/HalfDonut.stl')
>>> m.save_ply('/tmp/_numpy_stl_test.ply')
"""
from .ply import write_ply
if update_normals:
self.update_normals()
name = ''
if isinstance(self.name, bytes):
name = self.name.decode('ascii', errors='replace')
elif isinstance(self.name, str):
name = self.name
if fh:
write_ply(fh, self.data, name=name, mode=mode)
else:
with open(filename, 'wb') as fh:
write_ply(fh, self.data, name=name, mode=mode)
if TYPE_CHECKING:
def StlMesh( # noqa: N802
filename: str,
calculate_normals: bool = True,
fh: 'IO[bytes] | None' = None,
mode: Mode = Mode.AUTOMATIC,
speedups: bool = True,
**kwargs: Any,
) -> BaseStl: ...
else:
StlMesh = BaseStl.from_file