/
creation.py
1407 lines (1202 loc) · 44.7 KB
/
creation.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
creation.py
--------------
Create meshes from primitives, or with operations.
"""
import collections
import warnings
import numpy as np
from . import exceptions, grouping, triangles, util
from . import transformations as tf
from .base import Trimesh
from .constants import log, tol
from .geometry import align_vectors, faces_to_edges, plane_transform
from .resources import get_json
from .typed import ArrayLike, Dict, Integer, Number, Optional
try:
# shapely is a soft dependency
from shapely.geometry import Polygon
from shapely.wkb import loads as load_wkb
except BaseException as E:
# re-raise the exception when someone tries
# to use the module that they don't have
Polygon = exceptions.ExceptionWrapper(E)
load_wkb = exceptions.ExceptionWrapper(E)
try:
from mapbox_earcut import triangulate_float64 as _tri_earcut
except BaseException as E:
_tri_earcut = exceptions.ExceptionWrapper(E)
# get stored values for simple box and icosahedron primitives
_data = get_json("creation.json")
def revolve(
linestring: ArrayLike,
angle: Optional[Number] = None,
sections: Optional[Integer] = None,
transform: Optional[ArrayLike] = None,
**kwargs,
) -> Trimesh:
"""
Revolve a 2D line string around the 2D Y axis, with a result with
the 2D Y axis pointing along the 3D Z axis.
This function is intended to handle the complexity of indexing
and is intended to be used to create all radially symmetric primitives,
eventually including cylinders, annular cylinders, capsules, cones,
and UV spheres.
Note that if your linestring is closed, it needs to be counterclockwise
if you would like face winding and normals facing outwards.
Parameters
-------------
linestring : (n, 2) float
Lines in 2D which will be revolved
angle : None or float
Angle in radians to revolve curve by
sections : None or int
Number of sections result should have
If not specified default is 32 per revolution
transform : None or (4, 4) float
Transform to apply to mesh after construction
**kwargs : dict
Passed to Trimesh constructor
Returns
--------------
revolved : Trimesh
Mesh representing revolved result
"""
linestring = np.asanyarray(linestring, dtype=np.float64)
# linestring must be ordered 2D points
if len(linestring.shape) != 2 or linestring.shape[1] != 2:
raise ValueError("linestring must be 2D!")
if angle is None:
# default to closing the revolution
angle = np.pi * 2.0
closed = True
else:
# check passed angle value
closed = util.isclose(angle, np.pi * 2, atol=1e-10)
if sections is None:
# default to 32 sections for a full revolution
sections = int(angle / (np.pi * 2) * 32)
# change to face count
sections += 1
# create equally spaced angles
theta = np.linspace(0, angle, sections)
# 2D points around the revolution
points = np.column_stack((np.cos(theta), np.sin(theta)))
# how many points per slice
per = len(linestring)
# use the 2D X component as radius
radius = linestring[:, 0]
# use the 2D Y component as the height along revolution
height = linestring[:, 1]
# a lot of tiling to get our 3D vertices
vertices = np.column_stack(
(
np.tile(points, (1, per)).reshape((-1, 2))
* np.tile(radius, len(points)).reshape((-1, 1)),
np.tile(height, len(points)),
)
)
if closed:
# should be a duplicate set of vertices
if tol.strict:
assert util.allclose(vertices[:per], vertices[-per:], atol=1e-8)
# chop off duplicate vertices
vertices = vertices[:-per]
if transform is not None:
# apply transform to vertices
vertices = tf.transform_points(vertices, transform)
# how many slices of the pie
slices = len(theta) - 1
# start with a quad for every segment
# this is a superset which will then be reduced
quad = np.array([0, per, 1, 1, per, per + 1])
# stack the faces for a single slice of the revolution
single = np.tile(quad, per).reshape((-1, 3))
# `per` is basically the stride of the vertices
single += np.tile(np.arange(per), (2, 1)).T.reshape((-1, 1))
# remove any zero-area triangle
# this covers many cases without having to think too much
single = single[triangles.area(vertices[single]) > tol.merge]
# how much to offset each slice
# note arange multiplied by vertex stride
# but tiled by the number of faces we actually have
offset = np.tile(np.arange(slices) * per, (len(single), 1)).T.reshape((-1, 1))
# stack a single slice into N slices
stacked = np.tile(single.ravel(), slices).reshape((-1, 3))
if tol.strict:
# make sure we didn't screw up stacking operation
assert np.allclose(stacked.reshape((-1, single.shape[0], 3)) - single, 0)
# offset stacked and wrap vertices
faces = (stacked + offset) % len(vertices)
# if 'process' not in kwargs:
# kwargs['process'] = False
# create the mesh from our vertices and faces
mesh = Trimesh(vertices=vertices, faces=faces, **kwargs)
# strict checks run only in unit tests
if tol.strict and (
np.allclose(radius[[0, -1]], 0.0) or np.allclose(linestring[0], linestring[-1])
):
# if revolved curve starts and ends with zero radius
# it should really be a valid volume, unless the sign
# reversed on the input linestring
assert closed
assert mesh.is_volume
assert mesh.body_count == 1
return mesh
def extrude_polygon(
polygon: "Polygon", height: Number, transform: Optional[ArrayLike] = None, **kwargs
) -> Trimesh:
"""
Extrude a 2D shapely polygon into a 3D mesh
Parameters
----------
polygon : shapely.geometry.Polygon
2D geometry to extrude
height : float
Distance to extrude polygon along Z
transform : None or (4, 4) float
Transform to apply to mesh after construction
triangle_args : str or None
Passed to triangle
**kwargs : dict
Passed to `triangulate_polygon`
Returns
----------
mesh : trimesh.Trimesh
Resulting extrusion as watertight body
"""
# create a triangulation from the polygon
vertices, faces = triangulate_polygon(polygon, **kwargs)
# extrude that triangulation along Z
mesh = extrude_triangulation(
vertices=vertices, faces=faces, height=height, transform=transform, **kwargs
)
return mesh
def sweep_polygon(
polygon: "Polygon",
path: ArrayLike,
angles: Optional[ArrayLike] = None,
cap: bool = True,
connect: bool = True,
kwargs: Optional[Dict] = None,
**triangulation,
) -> Trimesh:
"""
Extrude a 2D polygon into a 3D mesh along a 3D path. Note that this
does *not* handle the case where there is very sharp curvature leading
the polygon to intersect the plane of a previous slice, and does *not*
scale the polygon along the induced normal to result in a constant cross section.
You may want to resample your path with a B-spline, i.e:
`trimesh.path.simplify.resample_spline(path, smooth=0.2, count=100)`
Parameters
----------
polygon : shapely.geometry.Polygon
Profile to sweep along path
path : (n, 3) float
A path in 3D
angles : (n,) float
Optional rotation angle relative to prior vertex
at each vertex.
cap
If an open path is passed apply a cap to both ends.
connect
If a closed path is passed connect the sweep into
a single watertight mesh.
kwargs : dict
Passed to the mesh constructor.
**triangulation
Passed to `triangulate_polygon`, i.e. `engine='triangle'`
Returns
-------
mesh : trimesh.Trimesh
Geometry of result
"""
path = np.asanyarray(path, dtype=np.float64)
if not util.is_shape(path, (-1, 3)):
raise ValueError("Path must be (n, 3)!")
if angles is not None:
angles = np.asanyarray(angles, dtype=np.float64)
if angles.shape != (len(path),):
raise ValueError(angles.shape)
else:
# set all angles to zero
angles = np.zeros(len(path), dtype=np.float64)
# check to see if path is closed i.e. first and last vertex are the same
closed = np.linalg.norm(path[0] - path[-1]) < tol.merge
# Extract 2D vertices and triangulation
vertices_2D, faces_2D = triangulate_polygon(polygon, **triangulation)
# stack the `(n, 3)` faces into `(3 * n, 2)` edges
edges = faces_to_edges(faces_2D)
# edges which only occur once are on the boundary of the polygon
# since the triangulation may have subdivided the boundary of the
# shapely polygon, we need to find it again
edges_unique = grouping.group_rows(np.sort(edges, axis=1), require_count=1)
# subset the vertices to only ones included in the boundary
unique, inverse = np.unique(edges[edges_unique].reshape(-1), return_inverse=True)
# take only the vertices in the boundary
# and stack them with zeros and ones so we can use dot
# products to transform them all over the place
vertices_tf = np.column_stack(
(vertices_2D[unique], np.zeros(len(unique)), np.ones(len(unique)))
)
# the indices of vertices_tf
boundary = inverse.reshape((-1, 2))
# now create the normals for the plane each slice will lie on
# consider the simple path with 3 vertices and therefore 2 vectors:
# - the first plane will be exactly along the first vector
# - the second plane will be the average of the two vectors
# - the last plane will be exactly along the last vector
# and each plane origin will be the corresponding vertex on the path
vector = util.unitize(path[1:] - path[:-1])
# unitize instead of / 2 as they may be degenerate / zero
vector_mean = util.unitize(vector[1:] + vector[:-1])
# collect the vectors into plane normals
normal = np.concatenate([[vector[0]], vector_mean, [vector[-1]]], axis=0)
if closed and connect:
# if we have a closed loop average the first and last planes
normal[0] = util.unitize(normal[[0, -1]].mean(axis=0))
# planes should have one unit normal and one vertex each
assert normal.shape == path.shape
# get the spherical coordinates for the normal vectors
theta, phi = util.vector_to_spherical(normal).T
# collect the trig values into numpy arrays we can compose into matrices
cos_theta, sin_theta = np.cos(theta), np.sin(theta)
cos_phi, sin_phi = np.cos(phi), np.sin(phi)
cos_roll, sin_roll = np.cos(angles), np.sin(angles)
# we want a rotation which will be the identity for a Z+ vector
# this was constructed and unrolled from the following sympy block
# theta, phi, roll = sp.symbols("theta phi roll")
# matrix = (
# tf.rotation_matrix(roll, [0, 0, 1]) @
# tf.rotation_matrix(phi, [1, 0, 0]) @
# tf.rotation_matrix((sp.pi / 2) - theta, [0, 0, 1])
# ).inv()
# matrix.simplify()
# shorthand for stacking
zeros = np.zeros(len(theta))
ones = np.ones(len(theta))
# stack initially as one unrolled matrix per row
transforms = np.column_stack(
[
-sin_roll * cos_phi * cos_theta + sin_theta * cos_roll,
sin_roll * sin_theta + cos_phi * cos_roll * cos_theta,
sin_phi * cos_theta,
path[:, 0],
-sin_roll * sin_theta * cos_phi - cos_roll * cos_theta,
-sin_roll * cos_theta + sin_theta * cos_phi * cos_roll,
sin_phi * sin_theta,
path[:, 1],
sin_phi * sin_roll,
-sin_phi * cos_roll,
cos_phi,
path[:, 2],
zeros,
zeros,
zeros,
ones,
]
).reshape((-1, 4, 4))
if tol.strict:
# make sure that each transform moves the Z+ vector to the requested normal
for n, matrix in zip(normal, transforms):
check = tf.transform_points([[0.0, 0.0, 1.0]], matrix, translate=False)[0]
assert np.allclose(check, n)
# apply transforms to prebaked homogeneous coordinates
vertices_3D = np.concatenate(
[np.dot(vertices_tf, matrix.T) for matrix in transforms], axis=0
)[:, :3]
# now construct the faces with one group of boundary faces per slice
stride = len(unique)
boundary_next = boundary + stride
faces_slice = np.column_stack(
[boundary, boundary_next[:, :1], boundary_next[:, ::-1], boundary[:, 1:]]
).reshape((-1, 3))
# offset the slices
faces = [faces_slice + offset for offset in np.arange(len(path) - 1) * stride]
# connect only applies to closed paths
if closed and connect:
# the last slice will not be required
max_vertex = (len(path) - 1) * stride
# clip off the duplicated vertices
vertices_3D = vertices_3D[:max_vertex]
# apply the modulus in-place to a conservative subset
faces[-1] %= max_vertex
elif cap:
# these are indices of `vertices_2D` that were not on the boundary
# which can happen for triangulation algorithms that added vertices
# we don't currently support that but you could append the unconsumed
# vertices and then update the mapping below to reflect that
unconsumed = set(unique).difference(faces_2D.ravel())
if len(unconsumed) > 0:
raise NotImplementedError("triangulation added vertices: no logic to cap!")
# map the 2D faces to the order we used
mapped = np.zeros(unique.max() + 2, dtype=np.int64)
mapped[unique] = np.arange(len(unique))
# now should correspond to the first vertex block
cap_zero = mapped[faces_2D]
# winding will be along +Z so flip for the bottom cap
faces.append(np.fliplr(cap_zero))
# offset the end cap
faces.append(cap_zero + stride * (len(path) - 1))
if kwargs is None:
kwargs = {}
if "process" not in kwargs:
# we should be constructing clean meshes here
# so we don't need to run an expensive verex merge
kwargs["process"] = False
# stack the faces used
faces = np.concatenate(faces, axis=0)
# generate the mesh from the face data
mesh = Trimesh(vertices=vertices_3D, faces=faces, **kwargs)
if tol.strict:
# we should not have included any unused vertices
assert len(np.unique(faces)) == len(vertices_3D)
if cap:
# mesh should always be a volume if cap is true
assert mesh.is_volume
if closed and connect:
assert mesh.is_volume
assert mesh.body_count == 1
return mesh
def extrude_triangulation(
vertices: ArrayLike,
faces: ArrayLike,
height: Number,
transform: Optional[ArrayLike] = None,
**kwargs,
) -> Trimesh:
"""
Extrude a 2D triangulation into a watertight mesh.
Parameters
----------
vertices : (n, 2) float
2D vertices
faces : (m, 3) int
Triangle indexes of vertices
height : float
Distance to extrude triangulation
transform : None or (4, 4) float
Transform to apply to mesh after construction
**kwargs : dict
Passed to Trimesh constructor
Returns
---------
mesh : trimesh.Trimesh
Mesh created from extrusion
"""
vertices = np.asanyarray(vertices, dtype=np.float64)
height = float(height)
faces = np.asanyarray(faces, dtype=np.int64)
if not util.is_shape(vertices, (-1, 2)):
raise ValueError("Vertices must be (n,2)")
if not util.is_shape(faces, (-1, 3)):
raise ValueError("Faces must be (n,3)")
if np.abs(height) < tol.merge:
raise ValueError("Height must be nonzero!")
# check the winding of the first few triangles
signs = np.array([np.cross(*i) for i in np.diff(vertices[faces[:10]], axis=1)])
# make sure the triangulation is aligned with the sign of
# the height we've been passed
if len(signs) > 0 and np.sign(signs.mean()) != np.sign(height):
faces = np.fliplr(faces)
# stack the (n,3) faces into (3*n, 2) edges
edges = faces_to_edges(faces)
edges_sorted = np.sort(edges, axis=1)
# edges which only occur once are on the boundary of the polygon
# since the triangulation may have subdivided the boundary of the
# shapely polygon, we need to find it again
edges_unique = grouping.group_rows(edges_sorted, require_count=1)
# (n, 2, 2) set of line segments (positions, not references)
boundary = vertices[edges[edges_unique]]
# we are creating two vertical triangles for every 2D line segment
# on the boundary of the 2D triangulation
vertical = np.tile(boundary.reshape((-1, 2)), 2).reshape((-1, 2))
vertical = np.column_stack((vertical, np.tile([0, height, 0, height], len(boundary))))
vertical_faces = np.tile([3, 1, 2, 2, 1, 0], (len(boundary), 1))
vertical_faces += np.arange(len(boundary)).reshape((-1, 1)) * 4
vertical_faces = vertical_faces.reshape((-1, 3))
# stack the (n,2) vertices with zeros to make them (n, 3)
vertices_3D = util.stack_3D(vertices)
# a sequence of zero- indexed faces, which will then be appended
# with offsets to create the final mesh
faces_seq = [faces[:, ::-1], faces.copy(), vertical_faces]
vertices_seq = [vertices_3D, vertices_3D.copy() + [0.0, 0, height], vertical]
# append sequences into flat nicely indexed arrays
vertices, faces = util.append_faces(vertices_seq, faces_seq)
if transform is not None:
# apply transform here to avoid later bookkeeping
vertices = tf.transform_points(vertices, transform)
# if the transform flips the winding flip faces back
# so that the normals will be facing outwards
if tf.flips_winding(transform):
# fliplr makes arrays non-contiguous
faces = np.ascontiguousarray(np.fliplr(faces))
# create mesh object with passed keywords
mesh = Trimesh(vertices=vertices, faces=faces, **kwargs)
# only check in strict mode (unit tests)
if tol.strict:
assert mesh.volume > 0.0
return mesh
def triangulate_polygon(
polygon, triangle_args: Optional[str] = None, engine: Optional[str] = None, **kwargs
):
"""
Given a shapely polygon create a triangulation using a
python interface to the permissively licensed `mapbox-earcut`
or the more robust `triangle.c`.
> pip install triangle
> pip install mapbox_earcut
Parameters
---------
polygon : Shapely.geometry.Polygon
Polygon object to be triangulated.
triangle_args : str or None
Passed to triangle.triangulate i.e: 'p', 'pq30', 'pY'="don't insert vert"
engine : None or str
None or 'earcut' will use earcut, 'triangle' will use triangle
Returns
--------------
vertices : (n, 2) float
Points in space
faces : (n, 3) int
Index of vertices that make up triangles
"""
if engine is None or engine == "earcut":
# get vertices as sequence where exterior
# is the first value
vertices = [np.array(polygon.exterior.coords)]
vertices.extend(np.array(i.coords) for i in polygon.interiors)
# record the index from the length of each vertex array
rings = np.cumsum([len(v) for v in vertices])
# stack vertices into (n, 2) float array
vertices = np.vstack(vertices)
# run triangulation
faces = (
_tri_earcut(vertices, rings)
.reshape((-1, 3))
.astype(np.int64)
.reshape((-1, 3))
)
return vertices, faces
elif engine == "triangle":
from triangle import triangulate
# set default triangulation arguments if not specified
if triangle_args is None:
triangle_args = "p"
# turn the polygon in to vertices, segments, and holes
arg = _polygon_to_kwargs(polygon)
# run the triangulation
result = triangulate(arg, triangle_args)
return result["vertices"], result["triangles"]
else:
log.warning(
"try running `pip install mapbox-earcut`"
+ "or explicitly pass:\n"
+ '`triangulate_polygon(*args, engine="triangle")`\n'
+ "to use the non-FSF-approved-license triangle engine"
)
raise ValueError("no valid triangulation engine!")
def _polygon_to_kwargs(polygon) -> Dict:
"""
Given a shapely polygon generate the data to pass to
the triangle mesh generator
Parameters
---------
polygon : Shapely.geometry.Polygon
Input geometry
Returns
--------
result : dict
Has keys: vertices, segments, holes
"""
if not polygon.is_valid:
raise ValueError("invalid shapely polygon passed!")
def round_trip(start, length):
"""
Given a start index and length, create a series of (n, 2) edges which
create a closed traversal.
Examples
---------
start, length = 0, 3
returns: [(0,1), (1,2), (2,0)]
"""
tiled = np.tile(np.arange(start, start + length).reshape((-1, 1)), 2)
tiled = tiled.reshape(-1)[1:-1].reshape((-1, 2))
tiled = np.vstack((tiled, [tiled[-1][-1], tiled[0][0]]))
return tiled
def add_boundary(boundary, start):
# coords is an (n, 2) ordered list of points on the polygon boundary
# the first and last points are the same, and there are no
# guarantees on points not being duplicated (which will
# later cause meshpy/triangle to shit a brick)
coords = np.array(boundary.coords)
# find indices points which occur only once, and sort them
# to maintain order
unique = np.sort(grouping.unique_rows(coords)[0])
cleaned = coords[unique]
vertices.append(cleaned)
facets.append(round_trip(start, len(cleaned)))
# holes require points inside the region of the hole, which we find
# by creating a polygon from the cleaned boundary region, and then
# using a representative point. You could do things like take the mean of
# the points, but this is more robust (to things like concavity), if
# slower.
test = Polygon(cleaned)
holes.append(np.array(test.representative_point().coords)[0])
return len(cleaned)
# sequence of (n,2) points in space
vertices = collections.deque()
# sequence of (n,2) indices of vertices
facets = collections.deque()
# list of (2) vertices in interior of hole regions
holes = collections.deque()
start = add_boundary(polygon.exterior, 0)
for interior in polygon.interiors:
try:
start += add_boundary(interior, start)
except BaseException:
log.warning("invalid interior, continuing")
continue
# create clean (n,2) float array of vertices
# and (m, 2) int array of facets
# by stacking the sequence of (p,2) arrays
vertices = np.vstack(vertices)
facets = np.vstack(facets).tolist()
# shapely polygons can include a Z component
# strip it out for the triangulation
if vertices.shape[1] == 3:
vertices = vertices[:, :2]
result = {"vertices": vertices, "segments": facets}
# holes in meshpy lingo are a (h, 2) list of (x,y) points
# which are inside the region of the hole
# we added a hole for the exterior, which we slice away here
holes = np.array(holes)[1:]
if len(holes) > 0:
result["holes"] = holes
return result
def box(
extents: Optional[ArrayLike] = None,
transform: Optional[ArrayLike] = None,
bounds: Optional[ArrayLike] = None,
**kwargs,
):
"""
Return a cuboid.
Parameters
------------
extents : (3,) float
Edge lengths
transform: (4, 4) float
Transformation matrix
bounds : None or (2, 3) float
Corners of AABB, overrides extents and transform.
**kwargs:
passed to Trimesh to create box
Returns
------------
geometry : trimesh.Trimesh
Mesh of a cuboid
"""
# vertices of the cube from reference
vertices = np.array(_data["box"]["vertices"], order="C", dtype=np.float64)
faces = np.array(_data["box"]["faces"], order="C", dtype=np.int64)
face_normals = np.array(_data["box"]["face_normals"], order="C", dtype=np.float64)
# resize cube based on passed extents
if bounds is not None:
if transform is not None or extents is not None:
raise ValueError("`bounds` overrides `extents`/`transform`!")
bounds = np.array(bounds, dtype=np.float64)
if bounds.shape != (2, 3):
raise ValueError("`bounds` must be (2, 3) float!")
extents = np.ptp(bounds, axis=0)
vertices *= extents
vertices += bounds[0]
elif extents is not None:
extents = np.asanyarray(extents, dtype=np.float64)
if extents.shape != (3,):
raise ValueError("Extents must be (3,)!")
vertices -= 0.5
vertices *= extents
else:
vertices -= 0.5
extents = np.asarray((1.0, 1.0, 1.0), dtype=np.float64)
if "metadata" not in kwargs:
kwargs["metadata"] = {}
kwargs["metadata"].update({"shape": "box", "extents": extents})
box = Trimesh(
vertices=vertices, faces=faces, face_normals=face_normals, process=False, **kwargs
)
# do the transform here to preserve face normals
if transform is not None:
box.apply_transform(transform)
return box
def icosahedron(**kwargs) -> Trimesh:
"""
Create an icosahedron, one of the platonic solids which is has 20 faces.
Parameters
------------
kwargs : dict
Passed through to `Trimesh` constructor.
Returns
-------------
ico : trimesh.Trimesh
Icosahederon centered at the origin.
"""
# get stored pre-baked primitive values
vertices = np.array(_data["icosahedron"]["vertices"], dtype=np.float64)
faces = np.array(_data["icosahedron"]["faces"], dtype=np.int64)
return Trimesh(
vertices=vertices, faces=faces, process=kwargs.pop("process", False), **kwargs
)
def icosphere(subdivisions: Integer = 3, radius: Number = 1.0, **kwargs):
"""
Create an isophere centered at the origin.
Parameters
----------
subdivisions : int
How many times to subdivide the mesh.
Note that the number of faces will grow as function of
4 ** subdivisions, so you probably want to keep this under ~5
radius : float
Desired radius of sphere
kwargs : dict
Passed through to `Trimesh` constructor.
Returns
---------
ico : trimesh.Trimesh
Meshed sphere
"""
radius = float(radius)
subdivisions = int(subdivisions)
ico = icosahedron()
ico._validate = False
for _ in range(subdivisions):
ico = ico.subdivide()
vectors = ico.vertices
scalar = np.sqrt(np.dot(vectors**2, [1, 1, 1]))
unit = vectors / scalar.reshape((-1, 1))
ico.vertices += unit * (radius - scalar).reshape((-1, 1))
# if we didn't subdivide we still need to refine the radius
if subdivisions <= 0:
vectors = ico.vertices
scalar = np.sqrt(np.dot(vectors**2, [1, 1, 1]))
unit = vectors / scalar.reshape((-1, 1))
ico.vertices += unit * (radius - scalar).reshape((-1, 1))
if "color" in kwargs:
warnings.warn(
"`icosphere(color=...)` is deprecated and will "
+ "be removed in June 2024: replace with Trimesh constructor "
+ "kewyword argument `icosphere(face_colors=...)`",
category=DeprecationWarning,
stacklevel=2,
)
kwargs["face_colors"] = kwargs.pop("color")
return Trimesh(
vertices=ico.vertices,
faces=ico.faces,
metadata={"shape": "sphere", "radius": radius},
process=kwargs.pop("process", False),
**kwargs,
)
def uv_sphere(
radius: Number = 1.0,
count: Optional[ArrayLike] = None,
transform: Optional[ArrayLike] = None,
**kwargs,
) -> Trimesh:
"""
Create a UV sphere (latitude + longitude) centered at the
origin. Roughly one order of magnitude faster than an
icosphere but slightly uglier.
Parameters
----------
radius : float
Radius of sphere
count : (2,) int
Number of latitude and longitude lines
transform : None or (4, 4) float
Transform to apply to mesh after construction
kwargs : dict
Passed thgrough
Returns
----------
mesh : trimesh.Trimesh
Mesh of UV sphere with specified parameters
"""
# set the resolution of the uv sphere
if count is None:
count = np.array([32, 64], dtype=np.int64)
else:
count = np.array(count, dtype=np.int64)
count += np.mod(count, 2)
count[1] *= 2
# generate the 2D curve for the UV sphere
theta = np.linspace(0.0, np.pi, num=count[0])
linestring = np.column_stack((np.sin(theta), -np.cos(theta))) * radius
# revolve the curve to create a volume
return revolve(
linestring=linestring,
sections=count[1],
transform=transform,
metadata={"shape": "sphere", "radius": radius},
**kwargs,
)
def capsule(
height: Number = 1.0,
radius: Number = 1.0,
count: Optional[ArrayLike] = None,
transform: Optional[ArrayLike] = None,
):
"""
Create a mesh of a capsule, or a cylinder with hemispheric ends.
Parameters
----------
height : float
Center to center distance of two spheres
radius : float
Radius of the cylinder and hemispheres
count : (2,) int
Number of sections on latitude and longitude
transform : None or (4, 4) float
Transform to apply to mesh after construction
Returns
----------
capsule : trimesh.Trimesh
Capsule geometry with:
- cylinder axis is along Z
- one hemisphere is centered at the origin
- other hemisphere is centered along the Z axis at height
"""
if count is None:
count = np.array([32, 64], dtype=np.int64)
else:
count = np.array(count, dtype=np.int64)
count += np.mod(count, 2)
height = abs(float(height))
radius = abs(float(radius))
# create a half circle
theta = np.linspace(-np.pi / 2.0, np.pi / 2.0, count[0])
linestring = np.column_stack((np.cos(theta), np.sin(theta))) * radius
# offset the top and bottom by half the height
half = len(linestring) // 2
linestring[:half][:, 1] -= height / 2.0
linestring[half:][:, 1] += height / 2.0
return revolve(
linestring,
sections=count[1],
transform=transform,
metadata={"shape": "capsule", "height": height, "radius": radius},
)
def cone(
radius: Number,
height: Number,
sections: Optional[Integer] = None,
transform: Optional[ArrayLike] = None,
**kwargs,
) -> Trimesh:
"""
Create a mesh of a cone along Z centered at the origin.
Parameters
----------
radius : float
The radius of the cylinder
height : float
The height of the cylinder
sections : int or None
How many pie wedges per revolution
transform : (4, 4) float or None
Transform to apply after creation
**kwargs : dict
Passed to Trimesh constructor
Returns
----------
cone: trimesh.Trimesh
Resulting mesh of a cone
"""
# create the 2D outline of a cone
linestring = [[0, 0], [radius, 0], [0, height]]
# revolve the profile to create a cone
if "metadata" not in kwargs:
kwargs["metadata"] = {}
kwargs["metadata"].update({"shape": "cone", "radius": radius, "height": height})
cone = revolve(
linestring=linestring, sections=sections, transform=transform, **kwargs
)
return cone
def cylinder(
radius: Number,
height: Optional[Number] = None,
sections: Optional[Integer] = None,
segment: Optional[ArrayLike] = None,
transform: Optional[ArrayLike] = None,
**kwargs,
):
"""
Create a mesh of a cylinder along Z centered at the origin.
Parameters
----------
radius : float
The radius of the cylinder
height : float or None
The height of the cylinder, or None if `segment` has been passed.
sections : int or None
How many pie wedges should the cylinder have
segment : (2, 3) float
Endpoints of axis, overrides transform and height
transform : None or (4, 4) float
Transform to apply to mesh after construction
**kwargs:
passed to Trimesh to create cylinder
Returns
----------
cylinder: trimesh.Trimesh
Resulting mesh of a cylinder
"""