-
Notifications
You must be signed in to change notification settings - Fork 28
Expand file tree
/
Copy pathcore.py
More file actions
3461 lines (3037 loc) · 173 KB
/
core.py
File metadata and controls
3461 lines (3037 loc) · 173 KB
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
r"""
Name : skinner.core.py
Author : Eric Pavey - warpcat@gmail.com - www.akeric.com
Creation Date : 2019-09-28
Description :
Import/export skin weights. It just works, fast.
For the UI, see skinner.window.py
Dependencies:
* Python 3.x+
* numpy & scipy on the path for import. To install them for
your version of Maya, see the confirmDependencies function.
Much learning around querying/setting skinCluster data via Maya API was found
from these sources:
https://gist.github.com/utatsuya/a95afe3c5523ab61e61b
http://ueta.hateblo.jp/entry/2015/08/24/102937
Other links:
http://www.charactersetup.com/tutorial_skinWeights.html
To Do:
* Weight Depot tab / functionality?
* Provide option to save out as json? change current format from .sknr to
.sknrp (pickle) and make .sknrj for the json? Something else like maybe
_sknr.pkl (but that's Py2, Py3 seems to use .pickle) and _sknr.json ?
* Update the SkinChunk.__init__ creation logic to store 'per-joint at bindpose'
data, in addition to the mesh being at bindpose or not.
Updates:
2021-09-26 : v1.0.0 : Ready for release.
2021-10-01 : v1.0.1 : Updating getMeshVertIds to find all child mesh under
the current selection. Bugfix divideByZeroError in setWeights. Bugfix
in closestNeighborsWeights where -nan values were being set. Updating
exportSkinChunks to allow for version control integration.
2021-10-07 : v1.0.2 : Bugfixing setWeights : it wasn't handling importing a
subset of weights on a whole mesh: This is now fixed. Added unlockInfluences
and updated requiring code (setWeights, addInfluences). Updating generateSkinChunks
to have a progress bar.
2021-10-08 : v1.0.3 : Bugfixing setWeights to use the corect value for skin
smoothing. Changing the setWeights arg postSmooth from a bool to an int.
2021-10-18 : v1.0.4 : Bugfix to exportSkinChunks : Fixing pathing to use forwardslahes
for version control call.
2021-10-20 : v1.0.5 : Updating setWeights & importSkin to track skin weight
import failures, and not crash the whole system if they happen.
2021-10-22 : v1.0.6 : Adding SkinChunk.getByVertCountOrder and updating setWeights
with new matchByVertCountOrder arg to make use of it. Updating to allow
for export / import on scenes with duplicate mesh names: Tracking stuff
better by full path. Bugfixing how skin smoothing works, which was causing
smoothed mesh to deform poorly.
2021-10-23 : v1.0.7 : Refactor many internal functions into the utils.py module.
2021-10-25 : v1.0.8 : Updating importSkin to have new printOverviewMode arg.
Updating generateSkinChunks & setToBindPose + calling code with setToBindPose arg.
2021-11-05 : v1.0.10 : Updating to remove calls to the scripted plugin based
on a new soltuion that runs faster without it.
2021-11-08 : v1.0.12 : Bugfixing SkinChunk.__init__ to better handle duplicate
names in the scene. Bugfixing setWeights to handle mesh and joints with
the same names.
2021-11-10 : 1.0.13 : Working around Maya bug in setWeights where hidden mesh
shape nodes will fail to have new skinCluster nodes generated for them.
2021-12-02 : v1.0.14 : Updating generateSkinChunks & setWeights to detect for
incompatible normalizeWeights values, and offer to auto-correct.
2021-12-03 : v1.0.15 : Updating core.setWeights to update the return to include
any new influences created. Updating exportSkin, exportTempSkin, and
importTempSkin to all have consistent 'item' parameters/args with importSkin.
2021-12-07 : v1.0.16 : Adding version info to SkinChunks. Small bugfix to
SkinChunk.printData for numVerts.
2021-12-13 :v1.0.17 : Updating generateSkinChunks to handle buggy imported
FBX data that was setting skinCluster.skinningMethod to -1 (invalid).
2021-12-15 : v1.1.0 : New feature to also query/store worldspace positions during
export based on the pre-deformed (aka intermediateObject) shape node, in
addition to the post-deformed one. Updating SkinChunk, generateSkinChunks,
and exportSkin to support this. In the process set many args that were
setToBindPose=True to False. Bugfix to setWeights undoqueue, that was
applying bad weights when only a subset should be imported on. Bugfix
to 'double not' statement at the bottom of this module. Not sure how
that slipped in... Provide more examples below.
2021-12-19 : v1.1.1 : Updating SkinChunks to store local transformations and
rotateOrder values on joints. Bugfixing setWeights to properly set influences
to bindpose on mesh that didn't yet have skinning, based on the influences
stored in the SkinChunk data. SkinChunks now track if they weren't at the
bindpose when created.
2021-12-30 : v1.1.2 : Updating all source to use Python3 type hint notation.
2022-01-06 : v1.1.4 : Updating setWeights skinCluster smoothing code with a
fixed tolerance value.
2022-03-09 : v1.1.7 : Updating setWeights with new postSmoothWeightDiff arg.
Changing the default post smooth diff value from .01 to .25, to help resolve
odd skinning bugs.
2022-03-31 : v1.1.8 : Bugfixing string formatting error in setWeights.
2022-05-18 : v1.1.9 : Updating closestPointKdTree to allow numNeighbors=1.
Before that it would be auto-set to 2, based on my not understanding how
the ndarray return values worked. Also updating it to support older versions
of KDTree that don't have the 'workers' arg.
2024-06-04 : v1.1.11 : Bugfixing tool to properly paste weights on selected
verts. Specifically updating setWeights to leverage the new utils.transposeWeights
to sort SkinChunk weights in the same order as the influences on the skinCluster.
Also raising more expections if 'selectVertsOnly' is set and operations
would happen that would change the skinning. Various verbose logging formatting
changes.
2024-06-10 : v1.2.0 : Setting setWeights's unskinFirst arg default to False,
was True. Adding regenrateSkinCluster. Adding new tempFilePath arg, and
kwargs capturing to both exportTempSkin and importTempSkin. Updating
the undoChunk closing code with specific names.
2024-10-02 : v1.2.1 : Bufixing importSkinChunks if multiple were imported at
once : could have been duplicating them up / confusing itself.
2026-03-19 : v1.3.1 : Updating importSkinChunks to provide better execption
reporting to users when trying to import a .sknr file saved with a different
version of numpy than what is current.
2026-04-24 : v1.3.2 : Hardening exportTempSkin/importTempSkin to normalize and
consistently use the provided tempFilePath, improve conflicting kwargs
handling for filePath/filePaths, and provide clearer filesystem errors.
2026-05-03 : v1.5.1 : .sknr pickle now stores NumPy arrays as dtype/shape/raw
bytes so files load across different NumPy major versions (forward saves).
Examples:
For all below:
import skinner.core as skinCore
#-----------
# Run the test suite. This will prompt the user to continue via a dialog since
# it is destructive to the scene (creates a new one).
skinCore.test()
#-----------
# Export and import temp weights on selection
# Select mesh/components etc and :
skinCore.exportTempSkin()
# Select other mesh/components etc and:
skinCore.importTempSkin()
#-----------
# Export / import skin weights on a defined lists of mesh/components to a given file.
filePath = "C:/path/to/some/skinner/file.sknr"
# Export the items to the file:
exportItems = [list of mesh and component names]
exportResults = skinCore.exportSkin(items=exportItems, filePath=filePath)
# Import onto other items. note the filePaths arg can take a single string, or
# a list of paths:
importItems = [list of mesh and component names]
importResults = skinCore.importSkin(items=importItems, filePaths=filePath)
"""
import os
import sys
import time
import pickle
import itertools
import tempfile
import traceback
from datetime import datetime
from collections import OrderedDict
import maya.cmds as mc
import maya.api.OpenMaya as om2
# See notes above for install
from skinner import __documentation__
try:
import numpy as np
except ImportError:
#om2.MGlobal.displayError(f"skinner.core : NumPy isn't installed, skinner won't function, see the docs: {__documentation__}")
np = None
try:
from scipy.spatial import KDTree
except ImportError:
#om2.MGlobal.displayError(f"skinner.core : SciPy isn't installed, skinner won't function, see the docs: {__documentation__}")
KDTree = None
from . import utils
if not np or not KDTree or not str(sys.version).startswith("3"):
utils.confirmDependencies()
from . import __version__
#---------------------------
# Same list indexed values values as the skinCluster.skinningMethod enum
SKIN_METHODS = ("classic linear", "dual quaternion", "weight blended")
EXT = "sknr"
TEMP_DIR = os.path.join(tempfile.gettempdir(), "skinner")
TEMP_FILE = f"temp.{EXT}"
TEMP_FILE_PATH = os.path.join(TEMP_DIR, TEMP_FILE)
TEMP_FILE_REGEN = f"temp_regen.{EXT}"
# Maya optionVar settings
OV_LAST_SAVE_PATH = "ov_skinner__lastSavePath"
# Used as a default arg in closestPointKdTree to set multithreading in KDTree.query()
gMultiThread = True
#---------------------------------
# Utils
def printWeightFile(filePath:str, importVerbose=False, **kwargs):
r"""
Print data in the provided weight file path.
Parameters:
filePath : string : The full path to the weight file to print.
importVerbose : bool : Default False : Should the import process also print info?
kwargs : keyword arguments to pass directly to the SkinChunk.printData method.
"""
skinChunks = importSkinChunks(filePath, verbose=importVerbose)
print("SkinChunk Data for : %s"%filePath)
for skinChunk in skinChunks:
skinChunk.printData(**kwargs)
#-------------------------------------------------------------------------------
# Closest Point Algorithms
def closestPointExample(points:np.ndarray, targets:np.ndarray, numNeighbors:int) -> tuple:
r"""
This is an example of writing your own closest point wrapper function: Follow
the signature of the parameters/arguments/return.
Parameters
points : ndarray[n][3] : The 3D points we're querying for. Aka, the 'verts
getting weights loaded on them', in that vert ID order.
targets : ndarray[n][3] : All the 3D points being tested against: Their
order represents the vert index order. They'er what the kdTree is being
made on.
numNeighbors : int : The number of closest neighbors to find/return.
Return : tuple : both are ndarrays of the same length in the vert order of the
passed in points array.
distances : ndarray[x][y] where x is the length of the points arg, and y
is the numNeighbors arg. These are the closest target distances in
order, from closest to furthest, based on the target point indexes
array, next:
indexes : ndarray[x][y] where x is the length of the points arg, and y
is the numNeighbors arg. These are the closest target indices in
order, from closest to furthest, based on the corresponding distances
array, above.
"""
raise NotImplementedError()
def closestPointKdTree(points:np.ndarray, targets:np.ndarray, numNeighbors:int) -> tuple:
r"""
Find the closest point(s) based on the scipy.spatial.KDTree algorithm
https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.KDTree.html
Very fast!
This also uses the global gMultiThread to determine if it should use all threads
for the compute.
Parameters
points : ndarray[n][3] : The 3D points we're querying for. Aka, the 'verts
getting weights loaded on them', in that vert ID order.
targets : ndarray[n][3] : All the 3D points being tested against: Their
order represents the vert index order. They're what the kdTree is being
made on; What originally had weights saved on them.
numNeighbors : int : The number of closest neighbors to find/return.
Return : tuple : both are ndarrays of the same length in the vert order of the
passed in points array. It is the direct return from KDTree.query()
distances : ndarray[x][y] where x is the length of the points arg, and y
is the size of numNeighbors arg. These are the closest target distances
in order, from closest to furthest, based on the target point indexes
array, next:
indexes : ndarray[x][y] where x is the length of the points arg, and y
is the size of numNeighbors arg. These are the closest target indices
in order, from closest to furthest, based on the corresponding distances
array, above.
"""
global gMultiThread
if not KDTree:
raise ImportError("Unable to import the scipy.spatial module to access the KDTree class")
if len(targets) < numNeighbors:
numNeighbors = len(targets)
workers = 1 # The KDTree.query default : Use 1 processor.
if gMultiThread:
workers = -1 # use all'dem
# Build and query a kdTree for our target points, then return the results
# checking them against our sample points:
# https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.KDTree.query.html#scipy.spatial.KDTree.query
try:
distances, indexes = KDTree(targets).query(points, numNeighbors, workers=workers)
except:
# Older versions of KDTree don't support the workers arg.
distances, indexes = KDTree(targets).query(points, numNeighbors)
if numNeighbors == 1:
# If we only query one closest distance, numpy will return an array of scalars,
# not an array of arrays. For consistency, we want it to always return
# an array of arrays.
distances = [[item] for item in distances]
indexes = [[item] for item in indexes]
return (distances, indexes)
def closestPointBruteForce(points:np.ndarray, targets:np.ndarray, numNeighbors:int) -> tuple:
r"""
Find closest point by brute force. The more the targets, the slower it gets.
Can use this to compare your wiz-bang algorithms against, and feel better
about yourself.
Parameters
points : ndarray[n][3] : The 3D points we're querying for. Aka, the 'verts
getting weights loaded on them', in that vert ID order.
targets : ndarray[n][3] : All the 3D points being tested against: Their
order represents the vert index order. They'er what the kdTree is being
made on.
numNeighbors : int : The number of closest neighbors to find/return.
Return : tuple : both are ndarrays of the same length in the vert order of the
passed in points array.
distances : ndarray[x][y] where x is the length of the points arg, and y
is the numNeighbors arg. These are the closest target distances in
order, from closest to furthest, based on the target point indexes
array, next:
indexes : ndarray[x][y] where x is the length of the points arg, and y
is the numNeighbors arg. These are the closest target indices in
order, from closest to furthest, based on the corresponding distances
array, above.
"""
distances = []
indexes = []
if len(targets) < numNeighbors:
numNeighbors = len(targets)
for i,point in enumerate(points):
distIndices = []
for j, targPoint in enumerate(targets):
dist = np.linalg.norm(point-targPoint)
distIndices.append([dist, j])
distIndices = sorted(distIndices)
trim = numNeighbors
if len(distIndices) < trim:
trim = len(distIndices)
theseDist = []
theseIndexes = []
for k in range(trim):
theseDist.append(distIndices[k][0])
theseIndexes.append(distIndices[k][1])
distances.append(theseDist)
indexes.append(theseIndexes)
return np.array(distances), np.array(indexes)
#-------------------------------------------------------------------------------
# Weighting Algorithms
def closestNeighborsWeights(allSavedWeights:np.ndarray, allSavedBlendWeights:np.ndarray,
importVertPositions:np.ndarray, savedVertPositions:np.ndarray,
importVertNormals:list, savedVertNormals:list,
closestNeighborCount:int, closestNeighborDistMult:float,
closestPointFunc=closestPointKdTree,
filterByVertNormal=False, vertNormalTolerance=0.0) -> dict:
r"""
The algorithm used to cacluate new weights (and blendWeights) based on the
"closest neighbor's weights (or blendWeights)" to each target vert.
The "one sentence description" is:
"Look for target verts/points around the source vert/point based on a distance
tolerance, and based on those distances, calculate new weights linearly prioritizing
the weights closest to the source."
Example for weights. BlendWeights are slightly more simplistic since it's a
single value per vert, rather than an array of values per influence per vert,
for regular weights.
A : For each target vert position in the importVertPositions...
B : Find the closest neighbor points to it in savedVertPositions, based on the
closestNeighborCount (say, 3), in a 'vert pool'.
C : From that pool, find the closest point (item 0) to the target vert: Store
that distance. Say, it is 1 unit.
D : Based on the other verts in the pool, see if they within the
'closestNeighborDistMult * the closest distance': If they are
outside that distance, remove them from vert pool. For example, if
closestNeighborDistMult was 2, and 'the distance to the cloest vert' was
1.0, then it will search 2.0 units (1.0 * 2.0) around the target vert in
the vert pool for others that fall within that radius.
E : For that pool of verts, based on their distances,
calculate their 'normalized distances'. For example, if the distances
from the target verts were 1.0, 1.5, and 2.0 units, the corresponding normalized
distances are [0.22, 0.33, 0.44]. But that deprioritizes the closer weights,
so that is reversed to become: [0.44 0.33, 0.22].
F : Then for each of the influences affecting each of the verts in the pool,
an uber-list of weights is generated of those influece weights, each weight
being multiplied by corresponding normalized weight above, to prioritize
weights of influences closer to the target, and deprioritize weights further
from the target, based on the normalized distances.
G : That weight list is returned, for application to the skinCluster.
H : So in a nutshell, look for verts around the target, and based on
those distances, calculate new weights linearly prioritizing the weights
closest to the target.
Parameters:
allSavedWeights : ndarray[x][y] (weights) : The weights that were previously
saved, and now being loaded.
Ultimately this is the return from either UberChunk.getAllWeights() /
SkinChunk.getAllWeights().
allSavedBlendWeights : ndarray[x] : The 'blend weights' (if the skinCluster
type being imported on is 'weight blended') that were previously saved,
and now being loaded.
Ultimately this is the return from either
UberChunk.getAllBlendWeights() / SkinChunk.getAllBlendWeights(). While
somethiing must be provided, this can be an empty array to skip the compute.
If it is provided, it must be the same length as allSavedWeights.
importVertPositions : ndarray[n][3] : The 3d sample points for each worldspace
location for each source vert having weights applied to. The 'source points'.
savedVertPositions : ndarray[n][3] : The 3d space sample points in the
SkinChunk or UberChunk being sampled/loaded. The 'target points'.
importVertNormals : list : om2.MVector representation of the vert normal for
each vert having weights imported. Needs to be the same number as savedVertPositions.
savedVertNormals : list : om2.MVector representation of the vert normal for
each vert having weights loaded from, as loaded from a SkinChunk or UberChunk.
closestNeighborCount : int : How many target verts should be sampled to generate
the new weight influences. This is the max value, only verts found within
'closest first distance * closestNeighborDistMult' will be considered.
Values of 3-6 are standard. If this value is 0 or -1, or if filterByVertNormal
is used, it will be set to the total number of verts being imported on,
aka, len(savedVertPositions).
closestNeighborDistMult : float : This defines the 'search bubble distance'
when looking for other close target verts: If the closest target vert is
1 unit away, the tools will search with a radius of 1 unit * closestNeighborDistMult
for other target positions\influences. 2.0 is standard.
closestPointFunc : function/None : Default closestPointKdTree : If None, use
closestPointBruteForce : The 'closest point function' to use. Broken out
as an arg so you can pass in your own, if you got something faster than
what this tool uses. See the docstring of closestPointExample if you want
to roll your own.
filterByVertNormal : bool : Default False : If True, use the vertNormalTolerance
value to filter out verts that have opposing normals, to reduce grabbing
weights from mesh they shouldn't.
vertNormalTolerance : float : Default 0.0 : This is the dot product tolerance
used to determine if a vert/weight should be included in the algorithm:
If the source vert (the one getting skinning applied) and the target verts
(one with the saved weights) normals (vectors) both point the same direction,
the dot is 1.0. If the target is 90 deg off from the source, the dot is
0.0. If the target is 180 deg off from the source, the dot is -1 : Any
dot found less than this tolerance will be rejected.
Return : dict : key:values for:
* "weights": each item is a sublist of influence weights, per source vert.
* "blendWeights" : A single list of floats, presuming allSavedBlendWeights
was passed in.
"""
if importVertNormals:
assert len(importVertNormals) == len(importVertPositions), f"The number of 'import vert positions ({len(importVertPositions)}) doesn't match the number of import vert normals ({len(importVertNormals)})"
if filterByVertNormal and not importVertNormals:
raise Exception("importByVertNormal=True, but importVertNormals is empty.")
useBlendWeights = False
if isinstance(allSavedBlendWeights, type(np.array)):
assert len(allSavedWeights) == len(allSavedBlendWeights), "allSavedBlendWeights was provided, but its length (%s) is not equal to allSavedWeights (%s)"%(len(allSavedBlendWeights), len(allSavedWeights))
useBlendWeights = True
if closestPointFunc is None:
closestPointFunc = closestPointBruteForce
# Python list, since numpy can't append to arrays
newWeights = []
newBlendWeights = []
if closestNeighborCount < 1:
# use everything found.
closestNeighborCount = len(importVertPositions)
# If we're filtering by vert normals, we need to possilby compare against a
# lot more verts in the pool.
closestNeighborCountOverride = closestNeighborCount
if filterByVertNormal:
closestNeighborCountOverride = len(importVertNormals)
# Find the closest points including things that should be filtered out by our
# normal filter below.
#
# distancesArr[i][j] : For every pos [i] in importVertPositions, this is a
# ordered list of all the closest savedVertPoints [j]
#
# indexArr[i][j] : for every pos [i] in importVertPositions, this is the corresponding
# ordered index for the distances in distancesArr [j].
#
# So, the closest distance to importVertPositions[i] is distancesArr[i][0]
# The second closest distance to importVertPositions[i] is distancesArr[i][1]
# And, the closest index to importVertPositions[i] is indexArr[i][0]
# The second closest index to importVertPositions[i] is indexArr[i][1]
# etc.
distancesArr, indexArr = closestPointFunc(importVertPositions, savedVertPositions,
numNeighbors=closestNeighborCountOverride)
#for i,importVertPos in enumerate(importVertPositions):
for i in range(len(importVertPositions)):
# Start building our data: Since numpy can't append to arrays, we'll
# use Python lists here:
closestDistances = []
closestIndices = []
rejectedDistnaces = []
rejectedIndices = []
searchDist = distancesArr[i][0] * closestNeighborDistMult
for j in range(0, len(distancesArr[i])):
if len(closestIndices) >= closestNeighborCount:
break
checkIndex = indexArr[i][j]
checkDist = distancesArr[i][j]
if checkDist > searchDist and not filterByVertNormal:
break
normalReject = False
if filterByVertNormal:
dot = importVertNormals[i] * savedVertNormals[checkIndex]
# Compare the normal of this vert to the normal of the check vert,
# via the dot product:
if dot < vertNormalTolerance:
normalReject = True
if not normalReject:
closestIndices.append(checkIndex)
closestDistances.append(checkDist)
else:
rejectedIndices.append(checkIndex)
rejectedDistnaces.append(checkDist)
if not closestIndices and filterByVertNormal:
# We didn't find anything, based on our vert normal filter, so in this
# case, just use the rejected list.
maxIndex = min([closestNeighborCount, len(rejectedIndices)])
closestIndices = rejectedIndices[:maxIndex]
closestDistances = rejectedIndices[:maxIndex]
# We how have (up to) the (closestNeighborCount) closest indices, process
# what the weights should be.
numCloseIndices = len(closestIndices)
if numCloseIndices == 1:
# No magic or extra maths, just closest point, since only one point
# is close enough to sample based on our input arguments:
newWeights.append(allSavedWeights[int(closestIndices[0])])
if useBlendWeights:
newBlendWeights.append(allSavedBlendWeights[int(closestIndices[0])])
else:
# Teh Algorithzms
# Figure out how close this pos is to the closestIndex[i] pos
# Normalize that distance vs the closest.
# Then, based on that normalization, figure out all
# the weights for all the influences of each pos index,
# And write those weights out.
# If we passed in weights, each item is a sublist of weights for the closest indices
# If we passed in weightList, its a single list.
closesetIndexWeights = [allSavedWeights[int(index)] for index in closestIndices]
# Have hit bugs where closestDistances is a list of all zeroes. If so,
# average it all
if not any(closestDistances):
avg = 1.0 / len(closestDistances)
closestDistances = [avg for i in range(len(closestDistances))]
# Make all distances fit between 0->1.0
# CAN SET NAN if closestDistances is all zero
normalizedDistances = utils.normalizeToOne(closestDistances)
# However, this deprioritizes the weights of things closer, so
# we need to revere the results.
normalizedDistances.reverse()
# Handling weights : allSavedWeights is ndarray[x][y]
# Adjust the weights based on their proximity to the point.
# If we passed in weights, this is a list of sublists of weights: Each
# row is some point in space, and each column reflects an influence value.
weightByDist = []
for weightListIndex,weightList in enumerate(closesetIndexWeights):
wbd = [weight*normalizedDistances[weightListIndex] for weight in weightList]
weightByDist.append( wbd )
# Add up all the weights by row (axis0), across our multiple
# columns of weightLsits, so we have a single list
# of the weights per influence, but non-normalized:
sumedWeights = np.sum(weightByDist, axis=0)
# And finally normalize these weights between zero and one:
normalizedWeights = utils.normalizeToOne(sumedWeights)
newWeights.append(normalizedWeights)
if useBlendWeights:
# Handling blendWeights : allSavedBlendWeights is ndarray[x]
# Adjust the weights based on their proximity to the point.
# If we passed in blendWeights, this is a single list of values.
weightByDist = []
for weightListIndex,weight in enumerate(closesetIndexWeights):
weightByDist.append( weight*normalizedDistances[weightListIndex] )
# Add up the values of the normalized distances:
sumedWeights = np.sum(weightByDist, axis=0)
newBlendWeights.append(sumedWeights)
return {"weights":newWeights, "blendWeights":newBlendWeights}
def closestPointWeights(allSavedWeights:np.ndarray, allSavedBlendWeights:np.ndarray,
importVertPositions:np.ndarray, savedVertPositions:np.ndarray,
importVertNormals:list, savedVertNormals:list,
closestPointFunc=closestPointKdTree,
filterByVertNormal=False, vertNormalTolerance=0.0):
r"""
The 'closest point' algorithm used to find the weight/influences of the closest
target to each source. Pretty straight forward.
Parameters:
allSavedWeights : ndarray[x][y] (weights) : The weights that were previously
saved, and now being loaded.
Ultimately this is the return from either UberChunk.getAllWeights() /
SkinChunk.getAllWeights().
allSavedBlendWeights : ndarray[x] : The 'blend weights' (if the skinCluster
type being imported on is 'weight blended') that were previously saved,
and now being loaded.
Ultimately this is the return from either
UberChunk.getAllBlendWeights() / SkinChunk.getAllBlendWeights(). While
somethiing must be provided, this can be an empty array to skip the compute.
If it is provided, it must be the same length as allSavedWeights.
importVertPositions : ndarray[n][3] : The 3d sample points for each worldspace
location for each source vert having weights applied to. The 'source points'.
savedVertPositions : ndarray[n][3] : The 3d space sample points in the
SkinChunk or UberChunk being sampled/loaded. The 'target points'.
importVertNormals : list : om2.MVector representation of the vert normal for
each vert having weights imported. Needs to be the same number as savedVertPositions.
savedVertNormals : list : om2.MVector representation of the vert normal for
each vert having weights loaded from, as loaded from a SkinChunk or UberChunk.
closestPointFunc : function/None : Default closestPointKdTree : If None, use
closestPointBruteForce : The 'closest point function' to use. Broken out
as an arg so you can pass in your own, if you got something faster than
what this tool uses. See the docstring of closestPointExample if you want
to roll your own.
filterByVertNormal : bool : Default False : If True, use the vertNormalTolerance
value to filter out verts that have opposing normals, to reduce grabbing
weights from mesh they shouldn't.
vertNormalTolerance : float : Default 0.0 : This is the dot product tolerance
used to determine if a vert/weight should be included in the algorithm:
If the source vert (the one getting skinning applied) and the target verts
(one with the saved weights) normals (vectors) both point the same direction,
the dot is 1.0. If the target is 90 deg off from the source, the dot is
0.0. If the target is 180 deg off from the source, the dot is -1 : Any
dot found less than this tolerance will be rejected.
Return : dict : key:values for:
* "weights": each item is a sublist of influence weights, per source vert.
* "blendWeights" : A single list of floats, presuming allSavedBlendWeights
was passed in.
Return : dict : key:values for:
* "weights": each item is a sublist of influence weights, per source vert.
* "blendWeights" : A single list of floats, presuming allSavedBlendWeights
was passed in.
"""
if importVertNormals:
assert len(importVertNormals) == len(importVertPositions), f"The number of 'import vert positions ({len(importVertPositions)}) doesn't match the number of import vert normals ({len(importVertNormals)})"
if filterByVertNormal and not importVertNormals:
raise Exception("importByVertNormal=True, but importVertNormals is empty.")
useBlendWeights = False
if isinstance(allSavedBlendWeights, type(np.array)):
assert len(allSavedWeights) == len(allSavedBlendWeights), "allSavedBlendWeights was provided, but its length (%s) is not equal to allSavedWeights (%s)"%(len(allSavedBlendWeights), len(allSavedWeights))
useBlendWeights = True
if closestPointFunc is None:
closestPointFunc = closestPointBruteForce
newWeights = []
newBlendWeights = []
# Calculate our closest point data:
numNeighbors = 1
if filterByVertNormal:
numNeighbors = len(importVertPositions)
# Find the closest points including things that should be filtered out by our
# normal filter below.
#
# distancesArr[i][j] : For every pos [i] in importVertPositions, this is a
# ordered list of all the closest savedVertPoints [j]
#
# indexArr[i][j] : for every pos [i] in importVertPositions, this is the corresponding
# ordered index for the distances in distancesArr [j].
#
# So, the closest distance to importVertPositions[i] is distancesArr[i][0]
# The second closest distance to importVertPositions[i] is distancesArr[i][1]
# And, the closest index to importVertPositions[i] is indexArr[i][0]
# The second closest index to importVertPositions[i] is indexArr[i][1]
# etc.
distancesArr, indexArr = closestPointFunc(importVertPositions, savedVertPositions, numNeighbors=numNeighbors)
for i in range(len(importVertPositions)):
closestIndex = indexArr[i][0]
if filterByVertNormal:
closestNormalMatch = None
for j in range(0, len(distancesArr[i])):
checkIndex = indexArr[i][j]
dot = importVertNormals[i] * savedVertNormals[checkIndex]
# Compare the normal of this vert to the normal of the check vert,
# via the dot product:
if dot >= vertNormalTolerance:
closestNormalMatch = indexArr[i][j]
break
if closestNormalMatch:
closestIndex = closestNormalMatch
# if we don't find a closestNormalMatch based on any avilable normal
# just default to the closest point defined above.
newWeights.append(allSavedWeights[int(closestIndex)])
#print(i, closestIndex, allSavedWeights[int(closestIndex)])
if useBlendWeights:
newBlendWeights.append(allSavedBlendWeights[int(closestIndex)])
return {"weights":newWeights, "blendWeights":newBlendWeights}
#-----------------------------
# Portable ndarray pickling (avoids NumPy's pickle reconstructors in .sknr files)
# Dict key stored on each encoded array blob. A dedicated marker is required so
# _decode_from_pickle can tell Skinner's {dtype, shape, data} records apart from
# ordinary dicts that might legitimately use those same string keys (or older
# pickles that nested unrelated metadata). The "__skinner_..._v1__" name is
# namespaced and versioned so a future v2 layout can use a new constant without
# ambiguity.
_SKNR_NDARRAY_V1 = "__skinner_ndarray_v1__"
def _encode_ndarray_for_pickle(arr: np.ndarray) -> dict:
r"""
Serialize one ndarray to a plain dict safe for pickle without using NumPy's
internal pickle hooks (which differ across NumPy major versions).
Parameters
arr : np.ndarray
Array to encode (any dtype/shape used by Chunk skin data).
Return : dict
Keys: __skinner_ndarray_v1__, dtype (dtype.str), shape, and data
(C-contiguous raw bytes from tobytes).
"""
arr = np.ascontiguousarray(arr)
return {
_SKNR_NDARRAY_V1: True,
"dtype": arr.dtype.str,
"shape": tuple(arr.shape),
"data": arr.tobytes(order="C"),
}
def _is_portable_ndarray_dict(obj: object) -> bool:
r"""
Return True if obj is a dict produced by _encode_ndarray_for_pickle.
Parameters
obj : object
Candidate object during pickle decode walk.
Return : bool
"""
return isinstance(obj, dict) and obj.get(_SKNR_NDARRAY_V1) is True
def _decode_portable_ndarray(d: dict) -> np.ndarray:
r"""
Rebuild an ndarray from a v1 portable dict (inverse of
_encode_ndarray_for_pickle).
Empty arrays use np.empty; non-empty use frombuffer then reshape.
The result is copied so the array is writable (frombuffer views are
read-only), matching arrays built from Maya queries.
Parameters
d : dict
Must include dtype, shape, and data bytes.
Return : np.ndarray
Raises
ValueError
If len(data) does not match element count × dtype itemsize.
"""
dtype = np.dtype(d["dtype"])
shape = tuple(d["shape"])
data = d["data"]
count = int(np.prod(shape, dtype=np.int64)) if shape else 1
if count == 0:
return np.empty(shape, dtype=dtype)
itemsize = dtype.itemsize
if len(data) != count * itemsize:
raise ValueError(
"skinner portable ndarray: byte length %s != %s * %s for shape %s, dtype %s"
% (len(data), count, itemsize, shape, dtype)
)
arr = np.frombuffer(data, dtype=dtype)
out = arr.reshape(()) if shape == () else arr.reshape(shape)
# frombuffer is read-only; match mutability of freshly queried np.array data
return out.copy()
def _encode_for_pickle(obj: object) -> object:
r"""
Recursively copy a pickle state tree, replacing every np.ndarray with a
portable dict so pickle.dump never records NumPy array pickling opcodes.
Dict keys and values, list/tuple elements are walked; other objects are left
unchanged (e.g. SkinChunk instances, datetime, strings).
Parameters
obj : object
Typically a Chunk instance __dict__ or nested containers thereof.
Return : object
Structure safe to pickle with only portable array blobs.
"""
if isinstance(obj, np.ndarray):
return _encode_ndarray_for_pickle(obj)
if isinstance(obj, dict):
return {_encode_for_pickle(k): _encode_for_pickle(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_encode_for_pickle(x) for x in obj]
if isinstance(obj, tuple):
return tuple(_encode_for_pickle(x) for x in obj)
return obj
def _decode_from_pickle(obj: object) -> object:
r"""
Recursively restore portable array dicts to ndarray. Dicts without the
Skinner marker and existing ndarray instances (legacy .sknr loads) are
left as-is aside from copying container structure.
Parameters
obj : object
Unpickled state from Chunk.__getstate__ or legacy instance dicts.
Return : object
Tree with np.ndarray restored for v1 blobs.
"""
if _is_portable_ndarray_dict(obj):
return _decode_portable_ndarray(obj)
if isinstance(obj, np.ndarray):
return obj
if isinstance(obj, dict):
return {_decode_from_pickle(k): _decode_from_pickle(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_decode_from_pickle(x) for x in obj]
if isinstance(obj, tuple):
return tuple(_decode_from_pickle(x) for x in obj)
return obj
#-----------------------------
# Our Chunks, behold them!
class Chunk:
r"""
Chunk is the superclass that SkinChunks and UberChunks are based on: It contains
the superset of methods they both share, to reduce code redundancy/complexity.
"""
def __init__(self):
r"""
Initialize our Chunk instance with empty values.
"""
self.totalMeshVerts = 0
self.influences = []
self.influenceMatrices = []
self.influenceLocalTransforms = [] # Added 1.1.1
self.influenceRotateOrders = [] # Added 1.1.1
self.influenceParents = []
self.weights = []
self.blendWeights = []
self.normals = []
self.normalsPreDeformed = [] # Added 1.1.0
self.vertPositions = []
self.vertPositionsPreDeformed = [] # Added 1.1.0
def __getstate__(self) -> dict:
r"""
Pickle hook: return a shallow copy of instance __dict__ with all ndarrays
replaced by portable dtype/shape/bytes dicts for cross-NumPy-compatible
.sknr files.
Return : dict
Pickle-safe instance state.
"""
return _encode_for_pickle(self.__dict__.copy())
def __setstate__(self, state: object) -> None:
r"""
Pickle hook: merge decoded state into this instance. Portable array dicts
become writable ndarrays; legacy pickles may already contain ndarrays,
which are kept unchanged.
Parameters
state : dict or object
Unpickled state; normally a dict from __getstate__.
"""
if isinstance(state, dict):
self.__dict__.update(_decode_from_pickle(state))
else:
# defensive: unexpected legacy layout
self.__dict__ = state
#------------
# Queries
def hasInfluence(self, influence:str) -> bool:
r"""
Return True/False if the provided influence leaf string name is part of
this Chunk.
"""
inf = influence.split("|")[-1].split(":")[-1]
if inf in self.influences:
return True
else:
return False
def getAllWeights(self) -> np.ndarray:
r"""
Return a ndarray[x][y] where Each item (x, represents vert ids) is a
sublist (y) : The sublist are weights in relationship to the passed
ininfluences list. Ultimately, this was generated by utils.getWeights.
"""
return self.weights
def getAllBlendWeights(self) -> np.ndarray:
r"""
Return a ndarray[x] where Each item (x) is a corresponding 'blendWeight'
to the same index in vertIds : Ultimately, this was generated by
utils.getBlendWeights.
"""
return self.blendWeights
def getAllNormals(self, preDeformed=False) -> np.ndarray:
r"""
Return a ndarray[x][y] for the worldspace vert normals. Ultimately, this
is generated by utils.getVertNormals.
Parameters:
preDeformed : bool : Added in 1.1.0 : If this is False,
return the worldspace normals for the verts when the SkinCluster was
generated. If True, return the 'pre-deformed' normals that were stored
out.
"""
# Introduced in 1.1.0 :
if preDeformed and hasattr(self, "normalsPreDeformed"):
return self.normalsPreDeformed
else:
return self.normals
def getInfluences(self) -> list:
r"""
Return a list of string leaf names of the influences in this Chunk.
"""
return self.influences
def getInfluenceMatrices(self) -> list:
r"""
Return a list, where each item is the worldspace matrix (as a list of 16
floats) for each influence saved int his Chunk.
"""
return self.influenceMatrices
def getInfluenceLocalTransforms(self) -> list:
r"""
New in 1.1.1
Return a list, where each item represents an influence in this Chunk.
Each value is a dict, with keys for "translate", "rotate", "scale", "rotateAxis",
"jointOrient", an values the corresponding [x,y,z] values
"""
try:
return self.influenceLocalTransforms
except AttributeError:
return {}
def getInfluenceRotateOrders(self) -> list:
"""
New in 1.1.1
Return a list, where each item represents the (int) rotateOrder for that
influence in this Chunk
"""
try:
return self.influenceRotateOrders
except AttributeError:
return 0 # xyz
def getInfluenceParents(self) -> list:
r"""
Return a list of leaf string names for the parent of each influence saved
in this Chunk.
"""
return self.influenceParents
def getVertPositions(self, preDeformed=False) -> list:
r"""
Based on the saved vert IDs, return back a list of their worldspace positions,
as sublists of 3 floats, based on what's saved in this Chunk.
Parameters:
* preDeformed : bool : Default = False : New in 1.1.0 : If this is False,