diff --git a/.travis.yml b/.travis.yml index b1b1aad..ab45334 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,4 @@ +dist: xenial sudo: false language: python python: 3.6 @@ -12,7 +13,10 @@ env: - TOX_ENV=py27-nix - TOX_ENV=py34-nix - TOX_ENV=py35-nix -- TOX_ENV=py36-nix +# - TOX_ENV=py36-nix +- TOX_ENV=py37-nix +- TOX_ENV=py38-nix +- TOX_ENV=py39-nix matrix: include: - env: TOX_ENV=py37-nix diff --git a/README.rst b/README.rst index 86f639e..e4fffeb 100644 --- a/README.rst +++ b/README.rst @@ -336,23 +336,12 @@ Combining multiple STL files # find the max dimensions, so we can know the bounding box, getting the height, # width, length (because these are the step size)... def find_mins_maxs(obj): - minx = maxx = miny = maxy = minz = maxz = None - for p in obj.points: - # p contains (x, y, z) - if minx is None: - minx = p[stl.Dimension.X] - maxx = p[stl.Dimension.X] - miny = p[stl.Dimension.Y] - maxy = p[stl.Dimension.Y] - minz = p[stl.Dimension.Z] - maxz = p[stl.Dimension.Z] - else: - maxx = max(p[stl.Dimension.X], maxx) - minx = min(p[stl.Dimension.X], minx) - maxy = max(p[stl.Dimension.Y], maxy) - miny = min(p[stl.Dimension.Y], miny) - maxz = max(p[stl.Dimension.Z], maxz) - minz = min(p[stl.Dimension.Z], minz) + minx = obj.x.min() + maxx = obj.x.max() + miny = obj.y.min() + maxy = obj.y.max() + minz = obj.z.min() + maxz = obj.z.max() return minx, maxx, miny, maxy, minz, maxz diff --git a/setup.py b/setup.py index 5516109..ff5ba68 100644 --- a/setup.py +++ b/setup.py @@ -5,6 +5,7 @@ import warnings from setuptools import setup, extension from setuptools.command.build_ext import build_ext +from setuptools.command.test import test as TestCommand setup_kwargs = {} @@ -25,6 +26,19 @@ def error(*lines): pass +class PyTest(TestCommand): + def finalize_options(self): + TestCommand.finalize_options(self) + self.test_args = [] + self.test_suite = True + + def run_tests(self): + # import here, cause outside the eggs aren't loaded + import pytest + errno = pytest.main(self.test_args) + sys.exit(errno) + + if sys.version_info.major == 2 or sys.platform.lower() != 'win32': try: import numpy @@ -68,12 +82,7 @@ def error(*lines): install_requires.append('enum34') -if os.environ.get('PYTEST_RUNNER', '').lower() == 'false': - tests_require = [] - setup_requires = [] -else: - tests_require = ['pytest'] - setup_requires = ['pytest-runner'] +tests_require = ['pytest'] class BuildExt(build_ext): @@ -101,7 +110,6 @@ def run(self): packages=['stl'], long_description=long_description, tests_require=tests_require, - setup_requires=setup_requires, entry_points={ 'console_scripts': [ 'stl = %s.main:main' % about['__import_name__'], @@ -128,6 +136,7 @@ def run(self): install_requires=install_requires, cmdclass=dict( build_ext=BuildExt, + test=PyTest, ), **setup_kwargs ) diff --git a/stl/__about__.py b/stl/__about__.py index 4dc1bc7..7a1b21f 100644 --- a/stl/__about__.py +++ b/stl/__about__.py @@ -1,6 +1,6 @@ __package_name__ = 'numpy-stl' __import_name__ = 'stl' -__version__ = '2.10.1' +__version__ = '2.11.0' __author__ = 'Rick van Hattem' __author_email__ = 'Wolph@Wol.ph' __description__ = ' '.join(''' diff --git a/stl/base.py b/stl/base.py index 070502d..90f77be 100644 --- a/stl/base.py +++ b/stl/base.py @@ -316,9 +316,10 @@ def remove_empty_areas(cls, data): def update_normals(self): '''Update the normals for all points''' normals = numpy.cross(self.v1 - self.v0, self.v2 - self.v0) - normal = numpy.linalg.norm(normals) - if normal: - normals /= normal + normal = numpy.linalg.norm(normals, axis=1) + non_zero = normal > 0 + if non_zero.any(): + normals[non_zero] /= normal[non_zero][:, None] self.normals[:] = normals def update_min(self): @@ -335,7 +336,7 @@ def check(self): '''Check the mesh is valid or not''' return self.is_closed() - def is_closed(self): + def is_closed(self): # pragma: no cover """Check the mesh is closed or not""" if (self.normals.sum(axis=0) >= 1e-4).any(): self.warning(''' @@ -436,7 +437,7 @@ def rotation_matrix(cls, axis, theta): axis = numpy.asarray(axis) # No need to rotate if there is no actual rotation if not axis.any(): - return numpy.zeros((3, 3)) + return numpy.identity(3) theta = 0.5 * numpy.asarray(theta) @@ -479,8 +480,9 @@ def rotate(self, axis, theta=0, point=None): self.rotate_using_matrix(self.rotation_matrix(axis, theta), point) def rotate_using_matrix(self, rotation_matrix, point=None): + identity = numpy.identity(rotation_matrix.shape[0]) # No need to rotate if there is no actual rotation - if not rotation_matrix.any(): + if not rotation_matrix.any() or (identity == rotation_matrix).all(): return if isinstance(point, (numpy.ndarray, list, tuple)) and len(point) == 3: @@ -500,6 +502,10 @@ def _rotate(matrix): # Simply apply the rotation return matrix.dot(rotation_matrix) + # Rotate the normals + self.normals[:] = _rotate(self.normals[:]) + + # Rotate the vectors for i in range(3): self.vectors[:, i] = _rotate(self.vectors[:, i]) diff --git a/stl/main.py b/stl/main.py index 01fea4a..aa15390 100644 --- a/stl/main.py +++ b/stl/main.py @@ -76,7 +76,7 @@ def to_ascii(): def to_binary(): - parser = _get_parser('Convert STL files to ASCII (text) format') + parser = _get_parser('Convert STL files to binary format') args = parser.parse_args() name = _get_name(args) stl_file = stl.StlMesh(filename=name, fh=args.infile, diff --git a/stl/stl.py b/stl/stl.py index 8b28faf..fc2cfa0 100644 --- a/stl/stl.py +++ b/stl/stl.py @@ -43,6 +43,8 @@ class Mode(enum.IntEnum): COUNT_SIZE = 4 #: The maximum amount of triangles we can read from binary files MAX_COUNT = 1e8 +#: The header format, can be safely monkeypatched. Limited to 80 characters +HEADER_FORMAT = '{package_name} ({version}) {now} {name}' class BaseStl(base.BaseMesh): @@ -56,7 +58,7 @@ def load(cls, fh, mode=AUTOMATIC, speedups=True): :param file fh: The file handle to open :param int mode: Automatically detect the filetype or force binary ''' - header = fh.read(HEADER_SIZE).lower() + header = fh.read(HEADER_SIZE) if not header: return @@ -65,7 +67,7 @@ def load(cls, fh, mode=AUTOMATIC, speedups=True): name = '' - if mode in (AUTOMATIC, ASCII) and header.startswith(b('solid')): + if mode in (AUTOMATIC, ASCII) and header[:5].lower() == b('solid'): try: name, data = cls._load_ascii( fh, header, speedups=speedups) @@ -136,20 +138,22 @@ def _ascii_reader(cls, fh, header): lines = b(header).split(b('\n')) def get(prefix=''): - prefix = b(prefix) + prefix = b(prefix).lower() if lines: - line = lines.pop(0) + 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')) - line += lines.pop(0) + raw_line += lines.pop(0) - line = line.lower().strip() + raw_line = raw_line.strip() + line = raw_line.lower() if line == b(''): return get(prefix) @@ -174,7 +178,7 @@ def get(prefix=''): raise RuntimeError(recoverable[0], 'Incorrect value %r' % line) else: - return b(line) + return b(raw_line) line = get() if not lines: @@ -275,17 +279,20 @@ def p(s, file): p('endsolid %s' % name, file=fh) - def _write_binary(self, fh, name): - # Create the header - header = '%s (%s) %s %s' % ( - metadata.__package_name__, - metadata.__version__, - datetime.datetime.now(), - name, + def get_header(self, name): + # Format the header + header = HEADER_FORMAT.format( + package_name=metadata.__package_name__, + version=metadata.__version__, + now=datetime.datetime.now(), + name=name, ) # Make it exactly 80 characters - header = header[:80].ljust(80, ' ') + return header[:80].ljust(80, ' ') + + def _write_binary(self, fh, name): + header = self.get_header(name) packed = struct.pack(s('