Coverage for /Volumes/workspace/numpy-stl/stl/base.py : 98%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1from __future__ import (absolute_import, division, print_function,
2 unicode_literals)
3import enum
4import math
5import numpy
6import logging
7try: # pragma: no cover
8 from collections import abc
9except ImportError: # pragma: no cover
10 import collections as abc
12from python_utils import logger
14from .utils import s
16#: When removing empty areas, remove areas that are smaller than this
17AREA_SIZE_THRESHOLD = 0
18#: Vectors in a point
19VECTORS = 3
20#: Dimensions used in a vector
21DIMENSIONS = 3
24class Dimension(enum.IntEnum):
25 #: X index (for example, `mesh.v0[0][X]`)
26 X = 0
27 #: Y index (for example, `mesh.v0[0][Y]`)
28 Y = 1
29 #: Z index (for example, `mesh.v0[0][Z]`)
30 Z = 2
33# For backwards compatibility, leave the original references
34X = Dimension.X
35Y = Dimension.Y
36Z = Dimension.Z
39class RemoveDuplicates(enum.Enum):
40 '''
41 Choose whether to remove no duplicates, leave only a single of the
42 duplicates or remove all duplicates (leaving holes).
43 '''
44 NONE = 0
45 SINGLE = 1
46 ALL = 2
48 @classmethod
49 def map(cls, value):
50 if value is True:
51 value = cls.SINGLE
52 elif value and value in cls:
53 pass
54 else:
55 value = cls.NONE
57 return value
60def logged(class_):
61 # For some reason the Logged baseclass is not properly initiated on Linux
62 # systems while this works on OS X. Please let me know if you can tell me
63 # what silly mistake I made here
65 logger_name = logger.Logged._Logged__get_name(
66 __name__,
67 class_.__name__,
68 )
70 class_.logger = logging.getLogger(logger_name)
72 for key in dir(logger.Logged):
73 if not key.startswith('__'):
74 setattr(class_, key, getattr(class_, key))
76 return class_
79@logged
80class BaseMesh(logger.Logged, abc.Mapping):
81 '''
82 Mesh object with easy access to the vectors through v0, v1 and v2.
83 The normals, areas, min, max and units are calculated automatically.
85 :param numpy.array data: The data for this mesh
86 :param bool calculate_normals: Whether to calculate the normals
87 :param bool remove_empty_areas: Whether to remove triangles with 0 area
88 (due to rounding errors for example)
90 :ivar str name: Name of the solid, only exists in ASCII files
91 :ivar numpy.array data: Data as :func:`BaseMesh.dtype`
92 :ivar numpy.array points: All points (Nx9)
93 :ivar numpy.array normals: Normals for this mesh, calculated automatically
94 by default (Nx3)
95 :ivar numpy.array vectors: Vectors in the mesh (Nx3x3)
96 :ivar numpy.array attr: Attributes per vector (used by binary STL)
97 :ivar numpy.array x: Points on the X axis by vertex (Nx3)
98 :ivar numpy.array y: Points on the Y axis by vertex (Nx3)
99 :ivar numpy.array z: Points on the Z axis by vertex (Nx3)
100 :ivar numpy.array v0: Points in vector 0 (Nx3)
101 :ivar numpy.array v1: Points in vector 1 (Nx3)
102 :ivar numpy.array v2: Points in vector 2 (Nx3)
104 >>> data = numpy.zeros(10, dtype=BaseMesh.dtype)
105 >>> mesh = BaseMesh(data, remove_empty_areas=False)
106 >>> # Increment vector 0 item 0
107 >>> mesh.v0[0] += 1
108 >>> mesh.v1[0] += 2
110 >>> # Check item 0 (contains v0, v1 and v2)
111 >>> assert numpy.array_equal(
112 ... mesh[0],
113 ... numpy.array([1., 1., 1., 2., 2., 2., 0., 0., 0.]))
114 >>> assert numpy.array_equal(
115 ... mesh.vectors[0],
116 ... numpy.array([[1., 1., 1.],
117 ... [2., 2., 2.],
118 ... [0., 0., 0.]]))
119 >>> assert numpy.array_equal(
120 ... mesh.v0[0],
121 ... numpy.array([1., 1., 1.]))
122 >>> assert numpy.array_equal(
123 ... mesh.points[0],
124 ... numpy.array([1., 1., 1., 2., 2., 2., 0., 0., 0.]))
125 >>> assert numpy.array_equal(
126 ... mesh.data[0],
127 ... numpy.array((
128 ... [0., 0., 0.],
129 ... [[1., 1., 1.], [2., 2., 2.], [0., 0., 0.]],
130 ... [0]),
131 ... dtype=BaseMesh.dtype))
132 >>> assert numpy.array_equal(mesh.x[0], numpy.array([1., 2., 0.]))
134 >>> mesh[0] = 3
135 >>> assert numpy.array_equal(
136 ... mesh[0],
137 ... numpy.array([3., 3., 3., 3., 3., 3., 3., 3., 3.]))
139 >>> len(mesh) == len(list(mesh))
140 True
141 >>> (mesh.min_ < mesh.max_).all()
142 True
143 >>> mesh.update_normals()
144 >>> mesh.units.sum()
145 0.0
146 >>> mesh.v0[:] = mesh.v1[:] = mesh.v2[:] = 0
147 >>> mesh.points.sum()
148 0.0
150 >>> mesh.v0 = mesh.v1 = mesh.v2 = 0
151 >>> mesh.x = mesh.y = mesh.z = 0
153 >>> mesh.attr = 1
154 >>> (mesh.attr == 1).all()
155 True
157 >>> mesh.normals = 2
158 >>> (mesh.normals == 2).all()
159 True
161 >>> mesh.vectors = 3
162 >>> (mesh.vectors == 3).all()
163 True
165 >>> mesh.points = 4
166 >>> (mesh.points == 4).all()
167 True
168 '''
169 #: - normals: :func:`numpy.float32`, `(3, )`
170 #: - vectors: :func:`numpy.float32`, `(3, 3)`
171 #: - attr: :func:`numpy.uint16`, `(1, )`
172 dtype = numpy.dtype([
173 (s('normals'), numpy.float32, (3, )),
174 (s('vectors'), numpy.float32, (3, 3)),
175 (s('attr'), numpy.uint16, (1, )),
176 ])
177 dtype = dtype.newbyteorder('<') # Even on big endian arches, use little e.
179 def __init__(self, data, calculate_normals=True,
180 remove_empty_areas=False,
181 remove_duplicate_polygons=RemoveDuplicates.NONE,
182 name='', speedups=True, **kwargs):
183 super(BaseMesh, self).__init__(**kwargs)
184 self.speedups = speedups
185 if remove_empty_areas:
186 data = self.remove_empty_areas(data)
188 if RemoveDuplicates.map(remove_duplicate_polygons).value:
189 data = self.remove_duplicate_polygons(data,
190 remove_duplicate_polygons)
192 self.name = name
193 self.data = data
195 if calculate_normals:
196 self.update_normals()
198 @property
199 def attr(self):
200 return self.data['attr']
202 @attr.setter
203 def attr(self, value):
204 self.data['attr'] = value
206 @property
207 def normals(self):
208 return self.data['normals']
210 @normals.setter
211 def normals(self, value):
212 self.data['normals'] = value
214 @property
215 def vectors(self):
216 return self.data['vectors']
218 @vectors.setter
219 def vectors(self, value):
220 self.data['vectors'] = value
222 @property
223 def points(self):
224 return self.vectors.reshape(self.data.size, 9)
226 @points.setter
227 def points(self, value):
228 self.points[:] = value
230 @property
231 def v0(self):
232 return self.vectors[:, 0]
234 @v0.setter
235 def v0(self, value):
236 self.vectors[:, 0] = value
238 @property
239 def v1(self):
240 return self.vectors[:, 1]
242 @v1.setter
243 def v1(self, value):
244 self.vectors[:, 1] = value
246 @property
247 def v2(self):
248 return self.vectors[:, 2]
250 @v2.setter
251 def v2(self, value):
252 self.vectors[:, 2] = value
254 @property
255 def x(self):
256 return self.points[:, Dimension.X::3]
258 @x.setter
259 def x(self, value):
260 self.points[:, Dimension.X::3] = value
262 @property
263 def y(self):
264 return self.points[:, Dimension.Y::3]
266 @y.setter
267 def y(self, value):
268 self.points[:, Dimension.Y::3] = value
270 @property
271 def z(self):
272 return self.points[:, Dimension.Z::3]
274 @z.setter
275 def z(self, value):
276 self.points[:, Dimension.Z::3] = value
278 @classmethod
279 def remove_duplicate_polygons(cls, data, value=RemoveDuplicates.SINGLE):
280 value = RemoveDuplicates.map(value)
281 polygons = data['vectors'].sum(axis=1)
282 # Get a sorted list of indices
283 idx = numpy.lexsort(polygons.T)
284 # Get the indices of all different indices
285 diff = numpy.any(polygons[idx[1:]] != polygons[idx[:-1]], axis=1)
287 if value is RemoveDuplicates.SINGLE:
288 # Only return the unique data, the True is so we always get at
289 # least the originals
290 return data[numpy.sort(idx[numpy.concatenate(([True], diff))])]
291 elif value is RemoveDuplicates.ALL:
292 # We need to return both items of the shifted diff
293 diff_a = numpy.concatenate(([True], diff))
294 diff_b = numpy.concatenate((diff, [True]))
295 diff = numpy.concatenate((diff, [False]))
297 # Combine both unique lists
298 filtered_data = data[numpy.sort(idx[diff_a & diff_b])]
299 if len(filtered_data) <= len(data) / 2:
300 return data[numpy.sort(idx[diff_a])]
301 else:
302 return data[numpy.sort(idx[diff])]
303 else:
304 return data
306 @classmethod
307 def remove_empty_areas(cls, data):
308 vectors = data['vectors']
309 v0 = vectors[:, 0]
310 v1 = vectors[:, 1]
311 v2 = vectors[:, 2]
312 normals = numpy.cross(v1 - v0, v2 - v0)
313 squared_areas = (normals ** 2).sum(axis=1)
314 return data[squared_areas > AREA_SIZE_THRESHOLD ** 2]
316 def update_normals(self, update_areas=True):
317 '''Update the normals and areas for all points'''
318 normals = numpy.cross(self.v1 - self.v0, self.v2 - self.v0)
320 if update_areas:
321 self.update_areas(normals)
323 self.normals[:] = normals
325 def get_unit_normals(self):
326 normals = self.normals.copy()
327 normal = numpy.linalg.norm(normals, axis=1)
328 non_zero = normal > 0
329 if non_zero.any():
330 normals[non_zero] /= normal[non_zero][:, None]
331 return normals
333 def update_min(self):
334 self._min = self.vectors.min(axis=(0, 1))
336 def update_max(self):
337 self._max = self.vectors.max(axis=(0, 1))
339 def update_areas(self, normals=None):
340 if normals is None:
341 normals = numpy.cross(self.v1 - self.v0, self.v2 - self.v0)
343 areas = .5 * numpy.sqrt((normals ** 2).sum(axis=1))
344 self.areas = areas.reshape((areas.size, 1))
346 def check(self):
347 '''Check the mesh is valid or not'''
348 return self.is_closed()
350 def is_closed(self): # pragma: no cover
351 """Check the mesh is closed or not"""
352 if numpy.isclose(self.normals.sum(axis=0), 0, atol=1e-4).all():
353 return True
354 else:
355 self.warning('''
356 Your mesh is not closed, the mass methods will not function
357 correctly on this mesh. For more info:
358 https://github.com/WoLpH/numpy-stl/issues/69
359 '''.strip())
360 return False
362 def get_mass_properties(self):
363 '''
364 Evaluate and return a tuple with the following elements:
365 - the volume
366 - the position of the center of gravity (COG)
367 - the inertia matrix expressed at the COG
369 Documentation can be found here:
370 http://www.geometrictools.com/Documentation/PolyhedralMassProperties.pdf
371 '''
372 self.check()
374 def subexpression(x):
375 w0, w1, w2 = x[:, 0], x[:, 1], x[:, 2]
376 temp0 = w0 + w1
377 f1 = temp0 + w2
378 temp1 = w0 * w0
379 temp2 = temp1 + w1 * temp0
380 f2 = temp2 + w2 * f1
381 f3 = w0 * temp1 + w1 * temp2 + w2 * f2
382 g0 = f2 + w0 * (f1 + w0)
383 g1 = f2 + w1 * (f1 + w1)
384 g2 = f2 + w2 * (f1 + w2)
385 return f1, f2, f3, g0, g1, g2
387 x0, x1, x2 = self.x[:, 0], self.x[:, 1], self.x[:, 2]
388 y0, y1, y2 = self.y[:, 0], self.y[:, 1], self.y[:, 2]
389 z0, z1, z2 = self.z[:, 0], self.z[:, 1], self.z[:, 2]
390 a1, b1, c1 = x1 - x0, y1 - y0, z1 - z0
391 a2, b2, c2 = x2 - x0, y2 - y0, z2 - z0
392 d0, d1, d2 = b1 * c2 - b2 * c1, a2 * c1 - a1 * c2, a1 * b2 - a2 * b1
394 f1x, f2x, f3x, g0x, g1x, g2x = subexpression(self.x)
395 f1y, f2y, f3y, g0y, g1y, g2y = subexpression(self.y)
396 f1z, f2z, f3z, g0z, g1z, g2z = subexpression(self.z)
398 intg = numpy.zeros((10))
399 intg[0] = sum(d0 * f1x)
400 intg[1:4] = sum(d0 * f2x), sum(d1 * f2y), sum(d2 * f2z)
401 intg[4:7] = sum(d0 * f3x), sum(d1 * f3y), sum(d2 * f3z)
402 intg[7] = sum(d0 * (y0 * g0x + y1 * g1x + y2 * g2x))
403 intg[8] = sum(d1 * (z0 * g0y + z1 * g1y + z2 * g2y))
404 intg[9] = sum(d2 * (x0 * g0z + x1 * g1z + x2 * g2z))
405 intg /= numpy.array([6, 24, 24, 24, 60, 60, 60, 120, 120, 120])
406 volume = intg[0]
407 cog = intg[1:4] / volume
408 cogsq = cog ** 2
409 inertia = numpy.zeros((3, 3))
410 inertia[0, 0] = intg[5] + intg[6] - volume * (cogsq[1] + cogsq[2])
411 inertia[1, 1] = intg[4] + intg[6] - volume * (cogsq[2] + cogsq[0])
412 inertia[2, 2] = intg[4] + intg[5] - volume * (cogsq[0] + cogsq[1])
413 inertia[0, 1] = inertia[1, 0] = -(intg[7] - volume * cog[0] * cog[1])
414 inertia[1, 2] = inertia[2, 1] = -(intg[8] - volume * cog[1] * cog[2])
415 inertia[0, 2] = inertia[2, 0] = -(intg[9] - volume * cog[2] * cog[0])
416 return volume, cog, inertia
418 def update_units(self):
419 units = self.normals.copy()
420 non_zero_areas = self.areas > 0
421 areas = self.areas
423 if non_zero_areas.shape[0] != areas.shape[0]: # pragma: no cover
424 self.warning('Zero sized areas found, '
425 'units calculation will be partially incorrect')
427 if non_zero_areas.any():
428 non_zero_areas.shape = non_zero_areas.shape[0]
429 areas = numpy.hstack((2 * areas[non_zero_areas],) * DIMENSIONS)
430 units[non_zero_areas] /= areas
432 self.units = units
434 @classmethod
435 def rotation_matrix(cls, axis, theta):
436 '''
437 Generate a rotation matrix to Rotate the matrix over the given axis by
438 the given theta (angle)
440 Uses the `Euler-Rodrigues
441 <https://en.wikipedia.org/wiki/Euler%E2%80%93Rodrigues_formula>`_
442 formula for fast rotations.
444 :param numpy.array axis: Axis to rotate over (x, y, z)
445 :param float theta: Rotation angle in radians, use `math.radians` to
446 convert degrees to radians if needed.
447 '''
448 axis = numpy.asarray(axis)
449 # No need to rotate if there is no actual rotation
450 if not axis.any():
451 return numpy.identity(3)
453 theta = 0.5 * numpy.asarray(theta)
455 axis = axis / numpy.linalg.norm(axis)
457 a = math.cos(theta)
458 b, c, d = - axis * math.sin(theta)
459 angles = a, b, c, d
460 powers = [x * y for x in angles for y in angles]
461 aa, ab, ac, ad = powers[0:4]
462 ba, bb, bc, bd = powers[4:8]
463 ca, cb, cc, cd = powers[8:12]
464 da, db, dc, dd = powers[12:16]
466 return numpy.array([[aa + bb - cc - dd, 2 * (bc + ad), 2 * (bd - ac)],
467 [2 * (bc - ad), aa + cc - bb - dd, 2 * (cd + ab)],
468 [2 * (bd + ac), 2 * (cd - ab), aa + dd - bb - cc]])
470 def rotate(self, axis, theta=0, point=None):
471 '''
472 Rotate the matrix over the given axis by the given theta (angle)
474 Uses the :py:func:`rotation_matrix` in the background.
476 .. note:: Note that the `point` was accidentaly inverted with the
477 old version of the code. To get the old and incorrect behaviour
478 simply pass `-point` instead of `point` or `-numpy.array(point)` if
479 you're passing along an array.
481 :param numpy.array axis: Axis to rotate over (x, y, z)
482 :param float theta: Rotation angle in radians, use `math.radians` to
483 convert degrees to radians if needed.
484 :param numpy.array point: Rotation point so manual translation is not
485 required
486 '''
487 # No need to rotate if there is no actual rotation
488 if not theta:
489 return
491 self.rotate_using_matrix(self.rotation_matrix(axis, theta), point)
493 def rotate_using_matrix(self, rotation_matrix, point=None):
494 identity = numpy.identity(rotation_matrix.shape[0])
495 # No need to rotate if there is no actual rotation
496 if not rotation_matrix.any() or (identity == rotation_matrix).all():
497 return
499 if isinstance(point, (numpy.ndarray, list, tuple)) and len(point) == 3:
500 point = numpy.asarray(point)
501 elif point is None:
502 point = numpy.array([0, 0, 0])
503 elif isinstance(point, (int, float)):
504 point = numpy.asarray([point] * 3)
505 else:
506 raise TypeError('Incorrect type for point', point)
508 def _rotate(matrix):
509 if point.any():
510 # Translate while rotating
511 return (matrix - point).dot(rotation_matrix) + point
512 else:
513 # Simply apply the rotation
514 return matrix.dot(rotation_matrix)
516 # Rotate the normals
517 self.normals[:] = _rotate(self.normals[:])
519 # Rotate the vectors
520 for i in range(3):
521 self.vectors[:, i] = _rotate(self.vectors[:, i])
523 def translate(self, translation):
524 '''
525 Translate the mesh in the three directions
527 :param numpy.array translation: Translation vector (x, y, z)
528 '''
529 assert len(translation) == 3, "Translation vector must be of length 3"
530 self.x += translation[0]
531 self.y += translation[1]
532 self.z += translation[2]
534 def transform(self, matrix):
535 '''
536 Transform the mesh with a rotation and a translation stored in a
537 single 4x4 matrix
539 :param numpy.array matrix: Transform matrix with shape (4, 4), where
540 matrix[0:3, 0:3] represents the rotation
541 part of the transformation
542 matrix[0:3, 3] represents the translation
543 part of the transformation
544 '''
545 is_a_4x4_matrix = matrix.shape == (4, 4)
546 assert is_a_4x4_matrix, "Transformation matrix must be of shape (4, 4)"
547 rotation = matrix[0:3, 0:3]
548 unit_det_rotation = numpy.allclose(numpy.linalg.det(rotation), 1.0)
549 assert unit_det_rotation, "Rotation matrix has not a unit determinant"
550 for i in range(3):
551 self.vectors[:, i] = numpy.dot(rotation, self.vectors[:, i].T).T
552 self.x += matrix[0, 3]
553 self.y += matrix[1, 3]
554 self.z += matrix[2, 3]
556 def _get_or_update(key):
557 def _get(self):
558 if not hasattr(self, '_%s' % key):
559 getattr(self, 'update_%s' % key)()
560 return getattr(self, '_%s' % key)
562 return _get
564 def _set(key):
565 def _set(self, value):
566 setattr(self, '_%s' % key, value)
568 return _set
570 min_ = property(_get_or_update('min'), _set('min'),
571 doc='Mesh minimum value')
572 max_ = property(_get_or_update('max'), _set('max'),
573 doc='Mesh maximum value')
574 areas = property(_get_or_update('areas'), _set('areas'),
575 doc='Mesh areas')
576 units = property(_get_or_update('units'), _set('units'),
577 doc='Mesh unit vectors')
579 def __getitem__(self, k):
580 return self.points[k]
582 def __setitem__(self, k, v):
583 self.points[k] = v
585 def __len__(self):
586 return self.points.shape[0]
588 def __iter__(self):
589 for point in self.points:
590 yield point
592 def get_mass_properties_with_density(self, density):
593 # add density for mesh,density unit kg/m3 when mesh is unit is m
594 self.check()
596 def subexpression(x):
597 w0, w1, w2 = x[:, 0], x[:, 1], x[:, 2]
598 temp0 = w0 + w1
599 f1 = temp0 + w2
600 temp1 = w0 * w0
601 temp2 = temp1 + w1 * temp0
602 f2 = temp2 + w2 * f1
603 f3 = w0 * temp1 + w1 * temp2 + w2 * f2
604 g0 = f2 + w0 * (f1 + w0)
605 g1 = f2 + w1 * (f1 + w1)
606 g2 = f2 + w2 * (f1 + w2)
607 return f1, f2, f3, g0, g1, g2
609 x0, x1, x2 = self.x[:, 0], self.x[:, 1], self.x[:, 2]
610 y0, y1, y2 = self.y[:, 0], self.y[:, 1], self.y[:, 2]
611 z0, z1, z2 = self.z[:, 0], self.z[:, 1], self.z[:, 2]
612 a1, b1, c1 = x1 - x0, y1 - y0, z1 - z0
613 a2, b2, c2 = x2 - x0, y2 - y0, z2 - z0
614 d0, d1, d2 = b1 * c2 - b2 * c1, a2 * c1 - a1 * c2, a1 * b2 - a2 * b1
616 f1x, f2x, f3x, g0x, g1x, g2x = subexpression(self.x)
617 f1y, f2y, f3y, g0y, g1y, g2y = subexpression(self.y)
618 f1z, f2z, f3z, g0z, g1z, g2z = subexpression(self.z)
620 intg = numpy.zeros((10))
621 intg[0] = sum(d0 * f1x)
622 intg[1:4] = sum(d0 * f2x), sum(d1 * f2y), sum(d2 * f2z)
623 intg[4:7] = sum(d0 * f3x), sum(d1 * f3y), sum(d2 * f3z)
624 intg[7] = sum(d0 * (y0 * g0x + y1 * g1x + y2 * g2x))
625 intg[8] = sum(d1 * (z0 * g0y + z1 * g1y + z2 * g2y))
626 intg[9] = sum(d2 * (x0 * g0z + x1 * g1z + x2 * g2z))
627 intg /= numpy.array([6, 24, 24, 24, 60, 60, 60, 120, 120, 120])
628 volume = intg[0]
629 cog = intg[1:4] / volume
630 cogsq = cog ** 2
631 vmass = volume * density
632 inertia = numpy.zeros((3, 3))
634 inertia[0, 0] = (intg[5] + intg[6]) * density - vmass * (
635 cogsq[1] + cogsq[2])
636 inertia[1, 1] = (intg[4] + intg[6]) * density - vmass * (
637 cogsq[2] + cogsq[0])
638 inertia[2, 2] = (intg[4] + intg[5]) * density - vmass * (
639 cogsq[0] + cogsq[1])
640 inertia[0, 1] = inertia[1, 0] = -(
641 intg[7] * density - vmass * cog[0] * cog[1])
642 inertia[1, 2] = inertia[2, 1] = -(
643 intg[8] * density - vmass * cog[1] * cog[2])
644 inertia[0, 2] = inertia[2, 0] = -(
645 intg[9] * density - vmass * cog[2] * cog[0])
647 return volume, vmass, cog, inertia