diff --git a/HE_scalp_mesh.py b/HE_scalp_mesh.py new file mode 100644 index 00000000..83a20cdf --- /dev/null +++ b/HE_scalp_mesh.py @@ -0,0 +1,1415 @@ +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +#by Noizirom + +def scalp_vertices(): + return [[-0.04461, 0.03254, -0.10046], + [-0.03806, 0.03898, -0.09903], + [-0.02974, 0.04644, -0.0969], + [-0.01956, 0.05167, -0.09533], + [-0.00969, 0.05546, -0.09403], + [-0.05781, 0.028, -0.06469], + [-0.06593, 0.03598, -0.01398], + [-0.07256, -0.00723, 0.0247], + [-0.07105, -0.03932, 0.0241], + [-0.06177, 0.0364, -0.04163], + [-0.05997, 0.03286, -0.05445], + [-0.04116, -0.09656, 0.03754], + [-0.03065, -0.1044, 0.03987], + [-0.05115, -0.08915, 0.03423], + [-0.06368, -0.07671, 0.02465], + [-0.02064, -0.11003, 0.04173], + [-0.00949, -0.11285, 0.04236], + [-0.07195, 0.01403, 0.01725], + [-0.07038, -0.04848, -0.06233], + [-0.0672, -0.07114, 0.01669], + [-0.05007, 0.02575, 0.05831], + [-0.04, 0.03135, 0.06673], + [-0.06842, 0.01582, 0.0284], + [-0.07708, 0.00275, -0.02223], + [-0.07633, -0.00433, -0.01943], + [-0.07671, 0.00916, -0.02533], + [-0.07573, 0.01442, -0.03509], + [-0.05717, 0.00264, 0.05529], + [-0.03988, 0.00776, 0.07404], + [-0.04981, 0.00487, 0.06502], + [-0.03764, -0.06405, 0.06921], + [-0.05418, 0.03465, -0.06279], + [-0.04568, 0.04839, -0.05897], + [-0.03919, 0.05507, -0.05709], + [-0.0295, 0.06189, -0.05504], + [-0.05595, 0.04005, -0.05116], + [-0.04726, 0.05469, -0.04595], + [-0.04053, 0.06147, -0.04389], + [-0.03073, 0.06797, -0.04195], + [-0.06892, -0.06119, 0.01959], + [-0.05779, 0.04427, -0.03688], + [-0.04942, 0.05982, -0.02896], + [-0.04243, 0.06687, -0.0264], + [-0.03309, 0.07386, -0.02493], + [-0.06495, -0.07982, 0.01334], + [-0.06295, 0.04355, -0.00662], + [-0.05457, 0.06075, 0.00596], + [-0.04769, 0.06861, 0.0099], + [-0.03854, 0.07614, 0.0134], + [-0.06715, 0.02857, 0.01893], + [-0.05541, 0.03845, 0.03604], + [-0.04791, 0.04499, 0.04288], + [-0.03911, 0.05325, 0.05033], + [-0.04072, -0.01112, 0.07629], + [-0.05067, -0.01122, 0.06756], + [-0.05017, -0.05786, 0.06163], + [-0.05791, -0.01101, 0.05685], + [-0.0697, -0.00721, 0.0355], + [-0.0693, 0.00333, 0.03352], + [-0.06834, -0.04233, 0.03484], + [-0.05897, -0.052, 0.05363], + [-0.05831, -0.06502, 0.04801], + [-0.06761, -0.05409, 0.03192], + [-0.0498, -0.07259, 0.05466], + [-0.03786, -0.08053, 0.06052], + [-0.01105, -0.09107, 0.06811], + [-0.01547, 0.06628, 0.05706], + [-0.01388, 0.08599, 0.01586], + [-0.01352, -0.01051, 0.08552], + [-0.01446, 0.01308, 0.08363], + [-0.01147, 0.08404, -0.02435], + [-0.01024, 0.07584, -0.04157], + [-0.00987, 0.07071, -0.05469], + [-0.01082, -0.07283, 0.07857], + [-0.024, -0.06907, 0.07481], + [-0.01886, 0.06685, -0.05488], + [-0.02682, 0.08179, 0.0155], + [-0.02218, 0.07957, -0.02463], + [-0.02008, 0.07379, -0.04167], + [-0.02879, 0.06204, 0.05585], + [-0.02785, 0.01116, 0.0801], + [-0.02762, -0.01116, 0.08271], + [-0.02447, -0.08659, 0.0651], + [-0.03761, -0.10498, 0.02864], + [-0.06105, -0.08578, 0.0195], + [-0.00808, -0.1179, 0.03163], + [-0.01848, -0.11613, 0.03123], + [-0.02788, -0.11163, 0.02992], + [-0.04723, -0.09825, 0.02634], + [-0.05588, -0.0778, 0.04179], + [-0.06607, -0.06598, 0.02862], + [-0.04611, -0.08534, 0.04634], + [-0.03458, -0.09352, 0.0502], + [-0.07032, -0.04043, -0.06233], + [-0.01066, -0.10349, 0.05469], + [-0.02297, -0.0998, 0.05326], + [-0.05583, 0.02325, -0.07381], + [-0.00944, 0.06172, -0.07236], + [-0.01876, 0.05826, -0.07331], + [-0.0277, 0.05189, -0.0744], + [-0.03637, 0.04556, -0.07576], + [-0.04276, 0.03927, -0.07737], + [-0.05194, 0.02448, -0.08089], + [-0.07031, -0.05038, 0.02232], + [-0.04249, 0.03564, -0.08651], + [-0.0364, 0.04287, -0.08491], + [-0.02839, 0.04919, -0.08382], + [-0.01911, 0.05393, -0.0825], + [-0.00985, 0.05843, -0.08124], + [-0.05764, 0.02107, 0.04974], + [-0.01433, 0.04072, 0.07398], + [-0.02776, 0.03686, 0.07213], + [-0.07641, 0.01261, -0.0304], + [-0.03957, 0.06919, 0.03372], + [-0.04786, 0.0603, 0.02729], + [-0.05539, 0.05319, 0.02264], + [-0.06478, 0.03832, 0.00739], + [-0.0154, 0.08061, 0.03808], + [-0.0283, 0.07637, 0.03735], + [-0.06772, 0.03274, -3e-05], + [-0.0455, 0.0698, -0.00768], + [-0.05224, 0.06232, -0.011], + [-0.0606, 0.04511, -0.02149], + [-0.03637, 0.07711, -0.00608], + [-0.01296, 0.08705, -0.00519], + [-0.02508, 0.08311, -0.00547], + [-0.06385, 0.03714, -0.02792], + [-0.07534, -0.00977, -0.01849], + [-0.07519, -0.01605, -0.01953], + [-0.07527, -0.02328, -0.02789], + [-0.07364, -0.03684, -0.02789], + [-0.07039, -0.034, -0.06233], + [-0.00955, 0.06597, -0.06416], + [-0.01873, 0.06248, -0.06454], + [-0.0282, 0.05629, -0.06503], + [-0.03707, 0.04921, -0.06682], + [-0.0436, 0.04282, -0.06868], + [-0.05234, 0.02908, -0.07186], + [-0.0716, -0.02856, -0.06233], + [-0.07245, 0.00274, 0.02217], + [-0.07034, 0.02451, 0.00952], + [-0.05636, 0.05435, -0.01516], + [-0.06184, 0.03365, 0.02906], + [-0.06363, -0.05881, 0.04018], + [-0.05995, 0.0453, 0.01551], + [-0.05344, 0.05282, -0.03216], + [-0.05893, 0.05266, 0.00075], + [-0.05157, 0.04896, -0.04786], + [-0.04979, 0.04242, -0.06041], + [-0.04804, 0.0288, -0.08807], + [-0.04687, 0.03225, -0.0791], + [-0.05516, -0.09172, 0.02319], + [-0.06177, -0.07108, 0.03572], + [-0.06406, 0.00348, 0.04531], + [-0.0586, -0.08258, 0.03027], + [-0.06345, 0.01822, 0.04044], + [-0.04783, 0.03661, -0.07001], + [-0.06433, -0.00888, 0.04684], + [-0.06454, -0.04668, 0.04464], + [-0.07112, -0.05497, 0.00529], + [-0.07527, 0.00877, 0.00098], + [-0.07352, -0.03604, 0.00818], + [-0.0755, -0.00845, 0.00674], + [-0.07284, -0.04558, 0.0071], + [-0.07215, 0.0214, -0.01423], + [-0.07567, 0.00015, 0.00481], + [-0.07409, 0.01633, -0.00554], + [-0.0671, -0.07181, 0.00115], + [-0.06845, -0.0692, -0.01352], + [-0.06935, -0.06379, 0.00333], + [-0.07068, -0.05973, -0.01421], + [-0.07117, -0.05369, -0.02804], + [-0.07146, -0.05146, -0.04303], + [-0.07252, -0.04975, -0.01185], + [-0.07242, -0.04626, -0.02932], + [-0.07204, -0.04421, -0.04478], + [-0.07239, -0.03624, -0.04704], + [-0.07458, -0.02991, -0.02789], + [-0.07398, -0.04131, -0.01121], + [-0.07455, -0.03319, -0.00954], + [-0.0755, -0.00959, -0.00659], + [-0.07591, -0.00249, -0.00787], + [-0.07591, 0.00484, -0.0107], + [-0.07518, 0.01135, -0.0158], + [-0.07391, 0.01576, -0.02281], + [-0.07061, 0.0238, -0.02512], + [-0.07302, 0.01788, -0.03132], + [-0.0694, 0.02449, -0.03662], + [-0.07187, 0.01834, -0.0404], + [-0.07112, 0.01772, -0.04948], + [-0.06832, 0.02384, -0.04738], + [-0.07002, 0.01637, -0.05877], + [-0.06698, 0.02133, -0.05771], + [-0.0651, 0.01806, -0.06662], + [-0.01257, -0.0332, 0.08569], + [-0.07286, -0.02936, -0.0494], + [-0.07349, -0.02849, -0.04352], + [-0.07432, -0.02412, -0.03399], + [-0.07405, -0.02776, -0.03933], + [-0.02676, -0.03223, 0.08257], + [-0.04062, -0.02994, 0.07709], + [-0.07246, -0.01736, 0.02561], + [-0.0591, -0.02434, 0.05738], + [-0.06969, -0.01827, 0.03612], + [-0.05146, -0.02708, 0.06758], + [-0.06476, -0.0209, 0.04684], + [-0.07474, -0.01721, 0.00832], + [-0.01198, -0.05386, 0.08333], + [-0.07517, -0.01704, -0.00602], + [-0.03922, -0.04797, 0.07457], + [-0.05978, -0.0385, 0.05693], + [-0.06963, -0.02988, 0.03627], + [-0.05138, -0.04327, 0.06526], + [-0.06514, -0.03427, 0.04687], + [-0.07183, -0.02817, 0.0259], + [-0.02565, -0.05134, 0.08066], + [-0.07407, -0.02648, 0.00872], + [-0.07482, -0.02537, -0.00797], + [-0.05653, 0.031, 0.04185], + [-0.04844, 0.04027, 0.04667], + [0.0, 0.05651, -0.09374], + [0.04461, 0.03254, -0.10046], + [0.03806, 0.03898, -0.09903], + [0.02974, 0.04644, -0.0969], + [0.01956, 0.05167, -0.09533], + [0.00969, 0.05546, -0.09403], + [0.0, -0.11314, 0.0427], + [0.0, -0.01043, 0.08679], + [0.0, -0.07288, 0.07946], + [0.0, 0.07257, -0.05405], + [0.0, 0.07978, -0.04111], + [0.0, 0.08669, -0.02402], + [0.0, 0.08942, 0.01627], + [0.0, 0.06848, 0.05872], + [0.0, -0.0909, 0.06864], + [0.0, -0.1182, 0.03195], + [0.0, -0.10413, 0.05527], + [0.0, 0.06335, -0.07178], + [0.0, 0.05992, -0.08066], + [0.0, 0.04367, 0.07566], + [0.0, 0.08232, 0.03921], + [0.0, 0.08911, -0.00504], + [0.0, 0.06757, -0.06341], + [0.0, -0.03336, 0.08646], + [0.0, -0.05374, 0.08478], + [0.05781, 0.028, -0.06469], + [0.06593, 0.03598, -0.01398], + [0.07256, -0.00723, 0.0247], + [0.07105, -0.03932, 0.0241], + [0.06177, 0.0364, -0.04163], + [0.05997, 0.03286, -0.05445], + [0.04116, -0.09656, 0.03754], + [0.03065, -0.1044, 0.03987], + [0.05115, -0.08915, 0.03423], + [0.06368, -0.07671, 0.02465], + [0.02064, -0.11003, 0.04173], + [0.00949, -0.11285, 0.04236], + [0.07195, 0.01403, 0.01725], + [0.07038, -0.04848, -0.06233], + [0.0672, -0.07114, 0.01669], + [0.05007, 0.02575, 0.05831], + [0.04, 0.03135, 0.06673], + [0.06842, 0.01582, 0.0284], + [0.07708, 0.00275, -0.02223], + [0.07633, -0.00433, -0.01943], + [0.07671, 0.00916, -0.02533], + [0.07573, 0.01442, -0.03509], + [0.05717, 0.00264, 0.05529], + [0.03988, 0.00776, 0.07404], + [0.04981, 0.00487, 0.06502], + [0.03764, -0.06405, 0.06921], + [0.05418, 0.03465, -0.06279], + [0.04568, 0.04839, -0.05897], + [0.03919, 0.05507, -0.05709], + [0.0295, 0.06189, -0.05504], + [0.05595, 0.04005, -0.05116], + [0.04726, 0.05469, -0.04595], + [0.04053, 0.06147, -0.04389], + [0.03073, 0.06797, -0.04195], + [0.06892, -0.06119, 0.01959], + [0.05779, 0.04427, -0.03688], + [0.04942, 0.05982, -0.02896], + [0.04243, 0.06687, -0.0264], + [0.03309, 0.07386, -0.02493], + [0.06495, -0.07982, 0.01334], + [0.06295, 0.04355, -0.00662], + [0.05457, 0.06075, 0.00596], + [0.04769, 0.06861, 0.0099], + [0.03854, 0.07614, 0.0134], + [0.06715, 0.02857, 0.01893], + [0.05541, 0.03845, 0.03604], + [0.04791, 0.04499, 0.04288], + [0.03911, 0.05325, 0.05033], + [0.04072, -0.01112, 0.07629], + [0.05067, -0.01122, 0.06756], + [0.05017, -0.05786, 0.06163], + [0.05791, -0.01101, 0.05685], + [0.0697, -0.00721, 0.0355], + [0.0693, 0.00333, 0.03352], + [0.06834, -0.04233, 0.03484], + [0.05897, -0.052, 0.05363], + [0.05831, -0.06502, 0.04801], + [0.06761, -0.05409, 0.03192], + [0.0498, -0.07259, 0.05466], + [0.03786, -0.08053, 0.06052], + [0.01105, -0.09107, 0.06811], + [0.01547, 0.06628, 0.05706], + [0.01388, 0.08599, 0.01586], + [0.01352, -0.01051, 0.08552], + [0.01446, 0.01308, 0.08363], + [0.01147, 0.08404, -0.02435], + [0.01024, 0.07584, -0.04157], + [0.00987, 0.07071, -0.05469], + [0.01082, -0.07283, 0.07857], + [0.024, -0.06907, 0.07481], + [0.01886, 0.06685, -0.05488], + [0.02682, 0.08179, 0.0155], + [0.02218, 0.07957, -0.02463], + [0.02008, 0.07379, -0.04167], + [0.02879, 0.06204, 0.05585], + [0.02785, 0.01116, 0.0801], + [0.02762, -0.01116, 0.08271], + [0.02447, -0.08659, 0.0651], + [0.03761, -0.10498, 0.02864], + [0.06105, -0.08578, 0.0195], + [0.00808, -0.1179, 0.03163], + [0.01848, -0.11613, 0.03123], + [0.02788, -0.11163, 0.02992], + [0.04723, -0.09825, 0.02634], + [0.05588, -0.0778, 0.04179], + [0.06607, -0.06598, 0.02862], + [0.04611, -0.08534, 0.04634], + [0.03458, -0.09352, 0.0502], + [0.07032, -0.04043, -0.06233], + [0.01066, -0.10349, 0.05469], + [0.02297, -0.0998, 0.05326], + [0.05583, 0.02325, -0.07381], + [0.00944, 0.06172, -0.07236], + [0.01876, 0.05826, -0.07331], + [0.0277, 0.05189, -0.0744], + [0.03637, 0.04556, -0.07576], + [0.04276, 0.03927, -0.07737], + [0.05194, 0.02448, -0.08089], + [0.07031, -0.05038, 0.02232], + [0.04249, 0.03564, -0.08651], + [0.0364, 0.04287, -0.08491], + [0.02839, 0.04919, -0.08382], + [0.01911, 0.05393, -0.0825], + [0.00985, 0.05843, -0.08124], + [0.05764, 0.02107, 0.04974], + [0.01433, 0.04072, 0.07398], + [0.02776, 0.03686, 0.07213], + [0.07641, 0.01261, -0.0304], + [0.03957, 0.06919, 0.03372], + [0.04786, 0.0603, 0.02729], + [0.05539, 0.05319, 0.02264], + [0.06478, 0.03832, 0.00739], + [0.0154, 0.08061, 0.03808], + [0.0283, 0.07637, 0.03735], + [0.06772, 0.03274, -3e-05], + [0.0455, 0.0698, -0.00768], + [0.05224, 0.06232, -0.011], + [0.0606, 0.04511, -0.02149], + [0.03637, 0.07711, -0.00608], + [0.01296, 0.08705, -0.00519], + [0.02508, 0.08311, -0.00547], + [0.06385, 0.03714, -0.02792], + [0.07534, -0.00977, -0.01849], + [0.07519, -0.01605, -0.01953], + [0.07527, -0.02328, -0.02789], + [0.07364, -0.03684, -0.02789], + [0.07039, -0.034, -0.06233], + [0.00955, 0.06597, -0.06416], + [0.01873, 0.06248, -0.06454], + [0.0282, 0.05629, -0.06503], + [0.03707, 0.04921, -0.06682], + [0.0436, 0.04282, -0.06868], + [0.05234, 0.02908, -0.07186], + [0.0716, -0.02856, -0.06233], + [0.07245, 0.00274, 0.02217], + [0.07034, 0.02451, 0.00952], + [0.05636, 0.05435, -0.01516], + [0.06184, 0.03365, 0.02906], + [0.06363, -0.05881, 0.04018], + [0.05995, 0.0453, 0.01551], + [0.05344, 0.05282, -0.03216], + [0.05893, 0.05266, 0.00075], + [0.05157, 0.04896, -0.04786], + [0.04979, 0.04242, -0.06041], + [0.04804, 0.0288, -0.08807], + [0.04687, 0.03225, -0.0791], + [0.05516, -0.09172, 0.02319], + [0.06177, -0.07108, 0.03572], + [0.06406, 0.00348, 0.04531], + [0.0586, -0.08258, 0.03027], + [0.06345, 0.01822, 0.04044], + [0.04783, 0.03661, -0.07001], + [0.06433, -0.00888, 0.04684], + [0.06454, -0.04668, 0.04464], + [0.07112, -0.05497, 0.00529], + [0.07527, 0.00877, 0.00098], + [0.07352, -0.03604, 0.00818], + [0.0755, -0.00845, 0.00674], + [0.07284, -0.04558, 0.0071], + [0.07215, 0.0214, -0.01423], + [0.07567, 0.00015, 0.00481], + [0.07409, 0.01633, -0.00554], + [0.0671, -0.07181, 0.00115], + [0.06845, -0.0692, -0.01352], + [0.06935, -0.06379, 0.00333], + [0.07068, -0.05973, -0.01421], + [0.07117, -0.05369, -0.02804], + [0.07146, -0.05146, -0.04303], + [0.07252, -0.04975, -0.01185], + [0.07242, -0.04626, -0.02932], + [0.07204, -0.04421, -0.04478], + [0.07239, -0.03624, -0.04704], + [0.07458, -0.02991, -0.02789], + [0.07398, -0.04131, -0.01121], + [0.07455, -0.03319, -0.00954], + [0.0755, -0.00959, -0.00659], + [0.07591, -0.00249, -0.00787], + [0.07591, 0.00484, -0.0107], + [0.07518, 0.01135, -0.0158], + [0.07391, 0.01576, -0.02281], + [0.07061, 0.0238, -0.02512], + [0.07302, 0.01788, -0.03132], + [0.0694, 0.02449, -0.03662], + [0.07187, 0.01834, -0.0404], + [0.07112, 0.01772, -0.04948], + [0.06832, 0.02384, -0.04738], + [0.07002, 0.01637, -0.05877], + [0.06698, 0.02133, -0.05771], + [0.0651, 0.01806, -0.06662], + [0.01257, -0.0332, 0.08569], + [0.07286, -0.02936, -0.0494], + [0.07349, -0.02849, -0.04352], + [0.07432, -0.02412, -0.03399], + [0.07405, -0.02776, -0.03933], + [0.02676, -0.03223, 0.08257], + [0.04062, -0.02994, 0.07709], + [0.07246, -0.01736, 0.02561], + [0.0591, -0.02434, 0.05738], + [0.06969, -0.01827, 0.03612], + [0.05146, -0.02708, 0.06758], + [0.06476, -0.0209, 0.04684], + [0.07474, -0.01721, 0.00832], + [0.01198, -0.05386, 0.08333], + [0.07517, -0.01704, -0.00602], + [0.03922, -0.04797, 0.07457], + [0.05978, -0.0385, 0.05693], + [0.06963, -0.02988, 0.03627], + [0.05138, -0.04327, 0.06526], + [0.06514, -0.03427, 0.04687], + [0.07183, -0.02817, 0.0259], + [0.02565, -0.05134, 0.08066], + [0.07407, -0.02648, 0.00872], + [0.07482, -0.02537, -0.00797], + [0.0, 0.01419, 0.08488], + [0.05653, 0.031, 0.04185], + [0.04844, 0.04027, 0.04667]] + +def scalp_face_vertices(): + return [[4, 3, 107, 108], + [122, 141, 145, 40], + [119, 116, 45, 6], + [152, 154, 13, 89], + [89, 13, 11, 91], + [91, 11, 12, 92], + [126, 122, 40, 9], + [29, 54, 53, 28], + [92, 12, 15, 95], + [52, 21, 111, 79], + [50, 218, 219, 51], + [51, 219, 52], + [103, 62, 59, 8], + [27, 56, 54, 29], + [153, 157, 56, 27], + [147, 36, 32, 148], + [142, 155, 218, 50], + [46, 115, 114, 47], + [79, 111, 110, 66], + [134, 34, 75, 133], + [147, 145, 41, 36], + [5, 10, 35, 31], + [37, 42, 43, 38], + [36, 41, 42, 37], + [121, 46, 47, 120], + [141, 146, 46, 121], + [120, 47, 48, 123], + [114, 51, 52, 113], + [115, 50, 51, 114], + [144, 142, 50, 115], + [204, 200, 53, 54], + [81, 80, 28, 53], + [36, 37, 33, 32], + [37, 38, 34, 33], + [133, 75, 72, 132], + [34, 38, 78, 75], + [205, 202, 56, 157], + [95, 15, 16, 94], + [201, 214, 211, 203], + [158, 143, 61, 60], + [8, 59, 211, 214], + [60, 61, 63, 55], + [74, 30, 64, 82], + [75, 78, 71, 72], + [30, 55, 63, 64], + [78, 77, 70, 71], + [202, 204, 54, 56], + [118, 79, 66, 117], + [125, 76, 67, 124], + [127, 180, 181, 24], + [5, 31, 137, 96], + [199, 194, 68, 81], + [68, 69, 80, 81], + [38, 43, 77, 78], + [113, 52, 79, 118], + [123, 48, 76, 125], + [200, 199, 81, 53], + [179, 177, 130, 178], + [73, 74, 82, 65], + [45, 122, 126, 6], + [139, 7, 57, 58], + [11, 83, 87, 12], + [154, 151, 88, 13], + [13, 88, 83, 11], + [12, 87, 86, 15], + [15, 86, 85, 16], + [106, 99, 98, 107], + [135, 134, 99, 100], + [136, 135, 100, 101], + [156, 136, 101, 150], + [22, 49, 140, 17], + [100, 99, 106, 105], + [7, 201, 203, 57], + [101, 100, 105, 104], + [150, 101, 104, 149], + [107, 98, 97, 108], + [47, 114, 113, 48], + [146, 144, 115, 46], + [48, 113, 118, 76], + [109, 155, 153, 27], + [49, 116, 119, 140], + [14, 90, 39, 19], + [19, 44, 84, 14], + [10, 9, 40, 35], + [76, 118, 117, 67], + [99, 134, 133, 98], + [23, 182, 183, 25], + [33, 34, 134, 135], + [32, 33, 135, 136], + [148, 32, 136, 156], + [39, 90, 62, 103], + [155, 22, 58, 153], + [20, 109, 27, 29], + [21, 20, 29, 28], + [111, 21, 28, 80], + [98, 133, 132, 97], + [141, 121, 41, 145], + [121, 120, 42, 41], + [120, 123, 43, 42], + [77, 43, 123, 125], + [110, 111, 80, 69], + [31, 148, 156, 137], + [45, 116, 144, 146], + [137, 156, 150, 102], + [14, 84, 151, 154], + [59, 62, 143, 158], + [203, 205, 157, 57], + [116, 49, 142, 144], + [122, 45, 146, 141], + [35, 40, 145, 147], + [49, 22, 155, 142], + [35, 147, 148, 31], + [58, 57, 157, 153], + [24, 181, 182, 23], + [90, 14, 154, 152], + [17, 160, 165, 139], + [140, 166, 160, 17], + [8, 161, 163, 103], + [139, 165, 162, 7], + [214, 216, 161, 8], + [119, 164, 166, 140], + [125, 124, 70, 77], + [167, 169, 170, 168], + [169, 159, 173, 170], + [93, 18, 172, 175], + [44, 19, 169, 167], + [19, 39, 159, 169], + [25, 183, 184, 112], + [131, 93, 175, 176], + [7, 162, 206, 201], + [26, 112, 184, 186], + [181, 180, 162, 165], + [182, 181, 165, 160], + [182, 160, 166, 183], + [183, 166, 164, 184], + [187, 126, 9, 190], + [190, 9, 10, 192], + [176, 195, 138, 131], + [206, 162, 180, 208], + [161, 179, 178, 163], + [5, 193, 192, 10], + [189, 190, 192, 191], + [188, 187, 190, 189], + [126, 187, 185, 6], + [187, 188, 186, 185], + [164, 185, 186, 184], + [195, 176, 130, 196], + [119, 6, 185, 164], + [129, 197, 198, 177], + [196, 130, 177, 198], + [216, 206, 208, 217], + [3, 2, 106, 107], + [2, 1, 105, 106], + [1, 0, 104, 105], + [22, 17, 139, 58], + [172, 171, 174, 175], + [174, 171, 170, 173], + [175, 174, 130, 176], + [211, 213, 205, 203], + [178, 130, 174, 173], + [173, 159, 163, 178], + [159, 39, 103, 163], + [62, 90, 152, 143], + [143, 152, 89, 61], + [63, 61, 89, 91], + [64, 63, 91, 92], + [82, 64, 92, 95], + [82, 95, 94, 65], + [209, 215, 199, 200], + [215, 207, 194, 199], + [210, 212, 204, 202], + [213, 210, 202, 205], + [220, 4, 108, 238], + [212, 209, 200, 204], + [208, 180, 127, 128], + [55, 30, 209, 212], + [158, 60, 210, 213], + [60, 55, 212, 210], + [74, 73, 207, 215], + [30, 74, 215, 209], + [59, 158, 213, 211], + [161, 216, 217, 179], + [201, 206, 216, 214], + [179, 217, 129, 177], + [217, 208, 128, 129], + [66, 110, 239, 233], + [71, 70, 231, 230], + [124, 67, 232, 241], + [117, 66, 233, 240], + [194, 243, 227, 68], + [94, 16, 226, 236], + [72, 71, 230, 229], + [227, 458, 69, 68], + [73, 228, 244, 207], + [65, 234, 228, 73], + [16, 85, 235, 226], + [132, 242, 237, 97], + [97, 237, 238, 108], + [67, 117, 240, 232], + [72, 229, 242, 132], + [239, 110, 69, 458], + [124, 241, 231, 70], + [65, 94, 236, 234], + [207, 244, 243, 194], + [219, 218, 109, 20], + [52, 219, 20, 21], + [218, 155, 109], + [186, 188, 26], + [192, 193, 191], + [96, 193, 5], + [137, 102, 96], + [149, 102, 150], + [104, 0, 149], + [225, 348, 347, 224], + [362, 280, 385, 381], + [359, 246, 285, 356], + [392, 329, 253, 394], + [329, 331, 251, 253], + [331, 332, 252, 251], + [366, 249, 280, 362], + [269, 268, 293, 294], + [332, 335, 255, 252], + [292, 319, 351, 261], + [290, 291, 460, 459], + [291, 292, 460], + [343, 248, 299, 302], + [267, 269, 294, 296], + [393, 267, 296, 397], + [387, 388, 272, 276], + [382, 290, 459, 395], + [286, 287, 354, 355], + [319, 306, 350, 351], + [374, 373, 315, 274], + [387, 276, 281, 385], + [245, 271, 275, 250], + [277, 278, 283, 282], + [276, 277, 282, 281], + [361, 360, 287, 286], + [381, 361, 286, 386], + [360, 363, 288, 287], + [354, 353, 292, 291], + [355, 354, 291, 290], + [384, 355, 290, 382], + [444, 294, 293, 440], + [321, 293, 268, 320], + [276, 272, 273, 277], + [277, 273, 274, 278], + [373, 372, 312, 315], + [274, 315, 318, 278], + [445, 397, 296, 442], + [335, 334, 256, 255], + [441, 443, 451, 454], + [398, 300, 301, 383], + [248, 454, 451, 299], + [300, 295, 303, 301], + [314, 322, 304, 270], + [315, 312, 311, 318], + [270, 304, 303, 295], + [318, 311, 310, 317], + [442, 296, 294, 444], + [358, 357, 306, 319], + [365, 364, 307, 316], + [367, 264, 421, 420], + [245, 336, 377, 271], + [439, 321, 308, 434], + [308, 321, 320, 309], + [278, 318, 317, 283], + [353, 358, 319, 292], + [363, 365, 316, 288], + [440, 293, 321, 439], + [419, 418, 370, 417], + [313, 305, 322, 314], + [285, 246, 366, 362], + [379, 298, 297, 247], + [251, 252, 327, 323], + [394, 253, 328, 391], + [253, 251, 323, 328], + [252, 255, 326, 327], + [255, 256, 325, 326], + [346, 347, 338, 339], + [375, 340, 339, 374], + [376, 341, 340, 375], + [396, 390, 341, 376], + [262, 257, 380, 289], + [340, 345, 346, 339], + [247, 297, 443, 441], + [341, 344, 345, 340], + [390, 389, 344, 341], + [347, 348, 337, 338], + [287, 288, 353, 354], + [386, 286, 355, 384], + [288, 316, 358, 353], + [349, 267, 393, 395], + [289, 380, 359, 356], + [254, 259, 279, 330], + [259, 254, 324, 284], + [250, 275, 280, 249], + [316, 307, 357, 358], + [339, 338, 373, 374], + [263, 265, 423, 422], + [273, 375, 374, 274], + [272, 376, 375, 273], + [388, 396, 376, 272], + [279, 343, 302, 330], + [395, 393, 298, 262], + [260, 269, 267, 349], + [261, 268, 269, 260], + [351, 320, 268, 261], + [338, 337, 372, 373], + [381, 385, 281, 361], + [361, 281, 282, 360], + [360, 282, 283, 363], + [317, 365, 363, 283], + [350, 309, 320, 351], + [271, 377, 396, 388], + [285, 386, 384, 356], + [377, 342, 390, 396], + [254, 394, 391, 324], + [299, 398, 383, 302], + [443, 297, 397, 445], + [356, 384, 382, 289], + [362, 381, 386, 285], + [275, 387, 385, 280], + [289, 382, 395, 262], + [275, 271, 388, 387], + [298, 393, 397, 297], + [264, 263, 422, 421], + [330, 392, 394, 254], + [257, 379, 405, 400], + [380, 257, 400, 406], + [248, 343, 403, 401], + [379, 247, 402, 405], + [454, 248, 401, 456], + [359, 380, 406, 404], + [365, 317, 310, 364], + [407, 408, 410, 409], + [409, 410, 413, 399], + [333, 415, 412, 258], + [284, 407, 409, 259], + [259, 409, 399, 279], + [265, 352, 424, 423], + [371, 416, 415, 333], + [247, 441, 446, 402], + [266, 426, 424, 352], + [421, 405, 402, 420], + [422, 400, 405, 421], + [422, 423, 406, 400], + [423, 424, 404, 406], + [427, 430, 249, 366], + [430, 432, 250, 249], + [416, 371, 378, 435], + [446, 448, 420, 402], + [401, 403, 418, 419], + [245, 250, 432, 433], + [429, 431, 432, 430], + [428, 429, 430, 427], + [366, 246, 425, 427], + [427, 425, 426, 428], + [404, 424, 426, 425], + [435, 436, 370, 416], + [359, 404, 425, 246], + [369, 417, 438, 437], + [436, 438, 417, 370], + [456, 457, 448, 446], + [224, 347, 346, 223], + [223, 346, 345, 222], + [222, 345, 344, 221], + [262, 298, 379, 257], + [412, 415, 414, 411], + [414, 413, 410, 411], + [415, 416, 370, 414], + [451, 443, 445, 453], + [418, 413, 414, 370], + [413, 418, 403, 399], + [399, 403, 343, 279], + [302, 383, 392, 330], + [383, 301, 329, 392], + [303, 331, 329, 301], + [304, 332, 331, 303], + [322, 335, 332, 304], + [322, 305, 334, 335], + [449, 440, 439, 455], + [455, 439, 434, 447], + [450, 442, 444, 452], + [453, 445, 442, 450], + [220, 238, 348, 225], + [452, 444, 440, 449], + [448, 368, 367, 420], + [295, 452, 449, 270], + [398, 453, 450, 300], + [300, 450, 452, 295], + [314, 455, 447, 313], + [270, 449, 455, 314], + [299, 451, 453, 398], + [401, 419, 457, 456], + [441, 454, 456, 446], + [419, 417, 369, 457], + [457, 369, 368, 448], + [306, 233, 239, 350], + [311, 230, 231, 310], + [364, 241, 232, 307], + [357, 240, 233, 306], + [434, 308, 227, 243], + [334, 236, 226, 256], + [312, 229, 230, 311], + [227, 308, 309, 458], + [313, 447, 244, 228], + [305, 313, 228, 234], + [256, 226, 235, 325], + [372, 337, 237, 242], + [337, 348, 238, 237], + [307, 232, 240, 357], + [312, 372, 242, 229], + [239, 458, 309, 350], + [364, 310, 231, 241], + [305, 234, 236, 334], + [447, 434, 243, 244], + [460, 260, 349, 459], + [292, 261, 260, 460], + [459, 349, 395], + [426, 266, 428], + [432, 431, 433], + [336, 245, 433], + [377, 336, 342], + [389, 390, 342], + [344, 389, 221]] + +def scalp_vert_groups(): + return {"head": [0, 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], + "neck": [0, 1, 2, 3, 4, 31, 32, 33, 34, 72, 75, 96, 97, 98, 99, 100, 101, 102, + 104, 105, 106, 107, 108, 132, 133, 134, 135, 136, 137, 148, 149, 150, 156, 220, + 221, 222, 223, 224, 225, 229, 237, 238, 242, 271, 272, 273, 274, 312, 315, 336, + 337, 338, 339, 340, 341, 342, 344, 345, 346, 347, 348, 372, 373, 374, 375, 376, + 377, 388, 389, 390, 396], + "right": [0, 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, 226, 227, 228, 229, 230, 231, 232, + 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 458], + "left": [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], + "top": [11, 12, 13, 15, 16, 20, 21, 27, 28, 29, 30, 52, 53, 54, 55, 56, 60, 61, + 63, 64, 65, 66, 68, 69, 73, 74, 79, 80, 81, 82, 83, 85, 86, 87, 88, 89, 91, 92, + 94, 95, 109, 110, 111, 143, 151, 152, 153, 154, 155, 157, 158, 194, 199, 200, + 202, 204, 205, 207, 209, 210, 212, 213, 215, 218, 219, 226, 227, 228, 233, 234, + 235, 236, 239, 243, 244, 251, 252, 253, 255, 256, 260, 261, 267, 268, 269, 270, + 292, 293, 294, 295, 296, 300, 301, 303, 304, 305, 306, 308, 309, 313, 314, 319, + 320, 321, 322, 323, 325, 326, 327, 328, 329, 331, 332, 334, 335, 349, 350, 351, + 383, 391, 392, 393, 394, 395, 397, 398, 434, 439, 440, 442, 444, 445, 447, 449, + 450, 452, 453, 455, 458, 459, 460], + "back": [0, 1, 2, 3, 4, 32, 33, 34, 36, 37, 38, 41, 42, 43, 46, 47, 48, 50, 51, + 52, 66, 67, 70, 71, 72, 75, 76, 77, 78, 79, 97, 98, 99, 100, 101, 104, 105, 106, + 107, 108, 113, 114, 115, 117, 118, 120, 121, 123, 124, 125, 132, 133, 134, 135, + 136, 141, 142, 144, 145, 146, 147, 148, 149, 150, 155, 156, 218, 219, 220, 221, + 222, 223, 224, 225, 229, 230, 231, 232, 233, 237, 238, 240, 241, 242, 272, 273, + 274, 276, 277, 278, 281, 282, 283, 286, 287, 288, 290, 291, 292, 306, 307, 310, + 311, 312, 315, 316, 317, 318, 319, 337, 338, 339, 340, 341, 344, 345, 346, 347, + 348, 353, 354, 355, 357, 358, 360, 361, 363, 364, 365, 372, 373, 374, 375, 376, + 381, 382, 384, 385, 386, 387, 388, 389, 390, 395, 396, 459, 460], + "sides": [5, 6, 7, 8, 9, 10, 14, 17, 18, 19, 22, 23, 24, 25, 26, 31, 35, 39, 40, + 44, 45, 49, 57, 58, 59, 62, 84, 90, 93, 96, 102, 103, 112, 116, 119, 122, 126, + 127, 128, 129, 130, 131, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, + 148, 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, 195, 196, 197, + 198, 201, 203, 205, 206, 208, 211, 213, 214, 216, 217, 245, 246, 247, 248, 249, + 250, 254, 257, 258, 259, 262, 263, 264, 265, 266, 271, 275, 279, 280, 284, 285, + 289, 297, 298, 299, 302, 324, 330, 333, 336, 342, 343, 352, 356, 359, 362, 366, + 367, 368, 369, 370, 371, 377, 378, 379, 380, 381, 382, 383, 384, 385, 386, 387, + 388, 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, 435, 436, 437, + 438, 441, 443, 445, 446, 448, 451, 453, 454, 456, 457], + "sideburns": [18, 93, 129, 130, 131, 138, 171, 172, 174, 175, 176, 177, 195, 196, + 197, 198, 258, 333, 369, 370, 371, 378, 411, 412, 414, 415, 416, 417, 435, 436, + 437, 438], + "mohawk": [2, 3, 4, 12, 15, 16, 21, 28, 30, 34, 38, 43, 48, 52, 53, 64, 65, 66, + 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 85, 86, 87, 92, + 94, 95, 97, 98, 99, 106, 107, 108, 110, 111, 113, 117, 118, 123, 124, 125, 132, + 133, 134, 194, 199, 200, 207, 209, 215, 220, 223, 224, 225, 226, 227, 228, 229, + 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 252, + 255, 256, 261, 268, 270, 274, 278, 283, 288, 292, 293, 304, 305, 306, 307, 308, + 309, 310, 311, 312, 313, 314, 315, 316, 317, 318, 319, 320, 321, 322, 325, 326, + 327, 332, 334, 335, 337, 338, 339, 346, 347, 348, 350, 351, 353, 357, 358, 363, + 364, 365, 372, 373, 374, 434, 439, 440, 447, 449, 455, 458], + "bang": [11, 12, 13, 14, 15, 16, 61, 62, 63, 64, 65, 82, 83, 84, 85, 86, 87, 88, + 89, 90, 91, 92, 94, 95, 143, 151, 152, 154, 226, 234, 235, 236, 251, 252, 253, + 254, 255, 256, 301, 302, 303, 304, 305, 322, 323, 324, 325, 326, 327, 328, 329, + 330, 331, 332, 334, 335, 383, 391, 392, 394]} + +import bpy +from time import time +from numpy import array, append, arange, empty, linalg, linspace, isin, sin, cos, radians, sum + + +# UTILS +#################### + +def centroid(co): + co = array(co, float) + return sum(co, axis=0)/len(co) + +def surface_constraint(ob, target): + ob.constraints.new(type='SHRINKWRAP') + con = ob.constraints["Shrinkwrap"] + con.name = f"CON_{ob.name}" + con.target = target + con.use_track_normal = True + con.track_axis = 'TRACK_Z' + +def show_collection(collection, mode=False): + bpy.data.collections[collection].hide_viewport = not mode + +def get_PSY_mods(ob): + return [mod for mod in ob.modifiers[:] if mod.name.startswith("PSY_")] + +def show_modifier(modifiers, mode=False): + for modifier in modifiers: + modifier.show_viewport = mode + +def show_muse(ob, mode=False): + show_modifier(get_PSY_mods(ob), mode=mode) + show_collection("MUSE", mode=mode) + +def point_on_curve(co, t): + from math import factorial + co = array(co, float) + n = len(co)-1 + comb = lambda k: factorial(n) // (factorial(k)*factorial(n-k)) + return sum(array([comb(i) * t**i * (1-t)**(n-i) * co[i] for i in range(n+1)]), axis=0) + +def plot_curve(co, segments=10): + return array([point_on_curve(co, t) for t in linspace(0, 1, segments)]) + +def adjust_(co, segments): + co = array(co) + allco = [] + for v in co: + pc = plot_curve(v, segments=segments) + allco.append(pc) + return array(allco) + +# PARTICLES +#################### +def hair_update(): + bpy.ops.particle.disconnect_hair(all=True) + bpy.ops.particle.connect_hair(all=True) + +def new_hair(ob, Name, count, length, step, curve_guide=1): + ob.modifiers.new(Name, 'PARTICLE_SYSTEM') + psys = bpy.data.particles[Name] + psys.type = 'HAIR' + psys.count = count + psys.hair_length = length + psys.hair_step = step + psys.effector_weights.curve_guide = curve_guide + return psys + +def ob_eval(ob): + depsgraph = bpy.context.evaluated_depsgraph_get() + return ob.evaluated_get(depsgraph) + +def get_particles(ob, psystem): + eval = ob_eval(ob) + return eval.particle_systems[psystem].particles + +def get_particles_list(ob): + eval = ob_eval(ob) + allp = eval.particle_systems[:] + return [i.name for i in allp if i.name.startswith("PSY_")] + +def get_plocation(ob, psystem): + particles = get_particles(ob, psystem) + count = len(particles) + co = empty(count*3, float) + particles.foreach_get("location", co) + return co.reshape((count, 3)) + +def set_plocation(ob, psystem, co): + particles = get_particles(ob, psystem) + co = array(co).ravel() + particles.foreach_set("location", co) + +def get_pstrand_co(ob, psystem, strand): + particles = get_particles(ob, psystem) + hair_keys = particles[strand].hair_keys + count = len(hair_keys) + co = empty(count*3, float) + hair_keys.foreach_get("co", co) + return co.reshape((count, 3)) + +def set_pstrand_co(ob, psystem, strand, co): + co = array(co) + count = len(co) + particles = get_particles(ob, psystem) + particles[strand].location = co[-1] + hair_keys = particles[strand].hair_keys + for v in range(count): + hair_keys[v].co = co[v] + +def get_phair_co(ob, psystem): + particles = get_particles(ob, psystem) + pcount = len(particles) + strands = [] + for strand in range(pcount): + strands.append(get_pstrand_co(ob, psystem, strand)) + return array(strands) + +def set_phair_co(ob, psystem, co): + active_ob(ob.name, None) + hair_update() + particles = get_particles(ob, psystem) + pcount = len(co) + bpy.ops.particle.particle_edit_toggle() + for strand in range(pcount): + set_pstrand_co(ob, psystem, strand, co[strand]) + +# OBJECT +#################### +def active_ob(object, objects): + bpy.ops.object.select_all(action='DESELECT') + bpy.data.objects[object].select_set(state=True) + bpy.context.view_layer.objects.active = bpy.data.objects[object] + if objects is not None: + for o in objects: + bpy.data.objects[o].select_set(state=True) + +def get_vert_groups(ob): + vg = ob.vertex_groups + vt = ob.data.vertices + gct = len(vg) + vct = len(vt) + vg_idx_dict = {i: [] for i in range(gct)} + vg_wt_dict = {i: [] for i in range(gct)} + vn = [v.name for v in vg] + for v in range(vct): + gr = vt[v].groups[:] + if gr == []: + continue + else: + for g in gr: + vg_idx_dict[g.group].append(v) + vg_wt_dict[g.group].append(g.weight) + return vg_idx_dict, vg_wt_dict, vn + +def get_modified_co(ob): + import bmesh + depsgraph = bpy.context.evaluated_depsgraph_get() + bm = bmesh.new() + bm.from_object(ob, depsgraph) + bm.verts.ensure_lookup_table() + co = array([v.co for v in bm.verts], float) + bm.free() + return co + +def find_faces(f_verts, idxs): + return array([all(isin(list(fv), list(idxs))) for fv in f_verts]) + +def copy_ob_co(ob, vert_idx): + vt = ob.data.vertices + fa = ob.data.polygons + countv = len(vt) + co = empty(countv * 3, float) + vt.foreach_get('co', co) + co.shape = (countv, 3) + countf = len(fa) + f_verts = array([i.vertices[:] for i in fa]) + fidx = arange(countf) + mask = find_faces(f_verts, vert_idx) + face_idx = f_verts[mask.tolist()] + v_count = len(vert_idx) + f_count = len(f_verts) + vlist = vert_idx + new_idx = [i for i in range(v_count)] + nv_Dict = {o: n for n, o in enumerate(vlist)} + new_f = [[nv_Dict[i] for i in nest] for nest in face_idx] + return co[vlist], new_f, nv_Dict + +# COLLECTION +#################### +def collection_object_list(collection): + return [o.name for o in bpy.data.collections[collection].objects[:]] + +def new_collection(Name): + if bpy.data.collections.get(Name) == None: + new_coll = bpy.data.collections.new(Name) + bpy.context.scene.collection.children.link(new_coll) + return new_coll + else: + return bpy.data.collections.get(Name) + +def new_subcollection(Name, collection): + if bpy.data.collections.get(Name) == None: + new_coll = bpy.data.collections.new(Name) + bpy.data.collections[collection].children.link(new_coll) + return new_coll + else: + return bpy.data.collections.get(Name) + +# OPS +#################### + +def move_bez_origin(ob, point): + point = array(point, float) + loc = array(ob.location, float) + mw = array(ob.matrix_world, float) + move = point - loc + bp = ob.data.splines[0].bezier_points + for _ in bp: + co = array(_.co, float) + hl = array(_.handle_left, float) + hr = array(_.handle_right, float) + _.co = co - move + _.handle_left = hl - move + _.handle_right = hr - move + trans = mw[:3:,3] + mw[:3:,3] = move + ob.matrix_world = mw + ob.location = point + +def guide_maker(Name, length): + handle = length/2 + bpy.ops.curve.primitive_bezier_curve_add(enter_editmode=False, align='WORLD', location=(0, 0, 0)) + curve = bpy.context.object + curve.name = Name + curve.data.name = Name + move_bez_origin(curve, [-1, 0, 0]) + bp = curve.data.splines[0].bezier_points + bp[0].co = (0, 0, 0) + bp[1].co = (0, 0, length) + bp[0].handle_left = (0, 0, -handle) + bp[0].handle_right = (0, 0, handle) + bp[1].handle_left = (0, 0, length-handle) + bp[1].handle_right = (0, 0, length+handle) + bp[0].select_control_point = False + bp[1].select_control_point = True + bpy.context.collection.objects.unlink(curve) + return curve + +def add_curve_guide(curve, location, collection, target): + curve.location = location + bpy.data.collections[collection].objects.link(curve) + surface_constraint(curve, target) + return curve + +def mod_hook(ob, target): + Name = f"HOK_{ob.name}" + ob.modifiers.new(Name, 'HOOK') + ob.modifiers[Name].object = target + return ob.modifiers[Name] + +def add_empty(Name, radius, collection, location=(0, 0, 0), rotation=(0, 0, 0), type='PLAIN_AXES'): + bpy.ops.object.empty_add(type=type, radius=radius, location=location, rotation=rotation) + emp = bpy.context.object + emp.name = Name + emp.select_set(state=False) + bpy.context.collection.objects.unlink(emp) + bpy.data.collections[collection].objects.link(emp) + return emp + +def curve_hook(curve, hook): + mod = mod_hook(curve, hook) + active_ob(curve.name, None) + bpy.ops.object.editmode_toggle() + bpy.ops.object.hook_assign(modifier=mod.name) + bpy.ops.object.editmode_toggle() + curve.select_set(state=False) + +#Main +#################### + +def scalp(): + scalp_name = "Scalp" + if scalp_name in [i.name for i in bpy.context.scene.objects[:]]: + scalp = bpy.data.objects[scalp_name] + pass + else: + scalp_collection = "HAIR" + scalp_mesh = bpy.data.meshes.new(f"{scalp_name}_{time()}") + scalp_mesh.from_pydata(scalp_vertices(), [], scalp_face_vertices()) + scalp_mesh.validate() + scalp_mesh.update(calc_edges=True) + scalp = bpy.data.objects.new(scalp_name, scalp_mesh) + scalp.data = scalp_mesh + svgs = scalp_vert_groups() + for group in svgs: + svg = scalp.vertex_groups.new(name=group) + svg.add(svgs[group], 1.0, "REPLACE") + # + scalp.location = (0, 0, 1.76) + sub = scalp.modifiers.new("SCP_Subsurf", 'SUBSURF') + sub.levels = 3 + sub.render_levels = 3 + arm = scalp.modifiers.new("SCP_Armature", 'ARMATURE') + arm.vertex_group = "head" + arm.use_vertex_groups = True + swp = scalp.modifiers.new("SCP_Shrinkwrap", 'SHRINKWRAP') + swp.wrap_method = 'NEAREST_SURFACEPOINT' + swp.wrap_mode = 'ON_SURFACE' + if bpy.data.collections.get(scalp_collection)==None: + coll = bpy.data.collections.new(scalp_collection) + bpy.context.scene.collection.children.link(coll) + else: + coll = bpy.data.collections.get(scalp_collection) + # + bpy.data.collections[scalp_collection].objects.link(scalp) + return scalp + +def hair_to_vg(ob, guide=True): + root_radius = .003 + gvg = get_vert_groups(ob) + vg_names = gvg[2] + c_ = 'CRV_' + t_ = 'TGT_' + p_ = 'PSY_' + g_ = 'GUD_' + muse = 'MUSE' + guid = 'HAIR_GUIDE' + count = len(vg_names) + loc = array(ob.location) + dim = array(ob.dimensions) + factor = linalg.norm(dim) + for i in range(count): + vi = gvg[0][i] + coc = copy_ob_co(ob, vi) + co = coc[0] + loc + faces = coc[1] + Name = vg_names[i] + c_Name = f'{c_}{Name}' + t_Name = f'{t_}{Name}' + p_Name = f'{p_}{Name}' + g_Name = f'{g_}{Name}' + ob.modifiers.new(p_Name, 'PARTICLE_SYSTEM') + psys = bpy.data.particles[p_Name] + psys.type = 'HAIR' + psys.count = 100 + psys.hair_length = factor + psys.hair_step = 10 + psys.effector_weights.curve_guide = 1 + psys.root_radius = root_radius + # + p = bpy.data.particles[p_Name] + p.child_type = 'INTERPOLATED' + ob.particle_systems[p_Name].vertex_group_density = Name + # add curve guide + if guide == True: + coll_muse = new_collection(muse) + coll_guide = new_subcollection(guid, muse) + coll_ = new_subcollection(g_Name, guid) + cen = centroid(co) + _, pt, norm, poly = ob.closest_point_on_mesh(cen) + target_origin = array(pt) + loc + target_end = array(norm)*(factor/2)+target_origin + p.effector_weights.collection = coll_ + curve_data = guide_maker(c_Name, factor/2) + curve = add_curve_guide(curve_data, target_origin, g_Name, ob) + curve.field.type = 'GUIDE' + emp = add_empty(t_Name, factor/8, g_Name, location=target_end, rotation=norm, type='SPHERE') + curve_hook(curve, emp) + +def tranfer_hair(ob, psys): + p = get_phair_co(ob, psys) + count = p.shape[0] + segments = p.shape[1] + active_ob(ob.name, None) + bpy.ops.object.modifier_convert(modifier=psys) + mesh = bpy.context.object + active_ob(mesh.name, None) + co = get_modified_co(mesh) + ct = co.shape[0] + pts = int(ct/count) + nco = co.reshape((count, pts, 3)) + bpy.ops.object.delete(use_global=False, confirm=False) + active_ob(ob.name, None) + return adjust_(nco, segments) + +def hair_baking(ob, psys): + co = tranfer_hair(ob, psys) + set_phair_co(ob, psys, co) + + diff --git a/__init__.py b/__init__.py index 01511654..7b3202ac 100644 --- a/__init__.py +++ b/__init__.py @@ -62,6 +62,7 @@ from . import measurescreator from . import skeleton_ops from . import vgroupscreator +from . import HE_scalp_mesh logger = logging.getLogger(__name__) @@ -192,7 +193,7 @@ def start_lab_session(): else: mblab_humanoid.reset_mesh() mblab_humanoid.update_character(mode="update_all") - + # All inits for creation tools. morphcreator.init_morph_names_database() mbcrea_expressionscreator.reset_expressions_items() @@ -409,11 +410,11 @@ def init_categories_props(humanoid_instance): items=get_categories_enum(), update=modifiers_update, name="Morphing categories") - + # Sub-categories for "Facial expressions" mbcrea_expressionscreator.set_expressions_modifiers(mblab_humanoid) sub_categories_enum = mbcrea_expressionscreator.get_expressions_sub_categories() - + bpy.types.Scene.expressionsSubCategory = bpy.props.EnumProperty( items=sub_categories_enum, update=modifiers_update, @@ -737,7 +738,7 @@ def hair_style_list(self, context): #Teto def mbcrea_enum_expressions_items_update(self, context): return mbcrea_expressionscreator.get_expressions_items() - + bpy.types.Scene.mbcrea_enum_expressions_items = bpy.props.EnumProperty( items=mbcrea_enum_expressions_items_update, @@ -1199,7 +1200,7 @@ class SaveBodyAsIs(bpy.types.Operator, ExportHelper): bl_label = 'Save actual model\'s vertices in a file' bl_idname = 'mbast.button_save_body_as_is' filename_ext = ".json" - filter_glob = bpy.props.StringProperty(default='*.json', options={'HIDDEN'},) + filter_glob: bpy.props.StringProperty(default='*.json', options={'HIDDEN'},) bl_description = 'Save all vertices of the actual body shown on screen in a file.' bl_context = 'objectmode' bl_options = {'REGISTER', 'INTERNAL'} @@ -1221,7 +1222,7 @@ class LoadBaseBody(bpy.types.Operator, ImportHelper): bl_label = 'Load all vertices as a base model' bl_idname = 'mbast.button_load_base_body' filename_ext = ".json" - filter_glob = bpy.props.StringProperty(default="*.json", options={'HIDDEN'},) + filter_glob: bpy.props.StringProperty(default="*.json", options={'HIDDEN'},) bl_description = 'Load all vertices as a base body model.' bl_context = 'objectmode' bl_options = {'REGISTER', 'INTERNAL'} @@ -1241,7 +1242,7 @@ class LoadSculptedBody(bpy.types.Operator, ImportHelper): bl_label = 'Load all vertices as a sculpted model' bl_idname = 'mbast.button_load_sculpted_body' filename_ext = ".json" - filter_glob = bpy.props.StringProperty(default="*.json", options={'HIDDEN'},) + filter_glob: bpy.props.StringProperty(default="*.json", options={'HIDDEN'},) bl_description = 'Load all vertices as a sculpted body model.' bl_context = 'objectmode' bl_options = {'REGISTER', 'INTERNAL'} @@ -1684,7 +1685,7 @@ class FinalizeCharacterAndImages(bpy.types.Operator, ExportHelper): bl_label = 'Finalize with textures and backup' bl_idname = 'mbast.finalize_character_and_images' filename_ext = ".png" - filter_glob = bpy.props.StringProperty(default="*.png", options={'HIDDEN'},) + filter_glob: bpy.props.StringProperty(default="*.png", options={'HIDDEN'},) bl_description = 'Finalize, saving all the textures and converting the parameters in shapekeys. Warning: after the conversion the character will be no longer modifiable using MB-Lab tools' bl_context = 'objectmode' bl_options = {'REGISTER', 'INTERNAL'} @@ -1850,7 +1851,7 @@ class ExpDisplacementImage(bpy.types.Operator, ExportHelper): bl_idname = "mbast.export_dispimage" bl_label = "Save displacement map" filename_ext = ".png" - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="*.png", options={'HIDDEN'}, ) @@ -1867,7 +1868,7 @@ class ExpDermalImage(bpy.types.Operator, ExportHelper): bl_idname = "mbast.export_dermimage" bl_label = "Save dermal map" filename_ext = ".png" - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="*.png", options={'HIDDEN'}, ) @@ -1884,7 +1885,7 @@ class ExpAllImages(bpy.types.Operator, ExportHelper): bl_idname = "mbast.export_allimages" bl_label = "Export all maps" filename_ext = ".png" - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="*.png", options={'HIDDEN'}, ) @@ -1901,7 +1902,7 @@ class ExpCharacter(bpy.types.Operator, ExportHelper): bl_idname = "mbast.export_character" bl_label = "Export character" filename_ext = ".json" - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="*.json", options={'HIDDEN'}, ) @@ -1919,7 +1920,7 @@ class ExpMeasures(bpy.types.Operator, ExportHelper): bl_idname = "mbast.export_measures" bl_label = "Export measures" filename_ext = ".json" - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="*.json", options={'HIDDEN'}, ) @@ -1936,7 +1937,7 @@ class ImpCharacter(bpy.types.Operator, ImportHelper): bl_idname = "mbast.import_character" bl_label = "Import character" filename_ext = ".json" - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="*.json", options={'HIDDEN'}, ) @@ -1954,7 +1955,7 @@ class ImpMeasures(bpy.types.Operator, ImportHelper): bl_idname = "mbast.import_measures" bl_label = "Import measures" filename_ext = ".json" - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="*.json", options={'HIDDEN'}, ) @@ -1971,7 +1972,7 @@ class LoadDermImage(bpy.types.Operator, ImportHelper): bl_idname = "mbast.import_dermal" bl_label = "Load dermal map" filename_ext = ".png" - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="*.png", options={'HIDDEN'}, ) @@ -1988,7 +1989,7 @@ class LoadDispImage(bpy.types.Operator, ImportHelper): bl_idname = "mbast.import_displacement" bl_label = "Load displacement map" filename_ext = ".png" - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="*.png", options={'HIDDEN'}, ) @@ -2034,7 +2035,7 @@ def execute(self, context): scn = bpy.context.scene mblab_proxy.remove_fitting() return {'FINISHED'} - + class ApplyMeasures(bpy.types.Operator): """Fit the character to the measures""" @@ -2086,7 +2087,7 @@ class SaveRestPose(bpy.types.Operator, ExportHelper): bl_idname = "mbast.restpose_save" bl_label = "Save custom rest pose" filename_ext = ".json" - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="*.json", options={'HIDDEN'}, ) @@ -2104,7 +2105,7 @@ class LoadRestPose(bpy.types.Operator, ImportHelper): bl_idname = "mbast.restpose_load" bl_label = "Load custom rest pose" filename_ext = ".json" - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="*.json", options={'HIDDEN'}, ) @@ -2122,7 +2123,7 @@ class SavePose(bpy.types.Operator, ExportHelper): bl_idname = "mbast.pose_save" bl_label = "Save pose" filename_ext = ".json" - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="*.json", options={'HIDDEN'}, ) @@ -2140,7 +2141,7 @@ class ButtonLoadBvhAdjusments(bpy.types.Operator, ImportHelper): bl_label = "Load BVH Bone Config" filename_ext = ".json" bl_description = 'Import the json file containing bvh animation adjustments' - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="*.json", options={'HIDDEN'}, ) @@ -2169,7 +2170,7 @@ class ButtonSaveBvhAdjustments(bpy.types.Operator, ExportHelper): bl_label = 'Save BVH Bone Config' bl_description = 'Save bone corrections into a local json file' filename_ext = ".json" - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="*.json", options={'HIDDEN'}, ) @@ -2208,7 +2209,7 @@ class LoadPose(bpy.types.Operator, ImportHelper): bl_idname = "mbast.pose_load" bl_label = "Load pose" filename_ext = ".json" - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="*.json", options={'HIDDEN'}, ) @@ -2240,7 +2241,7 @@ class LoadBvh(bpy.types.Operator, ImportHelper): bl_label = "Load animation (bvh)" filename_ext = ".bvh" bl_description = 'Import the animation from a bvh motion capture file' - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="*.bvh", options={'HIDDEN'}, ) @@ -2360,7 +2361,7 @@ def execute(self, context): # try: # bpy.ops.object.mode_set(mode='POSE') # except: - # pass + # pass return {'FINISHED'} class OBJECT_OT_manual_hair(bpy.types.Operator): @@ -2392,7 +2393,7 @@ def execute(self, context): # try: # bpy.ops.object.mode_set(mode='POSE') # except: - # pass + # pass return {'FINISHED'} class OBJECT_OT_change_hair_color(bpy.types.Operator): @@ -2481,6 +2482,64 @@ def execute(self, context): node_ops.replace_removed_shader(fileName, UN_shader_remove) return {'FINISHED'} +#Scalp Mesh +class GuideProp(bpy.types.PropertyGroup): + # + guide_bool: bpy.props.BoolProperty( + name = "Add Curve Guide", + description = "Add curve guide to hair", + default = False, + ) + +class AddScalp(bpy.types.Operator): + """Add scalp mesh""" + bl_idname = "mbast.add_scalp" + bl_label = "Add Scalp Mesh" + # + def execute(self, context): + HE_scalp_mesh.scalp() + return {'FINISHED'} + +class VertexGrouptoHair(bpy.types.Operator): + """Spawn hair from vertex groups""" + bl_idname = "mbast.vertex_group_to_hair" + bl_label = "Hair from Vertex Groups" + # + @classmethod + def poll(cls, context): + return context.object.type == 'MESH' + # + def execute(self, context): + guide = context.scene.add_guide.guide_bool + HE_scalp_mesh.hair_to_vg(context.object, guide=guide) + return {'FINISHED'} + +class BakeHairShape(bpy.types.Operator): + """Bakes hair into shape""" + bl_idname = "mbast.bake_hair_shape" + bl_label = "Bake Hair Shape" + # + @classmethod + def poll(cls, context): + return context.object.type == 'MESH' + # + def execute(self, context): + HE_scalp_mesh.hair_baking(context.object , context.object.particle_systems.active.name) + return {'FINISHED'} + +def draw_scalp_layout(self, context, layout): + scalp_box = layout.box() + scalp_box.label(text="Scalp Mesh") + scalp_box.operator(AddScalp.bl_idname) + hair_box = layout.box() + hair_box.label(text="Vertex Group to Hair") + hair_box.prop(context.scene.add_guide ,"guide_bool") + hair_box.operator(VertexGrouptoHair.bl_idname) + if context.scene.add_guide.guide_bool: + bake_box = layout.box() + bake_box.label(text="Bake Active Hair Particles") + bake_box.operator(BakeHairShape.bl_idname) + class StartSession(bpy.types.Operator): bl_idname = "mbast.init_character" bl_label = "Create character" @@ -2514,7 +2573,7 @@ def execute(self, context): # MB-Lab Main GUI class VIEW3D_PT_tools_MBLAB(bpy.types.Panel): - bl_label = "MB-Lab {0}.{1}.{2}".format(bl_info["version"][0], bl_info["version"][1], bl_info["version"][2], bl_info["version"][3]) + bl_label = "MB-Lab {0}.{1}.{2}".format(bl_info["version"][0], bl_info["version"][1], bl_info["version"][2]) bl_idname = "OBJECT_PT_characters01" bl_space_type = 'VIEW_3D' bl_region_type = 'UI' @@ -2551,7 +2610,7 @@ def draw(self, context): box_new_opt.prop(scn, 'mbcrea_root_data') box_new_opt.prop(scn, 'mblab_character_name') box_new_opt.separator(factor=0.5) - + if mblab_humanoid.is_ik_rig_available(scn.mblab_character_name): box_new_opt.prop(scn, 'mblab_use_ik', icon='BONE_DATA') if mblab_humanoid.is_muscle_rig_available(scn.mblab_character_name): @@ -2561,7 +2620,7 @@ def draw(self, context): box_new_opt.prop(scn, 'mblab_use_eevee', icon='SHADING_RENDERED') if scn.mblab_use_cycles or scn.mblab_use_eevee: box_new_opt.prop(scn, 'mblab_use_lamps', icon='LIGHT_DATA') - + self.layout.separator(factor=0.5) self.layout.operator('mbast.init_character', icon='ARMATURE_DATA') @@ -2576,7 +2635,7 @@ def draw(self, context): box_post_opt.operator('mbast.button_facerig_off', icon=icon_collapse) # Face Rig - + box_face_rig = self.layout.box() box_face_rig_a = box_face_rig.column(align=True) #box_face_rig.label(text="Face Rig") @@ -2585,7 +2644,7 @@ def draw(self, context): box_face_rig_a.prop(scn, "mblab_facs_rig") # Expressions - + if gui_active_panel_fin != "expressions": box_post_opt.operator('mbast.button_expressions_on', icon=icon_expand) else: @@ -2615,7 +2674,7 @@ def draw(self, context): else: box_exp.enabled = False box_exp.label(text="No express. shapekeys", icon='INFO') - + # Assets, Fitting and Particle Hair if gui_active_panel_fin != "assets": @@ -2645,6 +2704,8 @@ def draw(self, context): box_asts_b.operator("mbast.del_hair_preset", icon='USER') box_asts_b.operator("mbast.rep_hair_preset", icon='USER') + draw_scalp_layout(self, context, box_asts) + # Proxy Fitting if gui_active_panel_fin != "proxy_fit": @@ -2789,9 +2850,9 @@ def draw(self, context): #self.layout.label(text="Tip: for slow PC, disable the subdivision in Display Options below", icon='INFO') # Sub-panel for all tools below box_act_tools_sub = self.layout.box() - + # Character library - + box_act_tools_sub.label(text="CHARACTER SET-UP", icon="RNA") box_act_tools_a = box_act_tools_sub.column(align=True) if gui_active_panel != "library": @@ -2806,9 +2867,9 @@ def draw(self, context): if mblab_humanoid.exists_phenotype_database(): box_lib.prop(obj, "ethnic") box_lib.prop(scn, 'mblab_mix_characters', icon='FORCE_CHARGE') - + # Randomize character - + if gui_active_panel != "random": box_act_tools_a.operator('mbast.button_random_on', icon=icon_expand) else: @@ -2832,9 +2893,9 @@ def draw(self, context): box_rand.prop(scn, "mblab_preserve_fantasy") box_rand.operator('mbast.character_generator', icon="FILE_REFRESH") - + # Automodelling tools - + if mblab_humanoid.exists_measure_database(): if gui_active_panel != "automodelling": box_act_tools_a.operator('mbast.button_automodelling_on', icon=icon_expand) @@ -2848,9 +2909,9 @@ def draw(self, context): box_auto = self.layout.box() box_auto.enabled = False box_auto.label(text="Automodelling not available for this character", icon='INFO') - + # Body measures - + box_act_tools_sub.label(text="CHARACTER DESIGN", icon="RNA") box_act_tools_b = box_act_tools_sub.column(align=True) if gui_active_panel_middle != "parameters": @@ -2869,7 +2930,7 @@ def draw(self, context): col.label(text="PARAMETERS") col.prop(scn, "morphingCategory") col.separator(factor=0.2) - + for prop in mblab_humanoid.get_properties_in_category(scn.morphingCategory): if hasattr(obj, prop) and not prop.startswith("Expressions_ID"): col.prop(obj, prop) @@ -2901,9 +2962,9 @@ def draw(self, context): sub = box_param.column(align=True) sub.label(text="RESET") sub.operator("mbast.reset_categoryonly", icon="RECOVER_LAST") - + # Poses - + if mblab_humanoid.exists_rest_poses_database(): if gui_active_panel_middle != "rest_pose": box_act_tools_b.operator('mbast.button_rest_pose_on', icon=icon_expand) @@ -2921,9 +2982,9 @@ def draw(self, context): box_act_pose.separator(factor=0.2) box_act_pose.operator("mbast.restpose_load", icon='IMPORT') box_act_pose.operator("mbast.restpose_save", icon='EXPORT') - + # Skin editor - + if gui_active_panel_middle != "skin": box_act_tools_b.operator('mbast.button_skin_on', icon=icon_expand) else: @@ -2938,11 +2999,11 @@ def draw(self, context): if mblab_humanoid.exists_displace_texture(): box_skin_b.operator("mbast.skindisplace_calculate", icon='MOD_DISPLACE') box_skin_b.label(text="Enable Displacement Preview to view updates", icon='INFO') - + box_skin = box_skin_b.column(align=True) for material_data_prop in sorted(mblab_humanoid.character_material_properties.keys()): box_skin.prop(obj, material_data_prop) - + # Finalize character box_act_tools_sub.label(text="OTHERS", icon="RNA") box_act_tools_c = box_act_tools_sub.column(align=True) @@ -2958,9 +3019,9 @@ def draw(self, context): box_fin.operator("mbast.finalize_character_and_images", icon='FREEZE') else: box_fin.operator("mbast.finalize_character", icon='FREEZE') - + # File tools - + if gui_active_panel_display != "file": box_act_tools_c.operator('mbast.button_file_on', icon=icon_expand) else: @@ -2991,9 +3052,9 @@ def draw(self, context): box_file.prop(scn, 'mblab_export_materials', icon='MATERIAL') box_file.operator("mbast.export_character", icon='EXPORT') box_file.operator("mbast.import_character", icon='IMPORT') - + # Display character - + if gui_active_panel_display != "display_opt": box_act_tools_c.operator('mbast.button_display_on', icon=icon_expand) else: @@ -3047,7 +3108,7 @@ def draw(self, context): is_objet, name = algorithms.looking_for_humanoid_obj() icon_expand = "DISCLOSURE_TRI_RIGHT" icon_collapse = "DISCLOSURE_TRI_DOWN" - + box_general = self.layout.box() box_general.label(text="https://www.mblab.dev") #box_general.operator('mbcrea.button_for_tests', icon='BLENDER') @@ -3076,7 +3137,7 @@ def draw(self, context): if len(scn.mblab_morph_name) > 0: morph_label = "Morph name : " + morphcreator.get_body_parts(scn.mblab_body_part_name) morph_label += "_" + scn.mblab_morph_name - morph_label += "_" + morphcreator.get_min_max(scn.mblab_morph_min_max) + morph_label += "_" + morphcreator.get_min_max(scn.mblab_morph_min_max) box_morphcreator.label(text=morph_label, icon='INFO') else: box_morphcreator.label(text="Name needed !", icon='ERROR') @@ -3440,7 +3501,7 @@ def draw(self, context): else: box_combinexpression.label(text="!NO COMPATIBLE MODEL!", icon='ERROR') box_combinexpression.enabled = False - + # Copy / Move / Delete utilities. elif scn.mbcrea_before_edition_tools == "cmd_utilities": box_cmd_morphs = self.layout.box() @@ -3562,7 +3623,7 @@ def draw(self, context): else: creation_tools_ops.init_config() box_compat_tools_sub.label(text="Choose a project name !", icon='ERROR') - + # Tools about Config file creation - Base models box_project_tools = self.layout.box() box_project_tools.label(text="TOOLS", icon="MODIFIER_ON") @@ -3685,14 +3746,6 @@ def draw(self, context): b_m_c_c.label(text="Displacement : " + creation_tools_ops.get_content(key, "texture_displacement"), icon='CHECKMARK') else: b_m_c_c.prop(scn, "mbcrea_texture_displacement") - if creation_tools_ops.get_content(key, "texture_subdermal") != "": - b_m_c_c.label(text="texture_subdermal : " + creation_tools_ops.get_content(key, "texture_subdermal"), icon='CHECKMARK') - else: - b_m_c_c.prop(scn, "mbcrea_texture_subdermal") - if creation_tools_ops.get_content(key, "texture_thickness") != "": - b_m_c_c.label(text="Thickness : " + creation_tools_ops.get_content(key, "texture_thickness"), icon='CHECKMARK') - else: - b_m_c_c.prop(scn, "mbcrea_texture_thickness") if creation_tools_ops.get_content(key, "texture_frecklemask") != "": b_m_c_c.label(text="Frekles : " + creation_tools_ops.get_content(key, "texture_frecklemask"), icon='CHECKMARK') else: @@ -3705,6 +3758,10 @@ def draw(self, context): b_m_c_c.label(text="Sebum : " + creation_tools_ops.get_content(key, "texture_sebum"), icon='CHECKMARK') else: b_m_c_c.prop(scn, "mbcrea_texture_sebum") + if creation_tools_ops.get_content(key, "texture_roughness") != "": + b_m_c_c.label(text="Sebum : " + creation_tools_ops.get_content(key, "texture_sebum"), icon='CHECKMARK') + else: + b_m_c_c.prop(scn, "mbcrea_texture_roughness") if creation_tools_ops.get_content(key, "texture_eyes") != "": b_m_c_c.label(text="Eyes : " + creation_tools_ops.get_content(key, "texture_eyes"), icon='CHECKMARK') else: @@ -4224,7 +4281,7 @@ def mbcrea_enum_morph_items_update(self, context): global mbcrea_combined_morph_list mbcrea_combined_morph_list = algorithms.create_enum_property_items(props) return mbcrea_combined_morph_list - + bpy.types.Scene.mbcrea_morphs_items_1 = bpy.props.EnumProperty( items=mbcrea_enum_morph_items_update, name="", @@ -4300,7 +4357,7 @@ def morphs_items_minmax(box, items_str, minmax_str): bpy.types.Scene.mbcrea_integrate_material = bpy.props.BoolProperty( name="Integrate material", description="You can integrate the material or not.") - + bpy.types.Scene.mbcrea_special_preset = bpy.props.BoolProperty( name="Special", description="If the preset is special or common") @@ -4323,7 +4380,7 @@ def mbcrea_enum_transfor_category(self, context): name = "Tone" mbcrea_transfor_category_list.append((key, name, name)) return mbcrea_transfor_category_list - + bpy.types.Scene.mbcrea_transfor_category = bpy.props.EnumProperty( items=mbcrea_enum_transfor_category, @@ -4362,7 +4419,7 @@ def update_cmd_file(self, context): name="Spectrum", update=update_cmd_file, default="GE") - + bpy.types.Scene.mbcrea_gender_files_in = bpy.props.EnumProperty( items=morphcreator.get_gender_type_files(mblab_humanoid, "Gender"), name="Gender", @@ -4384,7 +4441,7 @@ def get_morph_file_categories(self, context): def update_cmd_categories(self, context): morphcreator.update_cmd_morphs() - + bpy.types.Scene.mbcrea_file_categories_content = bpy.props.EnumProperty( items=get_morph_file_categories, name="Category", @@ -4442,10 +4499,10 @@ def update_cmd_categories(self, context): name="", default="None", ) - + def update_template_list(self, context): return creation_tools_ops.get_templates_list() - + bpy.types.Scene.mbcrea_template_list = bpy.props.EnumProperty( items=update_template_list, name="", @@ -4459,7 +4516,7 @@ def update_template_new_name(self, context): return txt += "_" + scn.mbcrea_gender_list + "_base" creation_tools_ops.add_content("templates_list", None, txt) - + bpy.types.Scene.mbcrea_template_new_name = bpy.props.StringProperty( name="", description="The name for the template", @@ -4509,7 +4566,7 @@ def update_meshes_show(self, context): def get_character_list(self, context): return creation_tools_ops.get_character_list() - + def get_character_list_without(self, context): return creation_tools_ops.get_character_list(with_new=False) @@ -4582,47 +4639,42 @@ def update_character_new_name(self, context): def update_texture_items(self, context): return creation_tools_ops.get_file_list("textures", file_type="png") - + bpy.types.Scene.mbcrea_texture_albedo = bpy.props.EnumProperty( items=update_texture_items, name="Albedo", default=None) - + bpy.types.Scene.mbcrea_texture_bump = bpy.props.EnumProperty( items=update_texture_items, name="Bump", default=None) - + bpy.types.Scene.mbcrea_texture_displacement = bpy.props.EnumProperty( items=update_texture_items, name="Displacement", default=None) - -bpy.types.Scene.mbcrea_texture_subdermal = bpy.props.EnumProperty( - items=update_texture_items, - name="Subdermal", - default=None) - -bpy.types.Scene.mbcrea_texture_thickness = bpy.props.EnumProperty( + +bpy.types.Scene.mbcrea_texture_roughness = bpy.props.EnumProperty( items=update_texture_items, - name="Thickness", + name="Roughness", default=None) - + bpy.types.Scene.mbcrea_texture_frecklemask = bpy.props.EnumProperty( items=update_texture_items, name="Freckle mask", default=None) - + bpy.types.Scene.mbcrea_texture_blush = bpy.props.EnumProperty( items=update_texture_items, name="Blush", default=None) - + bpy.types.Scene.mbcrea_texture_sebum = bpy.props.EnumProperty( items=update_texture_items, name="Sebum", default=None) - + bpy.types.Scene.mbcrea_texture_eyes = bpy.props.EnumProperty( items=update_texture_items, name="Eyes", @@ -4632,47 +4684,47 @@ def update_texture_items(self, context): items=update_texture_items, name="Eyelash albedo", default=None) - + bpy.types.Scene.mbcrea_texture_iris_color = bpy.props.EnumProperty( items=update_texture_items, name="Iris color", default=None) - + bpy.types.Scene.mbcrea_texture_iris_bump = bpy.props.EnumProperty( items=update_texture_items, name="Iris bump", default=None) - + bpy.types.Scene.mbcrea_texture_sclera_color = bpy.props.EnumProperty( items=update_texture_items, name="Sclera color", default=None) - + bpy.types.Scene.mbcrea_texture_sclera_mask = bpy.props.EnumProperty( items=update_texture_items, name="Mask", default=None) - + bpy.types.Scene.mbcrea_texture_translucent_mask = bpy.props.EnumProperty( items=update_texture_items, name="Transluscent", default=None) - + bpy.types.Scene.mbcrea_texture_lipmap = bpy.props.EnumProperty( items=update_texture_items, name="Lip map", default=None) - + bpy.types.Scene.mbcrea_texture_tongue_albedo = bpy.props.EnumProperty( items=update_texture_items, name="Tongue albedo", default=None) - + bpy.types.Scene.mbcrea_texture_teeth_albedo = bpy.props.EnumProperty( items=update_texture_items, name="Teeth albedo", default=None) - + bpy.types.Scene.mbcrea_texture_nails_albedo = bpy.props.EnumProperty( items=update_texture_items, name="Nails albedo", @@ -4680,7 +4732,7 @@ def update_texture_items(self, context): def update_morph_items(self, context): return creation_tools_ops.get_file_list("morphs", file_type="json") - + bpy.types.Scene.mbcrea_shared_morphs_file = bpy.props.EnumProperty( items=update_morph_items, name="Main morphs", @@ -4693,7 +4745,7 @@ def update_morph_items(self, context): def update_bboxes_items(self, context): return creation_tools_ops.get_file_list("bboxes", file_type="json") - + bpy.types.Scene.mbcrea_bboxes_file = bpy.props.EnumProperty( items=update_bboxes_items, name="BBoxes", @@ -4701,7 +4753,7 @@ def update_bboxes_items(self, context): def update_joints_items(self, context): return creation_tools_ops.get_file_list("joints", file_type="json") - + bpy.types.Scene.mbcrea_joints_base_file = bpy.props.EnumProperty( items=update_joints_items, name="Base joints", @@ -4714,7 +4766,7 @@ def update_joints_items(self, context): def update_measures_items(self, context): return creation_tools_ops.get_file_list("measures", file_type="json") - + bpy.types.Scene.mbcrea_measures_file = bpy.props.EnumProperty( items=update_measures_items, name="Measures", @@ -4722,7 +4774,7 @@ def update_measures_items(self, context): def update_vgroups_items(self, context): return creation_tools_ops.get_file_list("vgroups", file_type="json") - + bpy.types.Scene.mbcrea_vgroups_base_file = bpy.props.EnumProperty( items=update_vgroups_items, name="VGroups base", @@ -4735,7 +4787,7 @@ def update_vgroups_items(self, context): def update_transfor_items(self, context): return creation_tools_ops.get_file_list("transformations", file_type="json") - + bpy.types.Scene.mbcrea_transfor_file = bpy.props.EnumProperty( items=update_transfor_items, name="Transformations", @@ -4743,7 +4795,7 @@ def update_transfor_items(self, context): def update_presets_folder(self, context): return creation_tools_ops.get_presets_folder_list() - + bpy.types.Scene.mbcrea_presets_folder = bpy.props.EnumProperty( items=update_presets_folder, name="Presets folder", @@ -4783,7 +4835,7 @@ def toggle_edit_object(self, context): bpy.ops.object.mode_set(mode='WEIGHT_PAINT') else: bpy.ops.object.mode_set(mode='OBJECT') - + bpy.types.Scene.mbcrea_toggle_edit_object = bpy.props.EnumProperty( items=[ ('OBJECT', 'Object', 'Object mode'), @@ -4796,7 +4848,7 @@ def toggle_edit_object(self, context): bpy.types.Scene.mbcrea_unselect_before = bpy.props.BoolProperty( name="Unselect all before", description="Unselect all before select\nnew vertices/edges/faces") - + bpy.types.Scene.mbcrea_measures_file_name = bpy.props.StringProperty( name="Other", description="Another name if you want to create a new measures file", @@ -5090,13 +5142,12 @@ def execute(self, context): creation_tools_ops.add_content(key, "texture_blush", decide_which(key, "texture_blush", scn.mbcrea_texture_blush)) creation_tools_ops.add_content(key, "texture_sebum", decide_which(key, "texture_sebum", scn.mbcrea_texture_sebum)) creation_tools_ops.add_content(key, "texture_lipmap", decide_which(key, "texture_lipmap", scn.mbcrea_texture_lipmap)) - creation_tools_ops.add_content(key, "texture_thickness", decide_which(key, "texture_thickness", scn.mbcrea_texture_thickness)) creation_tools_ops.add_content(key, "texture_iris_color", decide_which(key, "texture_iris_color", scn.mbcrea_texture_iris_color)) creation_tools_ops.add_content(key, "texture_iris_bump", decide_which(key, "texture_iris_bump", scn.mbcrea_texture_iris_bump)) creation_tools_ops.add_content(key, "texture_sclera_color", decide_which(key, "texture_sclera_color", scn.mbcrea_texture_sclera_color)) creation_tools_ops.add_content(key, "texture_translucent_mask", decide_which(key, "texture_translucent_mask", scn.mbcrea_texture_translucent_mask)) creation_tools_ops.add_content(key, "texture_sclera_mask", decide_which(key, "texture_sclera_mask", scn.mbcrea_texture_sclera_mask)) - creation_tools_ops.add_content(key, "texture_subdermal", decide_which(key, "texture_subdermal", scn.mbcrea_texture_subdermal)) + creation_tools_ops.add_content(key, "texture_roughness", decide_which(key, "texture_roughness", scn.mbcrea_texture_roughness)) # The rest creation_tools_ops.add_content(key, "bounding_boxes_file", decide_which(key, "bounding_boxes_file", scn.mbcrea_bboxes_file)) creation_tools_ops.add_content(key, "joints_base_file", decide_which(key, "joints_base_file", scn.mbcrea_joints_base_file)) @@ -5344,7 +5395,7 @@ def get_transfor_filepath(): name = tmp[0] + "_" + tmp[1] + "_" + algorithms.split_name(scn.mbcrea_agemasstone_name.lower()) + "_transf.json" filepath = os.path.join(file_ops.get_data_path(), "transformations", name) return filepath - + class ButtonTransforSave(bpy.types.Operator): bl_label = 'Save step / Finalize' bl_idname = 'mbcrea.button_transfor_save' @@ -5388,7 +5439,7 @@ class ButtonCurrentModelTransforSave(bpy.types.Operator): bl_label = 'Export current model' bl_idname = 'mbcrea.button_transfor_save_current' filename_ext = ".json" - filter_glob = bpy.props.StringProperty(default="*.json", options={'HIDDEN'},) + filter_glob: bpy.props.StringProperty(default="*.json", options={'HIDDEN'},) bl_description = 'Export the data base of the current model.\ni.e its data base, not the changes from user.' bl_context = 'objectmode' bl_options = {'REGISTER', 'INTERNAL'} @@ -5410,7 +5461,7 @@ class CheckTransformationFile(bpy.types.Operator, ImportHelper): bl_label = 'Check compatibility' bl_idname = 'mbcrea.button_check_transf' filename_ext = ".json" - filter_glob = bpy.props.StringProperty(default="*.json", options={'HIDDEN'},) + filter_glob: bpy.props.StringProperty(default="*.json", options={'HIDDEN'},) bl_description = 'Check the compatibility of a file to current model.\nThe result is stored under same directory, same name+.txt' bl_context = 'objectmode' bl_options = {'REGISTER', 'INTERNAL'} @@ -5424,13 +5475,13 @@ def execute(self, context): #-------------------- mbcrea_transfor.check_compatibility_with_current_model(self.filepath) return {'FINISHED'} - + def ShowMessageBox(self, message = "", title = "Error !", icon = 'ERROR'): def draw(self, context): self.layout.label(text=message) bpy.context.window_manager.popup_menu(draw, title = title, icon = icon) - + class LoadTransformationFile(bpy.types.Operator, ImportHelper): @@ -5440,7 +5491,7 @@ class LoadTransformationFile(bpy.types.Operator, ImportHelper): bl_label = 'Import for current model' bl_idname = 'mbcrea.button_load_transf' filename_ext = ".json" - filter_glob = bpy.props.StringProperty(default="*.json", options={'HIDDEN'},) + filter_glob: bpy.props.StringProperty(default="*.json", options={'HIDDEN'},) bl_description = 'Load a transformation file for the current model.' bl_context = 'objectmode' bl_options = {'REGISTER', 'INTERNAL'} @@ -5454,7 +5505,7 @@ def execute(self, context): #-------------------- mbcrea_transfor.load_transformation_from_file(self.filepath) return {'FINISHED'} - + def ShowMessageBox(self, message = "", title = "Error !", icon = 'ERROR'): def draw(self, context): @@ -5512,7 +5563,7 @@ def get_cmd_input_file_name(): if morphcreator.get_spectrum(scn.mbcrea_cmd_spectrum) == "Gender": return scn.mbcrea_gender_files_in return scn.mbcrea_body_type_files_in - + class ButtonCopyMorphs(bpy.types.Operator): bl_label = 'Copy to output file' bl_idname = 'mbcrea.button_copy_morph' @@ -5590,7 +5641,7 @@ class ButtonCompatToolsDir(bpy.types.Operator): @classmethod def poll(self, context): return not creation_tools_ops.is_directories_created() - + def execute(self, context): pn = creation_tools_ops.get_data_directory() creation_tools_ops.create_needed_directories(pn) @@ -5744,7 +5795,7 @@ def execute(self, context): scn = bpy.context.scene base = [] sculpted = [] - + try: base = morphcreator.get_vertices_list(0) except: @@ -5849,7 +5900,7 @@ class ImpExpression(bpy.types.Operator, ImportHelper): bl_idname = "mbcrea.import_expression" bl_label = "Import facial expression" filename_ext = ".json" - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="*.json", options={'HIDDEN'}, ) @@ -6751,7 +6802,11 @@ def execute(self, context): ButtonCreateVGroupsBaseFile, ButtonCreateVGroupsMusclesFile, ButtonSaveVGroupsBaseFile, - ButtonSaveVGroupsMuscleFile + ButtonSaveVGroupsMuscleFile, + GuideProp, + AddScalp, + VertexGrouptoHair, + BakeHairShape, ) def register(): @@ -6763,6 +6818,8 @@ def register(): # register the example panel, to show updater buttons for cls in classes: bpy.utils.register_class(cls) + #curve guide property + bpy.types.Scene.add_guide = bpy.props.PointerProperty(type=GuideProp) def unregister(): @@ -6772,7 +6829,8 @@ def unregister(): # register the example panel, to show updater buttons for cls in reversed(classes): bpy.utils.unregister_class(cls) - + #curve guide property + del bpy.types.Scene.add_guide if __name__ == "__main__": register() diff --git a/addon_updater.py b/addon_updater.py index df75f79f..54149ab3 100644 --- a/addon_updater.py +++ b/addon_updater.py @@ -20,9 +20,13 @@ """ See documentation for usage https://github.com/CGCookie/blender-addon-updater - """ +__version__ = "1.1.0" + +import errno +import traceback +import platform import ssl import urllib.request import urllib @@ -34,27 +38,21 @@ import fnmatch from datetime import datetime, timedelta -# blender imports, used in limited cases +# Blender imports, used in limited cases. import bpy import addon_utils # ----------------------------------------------------------------------------- -# Define error messages/notices & hard coded globals +# The main class # ----------------------------------------------------------------------------- -# currently not used -DEFAULT_TIMEOUT = 10 -DEFAULT_PER_PAGE = 30 +class SingletonUpdater: + """Addon updater service class. -# ----------------------------------------------------------------------------- -# The main class -# ----------------------------------------------------------------------------- - -class Singleton_updater(object): - """ - This is the singleton class to reference a copy from, - it is the shared module level class + This is the singleton class to instance once and then reference where + needed throughout the addon. It implements all the interfaces for running + updates. """ def __init__(self): @@ -64,32 +62,32 @@ def __init__(self): self._website = None self._current_version = None self._subfolder_path = None - self._tags = [] + self._tags = list() self._tag_latest = None - self._tag_names = [] + self._tag_names = list() self._latest_release = None self._use_releases = False self._include_branches = False self._include_branch_list = ['master'] - self._include_branch_autocheck = False + self._include_branch_auto_check = False self._manual_only = False self._version_min_update = None self._version_max_update = None - # by default, backup current addon if new is being loaded + # By default, backup current addon on update/target install. self._backup_current = True self._backup_ignore_patterns = None - # set patterns for what files to overwrite on update + # Set patterns the files to overwrite during an update. self._overwrite_patterns = ["*.py", "*.pyc"] - self._remove_pre_update_patterns = [] + self._remove_pre_update_patterns = list() - # by default, don't auto enable/disable the addon on update - # as it is slightly less stable/won't always fully reload module + # By default, don't auto disable+re-enable the addon after an update, + # as this is less stable/often won't fully reload all modules anyways. self._auto_reload_post_update = False - # settings relating to frequency and whether to enable auto background check - self._check_interval_enable = False + # Settings for the frequency of automated background checks. + self._check_interval_enabled = False self._check_interval_months = 0 self._check_interval_days = 7 self._check_interval_hours = 0 @@ -97,6 +95,7 @@ def __init__(self): # runtime variables, initial conditions self._verbose = False + self._use_print_traces = True self._fake_install = False self._async_checking = False # only true when async daemon started self._update_ready = None @@ -104,25 +103,25 @@ def __init__(self): self._update_version = None self._source_zip = None self._check_thread = None + self._select_link = None self.skip_tag = None - # get from module data + # Get data from the running blender module (addon). self._addon = __package__.lower() - self._addon_package = __package__ # must not change - self._updater_path = os.path.join(os.path.dirname(__file__), - self._addon+"_updater") + self._addon_package = __package__ # Must not change. + self._updater_path = os.path.join( + os.path.dirname(__file__), self._addon + "_updater") self._addon_root = os.path.dirname(__file__) - self._json = {} + self._json = dict() self._error = None self._error_msg = None self._prefiltered_tag_count = 0 - # UI code only, ie not used within this module but still useful - # properties to have + # UI properties, not used within this module but still useful to have. # to verify a valid import, in place of placeholder import - self.showpopups = True # used in UI to show or not show update popups - self.invalidupdater = False + self.show_popups = True # UI uses to show popups or not. + self.invalid_updater = False # pre-assign basic select-link function def select_link_function(self, tag): @@ -130,15 +129,24 @@ def select_link_function(self, tag): self._select_link = select_link_function + def print_trace(self): + """Print handled exception details when use_print_traces is set""" + if self._use_print_traces: + traceback.print_exc() + + def print_verbose(self, msg): + """Print out a verbose logging message if verbose is true.""" + if not self._verbose: + return + print("{} addon: ".format(self.addon) + msg) # ------------------------------------------------------------------------- # Getters and setters # ------------------------------------------------------------------------- - - @property def addon(self): return self._addon + @addon.setter def addon(self, value): self._addon = str(value) @@ -146,9 +154,10 @@ def addon(self, value): @property def api_url(self): return self._engine.api_url + @api_url.setter def api_url(self, value): - if self.check_is_url(value) is False: + if not self.check_is_url(value): raise ValueError("Not a valid URL: " + value) self._engine.api_url = value @@ -159,32 +168,33 @@ def async_checking(self): @property def auto_reload_post_update(self): return self._auto_reload_post_update + @auto_reload_post_update.setter def auto_reload_post_update(self, value): try: self._auto_reload_post_update = bool(value) except: - raise ValueError("Must be a boolean value") + raise ValueError("auto_reload_post_update must be a boolean value") @property def backup_current(self): return self._backup_current + @backup_current.setter def backup_current(self, value): if value is None: self._backup_current = False - return else: self._backup_current = value @property def backup_ignore_patterns(self): return self._backup_ignore_patterns + @backup_ignore_patterns.setter def backup_ignore_patterns(self, value): if value is None: self._backup_ignore_patterns = None - return elif not isinstance(value, list): raise ValueError("Backup pattern must be in list format") else: @@ -192,7 +202,7 @@ def backup_ignore_patterns(self, value): @property def check_interval(self): - return (self._check_interval_enable, + return (self._check_interval_enabled, self._check_interval_months, self._check_interval_days, self._check_interval_hours, @@ -201,33 +211,36 @@ def check_interval(self): @property def current_version(self): return self._current_version + @current_version.setter def current_version(self, tuple_values): if tuple_values is None: self._current_version = None return - elif not isinstance(tuple_values, tuple): + elif type(tuple_values) is not tuple: try: tuple(tuple_values) except: raise ValueError( - "Not a tuple! current_version must be a tuple of integers") + "current_version must be a tuple of integers") for i in tuple_values: - if not isinstance(i, int): + if type(i) is not int: raise ValueError( - "Not an integer! current_version must be a tuple of integers") + "current_version must be a tuple of integers") self._current_version = tuple(tuple_values) @property def engine(self): return self._engine.name + @engine.setter def engine(self, value): - if value.lower() == "github": + engine = value.lower() + if engine == "github": self._engine = GithubEngine() - elif value.lower() == "gitlab": + elif engine == "gitlab": self._engine = GitlabEngine() - elif value.lower() == "bitbucket": + elif engine == "bitbucket": self._engine = BitbucketEngine() else: raise ValueError("Invalid engine selection") @@ -243,6 +256,7 @@ def error_msg(self): @property def fake_install(self): return self._fake_install + @fake_install.setter def fake_install(self, value): if not isinstance(value, bool): @@ -251,33 +265,38 @@ def fake_install(self, value): # not currently used @property - def include_branch_autocheck(self): - return self._include_branch_autocheck - @include_branch_autocheck.setter - def include_branch_autocheck(self, value): + def include_branch_auto_check(self): + return self._include_branch_auto_check + + @include_branch_auto_check.setter + def include_branch_auto_check(self, value): try: - self._include_branch_autocheck = bool(value) + self._include_branch_auto_check = bool(value) except: - raise ValueError("include_branch_autocheck must be a boolean value") + raise ValueError("include_branch_autocheck must be a boolean") @property def include_branch_list(self): return self._include_branch_list + @include_branch_list.setter def include_branch_list(self, value): try: if value is None: self._include_branch_list = ['master'] - elif not isinstance(value, list) or value == []: - raise ValueError("include_branch_list should be a list of valid branches") + elif not isinstance(value, list) or len(value) == 0: + raise ValueError( + "include_branch_list should be a list of valid branches") else: self._include_branch_list = value except: - raise ValueError("include_branch_list should be a list of valid branches") + raise ValueError( + "include_branch_list should be a list of valid branches") @property def include_branches(self): return self._include_branches + @include_branches.setter def include_branches(self, value): try: @@ -287,7 +306,7 @@ def include_branches(self, value): @property def json(self): - if self._json == {}: + if len(self._json) == 0: self.set_updater_json() return self._json @@ -300,6 +319,7 @@ def latest_release(self): @property def manual_only(self): return self._manual_only + @manual_only.setter def manual_only(self, value): try: @@ -310,6 +330,7 @@ def manual_only(self, value): @property def overwrite_patterns(self): return self._overwrite_patterns + @overwrite_patterns.setter def overwrite_patterns(self, value): if value is None: @@ -322,6 +343,7 @@ def overwrite_patterns(self, value): @property def private_token(self): return self._engine.token + @private_token.setter def private_token(self, value): if value is None: @@ -332,28 +354,32 @@ def private_token(self, value): @property def remove_pre_update_patterns(self): return self._remove_pre_update_patterns + @remove_pre_update_patterns.setter def remove_pre_update_patterns(self, value): if value is None: - self._remove_pre_update_patterns = [] + self._remove_pre_update_patterns = list() elif not isinstance(value, list): - raise ValueError("remove_pre_update_patterns needs to be in a list format") + raise ValueError( + "remove_pre_update_patterns needs to be in a list format") else: self._remove_pre_update_patterns = value @property def repo(self): return self._repo + @repo.setter def repo(self, value): try: self._repo = str(value) except: - raise ValueError("User must be a string") + raise ValueError("repo must be a string value") @property def select_link(self): return self._select_link + @select_link.setter def select_link(self, value): # ensure it is a function assignment, with signature: @@ -365,31 +391,34 @@ def select_link(self, value): @property def stage_path(self): return self._updater_path + @stage_path.setter def stage_path(self, value): if value is None: - if self._verbose: print("Aborting assigning stage_path, it's null") + self.print_verbose("Aborting assigning stage_path, it's null") return elif value is not None and not os.path.exists(value): try: os.makedirs(value) except: - if self._verbose: print("Error trying to staging path") + self.print_verbose("Error trying to staging path") + self.print_trace() return self._updater_path = value @property def subfolder_path(self): return self._subfolder_path + @subfolder_path.setter def subfolder_path(self, value): self._subfolder_path = value @property def tags(self): - if self._tags == []: - return [] - tag_names = [] + if len(self._tags) == 0: + return list() + tag_names = list() for tag in self._tags: tag_names.append(tag["name"]) return tag_names @@ -415,6 +444,7 @@ def update_version(self): @property def use_releases(self): return self._use_releases + @use_releases.setter def use_releases(self, value): try: @@ -425,6 +455,7 @@ def use_releases(self, value): @property def user(self): return self._user + @user.setter def user(self, value): try: @@ -435,18 +466,30 @@ def user(self, value): @property def verbose(self): return self._verbose + @verbose.setter def verbose(self, value): try: self._verbose = bool(value) - if self._verbose is True: - print(self._addon+" updater verbose is enabled") + self.print_verbose("Verbose is enabled") except: raise ValueError("Verbose must be a boolean value") + @property + def use_print_traces(self): + return self._use_print_traces + + @use_print_traces.setter + def use_print_traces(self, value): + try: + self._use_print_traces = bool(value) + except: + raise ValueError("use_print_traces must be a boolean value") + @property def version_max_update(self): return self._version_max_update + @version_max_update.setter def version_max_update(self, value): if value is None: @@ -455,13 +498,14 @@ def version_max_update(self, value): if not isinstance(value, tuple): raise ValueError("Version maximum must be a tuple") for subvalue in value: - if not isinstance(subvalue, int): + if type(subvalue) is not int: raise ValueError("Version elements must be integers") self._version_max_update = value @property def version_min_update(self): return self._version_min_update + @version_min_update.setter def version_min_update(self, value): if value is None: @@ -470,80 +514,77 @@ def version_min_update(self, value): if not isinstance(value, tuple): raise ValueError("Version minimum must be a tuple") for subvalue in value: - if not isinstance(subvalue, int): + if type(subvalue) != int: raise ValueError("Version elements must be integers") self._version_min_update = value @property def website(self): return self._website + @website.setter def website(self, value): - if self.check_is_url(value) is False: + if not self.check_is_url(value): raise ValueError("Not a valid URL: " + value) self._website = value - # ------------------------------------------------------------------------- # Parameter validation related functions # ------------------------------------------------------------------------- - - - def check_is_url(self, url): + @staticmethod + def check_is_url(url): if not ("http://" in url or "https://" in url): return False if "." not in url: return False return True - def get_tag_names(self): - tag_names = [] + def _get_tag_names(self): + tag_names = list() self.get_tags() for tag in self._tags: tag_names.append(tag["name"]) return tag_names - def set_check_interval(self, enable=False, months=0, days=14, hours=0, minutes=0): - # enabled = False, default initially will not check against frequency - # if enabled, default is then 2 weeks + def set_check_interval(self, enabled=False, + months=0, days=14, hours=0, minutes=0): + """Set the time interval between automated checks, and if enabled. + + Has enabled = False as default to not check against frequency, + if enabled, default is 2 weeks. + """ - if not isinstance(enable, bool): + if type(enabled) is not bool: raise ValueError("Enable must be a boolean value") - if not isinstance(months, int): + if type(months) is not int: raise ValueError("Months must be an integer value") - if not isinstance(days, int): + if type(days) is not int: raise ValueError("Days must be an integer value") - if not isinstance(hours, int): + if type(hours) is not int: raise ValueError("Hours must be an integer value") - if not isinstance(minutes, int): + if type(minutes) is not int: raise ValueError("Minutes must be an integer value") - if enable is False: - self._check_interval_enable = False + if not enabled: + self._check_interval_enabled = False else: - self._check_interval_enable = True + self._check_interval_enabled = True self._check_interval_months = months self._check_interval_days = days self._check_interval_hours = hours self._check_interval_minutes = minutes - # declare how the class gets printed - def __repr__(self): return "".format(a=__file__) def __str__(self): return "Updater, with user: {a}, repository: {b}, url: {c}".format( - a=self._user, - b=self._repo, - c=self.form_repo_url()) - + a=self._user, b=self._repo, c=self.form_repo_url()) # ------------------------------------------------------------------------- # API-related functions # ------------------------------------------------------------------------- - def form_repo_url(self): return self._engine.form_repo_url(self) @@ -555,7 +596,7 @@ def form_branch_url(self, branch): def get_tags(self): request = self.form_tags_url() - if self._verbose: print("Getting tags from server") + self.print_verbose("Getting tags from server") # get all tags, internet call all_tags = self._engine.parse_tags(self.get_api(request), self) @@ -563,17 +604,17 @@ def get_tags(self): self._prefiltered_tag_count = len(all_tags) else: self._prefiltered_tag_count = 0 - all_tags = [] + all_tags = list() # pre-process to skip tags if self.skip_tag is not None: - self._tags = [tg for tg in all_tags if self.skip_tag(self, tg) is False] + self._tags = [tg for tg in all_tags if not self.skip_tag(self, tg)] else: self._tags = all_tags # get additional branches too, if needed, and place in front # Does NO checking here whether branch is valid - if self._include_branches is True: + if self._include_branches: temp_branches = self._include_branch_list.copy() temp_branches.reverse() for branch in temp_branches: @@ -587,58 +628,65 @@ def get_tags(self): if self._tags is None: # some error occurred self._tag_latest = None - self._tags = [] - return - elif self._prefiltered_tag_count == 0 and self._include_branches is False: + self._tags = list() + + elif self._prefiltered_tag_count == 0 and not self._include_branches: self._tag_latest = None - if self._error is None: # if not None, could have had no internet + if self._error is None: # if not None, could have had no internet self._error = "No releases found" - self._error_msg = "No releases or tags found on this repository" - if self._verbose: print("No releases or tags found on this repository") - elif self._prefiltered_tag_count == 0 and self._include_branches is True: - if not self._error: self._tag_latest = self._tags[0] - if self._verbose: - branch = self._include_branch_list[0] - print("{} branch found, no releases".format(branch), self._tags[0]) - elif (len(self._tags)-len(self._include_branch_list) == 0 and self._include_branches is True) \ - or (len(self._tags) == 0 and self._include_branches is False) \ - and self._prefiltered_tag_count > 0: + self._error_msg = "No releases or tags found in repository" + self.print_verbose("No releases or tags found in repository") + + elif self._prefiltered_tag_count == 0 and self._include_branches: + if not self._error: + self._tag_latest = self._tags[0] + branch = self._include_branch_list[0] + self.print_verbose("{} branch found, no releases: {}".format( + branch, self._tags[0])) + + elif ((len(self._tags) - len(self._include_branch_list) == 0 + and self._include_branches) + or (len(self._tags) == 0 and not self._include_branches) + and self._prefiltered_tag_count > 0): self._tag_latest = None self._error = "No releases available" self._error_msg = "No versions found within compatible version range" - if self._verbose: print("No versions found within compatible version range") + self.print_verbose(self._error_msg) + else: - if self._include_branches is False: + if not self._include_branches: self._tag_latest = self._tags[0] - if self._verbose: - print("Most recent tag found:", self._tags[0]['name']) + self.print_verbose( + "Most recent tag found:" + str(self._tags[0]['name'])) else: - # don't return branch if in list + # Don't return branch if in list. n = len(self._include_branch_list) self._tag_latest = self._tags[n] # guaranteed at least len()=n+1 - if self._verbose: - print("Most recent tag found:", self._tags[n]['name']) - + self.print_verbose( + "Most recent tag found:" + str(self._tags[n]['name'])) - # all API calls to base url def get_raw(self, url): - # print("Raw request:", url) + """All API calls to base url.""" request = urllib.request.Request(url) try: context = ssl._create_unverified_context() except: - # some blender packaged python versions don't have this, largely - # useful for local network setups otherwise minimal impact + # Some blender packaged python versions don't have this, largely + # useful for local network setups otherwise minimal impact. context = None - # setup private request headers if appropriate + # Setup private request headers if appropriate. if self._engine.token is not None: if self._engine.name == "gitlab": request.add_header('PRIVATE-TOKEN', self._engine.token) else: - if self._verbose: print("Tokens not setup for engine yet") + self.print_verbose("Tokens not setup for engine yet") - # run the request + # Always set user agent. + request.add_header( + 'User-Agent', "Python/" + str(platform.python_version())) + + # Run the request. try: if context: result = urllib.request.urlopen(request, context=context) @@ -653,10 +701,11 @@ def get_raw(self, url): self._error = "HTTP error" self._error_msg = str(e.code) print(self._error, self._error_msg) + self.print_trace() self._update_ready = None except urllib.error.URLError as e: reason = str(e.reason) - if "TLSV1_ALERT" in reason or "SSL" in reason: + if "TLSV1_ALERT" in reason or "SSL" in reason.upper(): self._error = "Connection rejected, download manually" self._error_msg = reason print(self._error, self._error_msg) @@ -664,6 +713,7 @@ def get_raw(self, url): self._error = "URL error, check internet connection" self._error_msg = reason print(self._error, self._error_msg) + self.print_trace() self._update_ready = None return None else: @@ -671,10 +721,8 @@ def get_raw(self, url): result.close() return result_string.decode() - - # result of all api calls, decoded into json format def get_api(self, url): - # return the json version + """Result of all api calls, decoded into json format.""" get = None get = self.get_raw(url) if get is not None: @@ -685,121 +733,136 @@ def get_api(self, url): self._error_msg = str(e.reason) self._update_ready = None print(self._error, self._error_msg) + self.print_trace() return None else: return None - - # create a working directory and download the new files def stage_repository(self, url): + """Create a working directory and download the new files""" local = os.path.join(self._updater_path, "update_staging") error = None - # make/clear the staging folder - # ensure the folder is always "clean" - if self._verbose: - print("Preparing staging folder for download:\n", local) - if os.path.isdir(local) is True: + # Make/clear the staging folder, to ensure the folder is always clean. + self.print_verbose( + "Preparing staging folder for download:\n" + str(local)) + if os.path.isdir(local): try: shutil.rmtree(local) os.makedirs(local) except: error = "failed to remove existing staging directory" + self.print_trace() else: try: os.makedirs(local) except: error = "failed to create staging directory" + self.print_trace() if error is not None: - if self._verbose: - print("Error: Aborting update, "+error) + self.print_verbose("Error: Aborting update, " + error) self._error = "Update aborted, staging path error" self._error_msg = "Error: {}".format(error) return False - if self._backup_current is True: + if self._backup_current: self.create_backup() - if self._verbose: print("Now retrieving the new source zip") + self.print_verbose("Now retrieving the new source zip") self._source_zip = os.path.join(local, "source.zip") - - if self._verbose: print("Starting download update zip") + self.print_verbose("Starting download update zip") try: request = urllib.request.Request(url) context = ssl._create_unverified_context() - # setup private token if appropriate + # Setup private token if appropriate. if self._engine.token is not None: if self._engine.name == "gitlab": request.add_header('PRIVATE-TOKEN', self._engine.token) else: - if self._verbose: print("Tokens not setup for selected engine yet") - self.urlretrieve(urllib.request.urlopen(request, context=context), self._source_zip) - # add additional checks on file size being non-zero - if self._verbose: print("Successfully downloaded update zip") + self.print_verbose( + "Tokens not setup for selected engine yet") + + # Always set user agent + request.add_header( + 'User-Agent', "Python/" + str(platform.python_version())) + + self.url_retrieve(urllib.request.urlopen(request, context=context), + self._source_zip) + # Add additional checks on file size being non-zero. + self.print_verbose("Successfully downloaded update zip") return True except Exception as e: self._error = "Error retrieving download, bad link?" self._error_msg = "Error: {}".format(e) - if self._verbose: - print("Error retrieving download, bad link?") - print("Error: {}".format(e)) + print("Error retrieving download, bad link?") + print("Error: {}".format(e)) + self.print_trace() return False - def create_backup(self): - if self._verbose: print("Backing up current addon folder") + """Save a backup of the current installed addon prior to an update.""" + self.print_verbose("Backing up current addon folder") local = os.path.join(self._updater_path, "backup") tempdest = os.path.join( - self._addon_root, - os.pardir, - self._addon+"_updater_backup_temp") + self._addon_root, os.pardir, self._addon + "_updater_backup_temp") - if self._verbose: - print("Backup destination path: ", local) + self.print_verbose("Backup destination path: " + str(local)) if os.path.isdir(local): try: shutil.rmtree(local) except: - if self._verbose: - print("Failed to removed previous backup folder, contininuing") + self.print_verbose( + "Failed to removed previous backup folder, continuing") + self.print_trace() - # remove the temp folder; shouldn't exist but could if previously interrupted + # Remove the temp folder. + # Shouldn't exist but could if previously interrupted. if os.path.isdir(tempdest): try: shutil.rmtree(tempdest) except: - if self._verbose: - print("Failed to remove existing temp folder, contininuing") - # make the full addon copy, which temporarily places outside the addon folder + self.print_verbose( + "Failed to remove existing temp folder, continuing") + self.print_trace() + + # Make a full addon copy, temporarily placed outside the addon folder. if self._backup_ignore_patterns is not None: - shutil.copytree( - self._addon_root, tempdest, - ignore=shutil.ignore_patterns(*self._backup_ignore_patterns)) + try: + shutil.copytree(self._addon_root, tempdest, + ignore=shutil.ignore_patterns( + *self._backup_ignore_patterns)) + except: + print("Failed to create backup, still attempting update.") + self.print_trace() + return else: - shutil.copytree(self._addon_root, tempdest) + try: + shutil.copytree(self._addon_root, tempdest) + except: + print("Failed to create backup, still attempting update.") + self.print_trace() + return shutil.move(tempdest, local) - # save the date for future ref + # Save the date for future reference. now = datetime.now() self._json["backup_date"] = "{m}-{d}-{yr}".format( m=now.strftime("%B"), d=now.day, yr=now.year) self.save_updater_json() def restore_backup(self): - if self._verbose: print("Restoring backup") - - if self._verbose: print("Backing up current addon folder") + """Restore the last backed up addon version, user initiated only""" + self.print_verbose("Restoring backup, backing up current addon folder") backuploc = os.path.join(self._updater_path, "backup") - tempdest = os.path.join(self._addon_root, - os.pardir, - self._addon+"_updater_backup_temp") + tempdest = os.path.join( + self._addon_root, os.pardir, self._addon + "_updater_backup_temp") tempdest = os.path.abspath(tempdest) - # make the copy + # Move instead contents back in place, instead of copy. shutil.move(backuploc, tempdest) shutil.rmtree(self._addon_root) os.rename(tempdest, self._addon_root) @@ -812,33 +875,79 @@ def restore_backup(self): self.reload_addon() def unpack_staged_zip(self, clean=False): - - if os.path.isfile(self._source_zip) is False: - if self._verbose: - print("Error, update zip not found") + """Unzip the downloaded file, and validate contents""" + if not os.path.isfile(self._source_zip): + self.print_verbose("Error, update zip not found") + self._error = "Install failed" + self._error_msg = "Downloaded zip not found" return -1 - # clear the existing source folder in case previous files remain + # Clear the existing source folder in case previous files remain. + outdir = os.path.join(self._updater_path, "source") try: - shutil.rmtree(os.path.join(self._updater_path, "source")) - os.makedirs(os.path.join(self._updater_path, "source")) - if self._verbose: - print("Source folder cleared and recreated") + shutil.rmtree(outdir) + self.print_verbose("Source folder cleared") except: - pass + self.print_trace() - if self._verbose: - print("Begin extracting source") - if zipfile.is_zipfile(self._source_zip): - with zipfile.ZipFile(self._source_zip) as zf: - # extractall is no longer a security hazard, below is safe - zf.extractall(os.path.join(self._updater_path, "source")) - else: - if self._verbose: - print("Not a zip file, future add support for just .py files") - raise ValueError("Resulting file is not a zip") - if self._verbose: - print("Extracted source") + # Create parent directories if needed, would not be relevant unless + # installing addon into another location or via an addon manager. + try: + os.mkdir(outdir) + except Exception as err: + print("Error occurred while making extract dir:") + print(str(err)) + self.print_trace() + self._error = "Install failed" + self._error_msg = "Failed to make extract directory" + return -1 + + if not os.path.isdir(outdir): + print("Failed to create source directory") + self._error = "Install failed" + self._error_msg = "Failed to create extract directory" + return -1 + + self.print_verbose( + "Begin extracting source from zip:" + str(self._source_zip)) + zfile = zipfile.ZipFile(self._source_zip, "r") + + if not zfile: + self._error = "Install failed" + self._error_msg = "Resulting file is not a zip, cannot extract" + self.print_verbose(self._error_msg) + return -1 + + # Now extract directly from the first subfolder (not root) + # this avoids adding the first subfolder to the path length, + # which can be too long if the download has the SHA in the name. + zsep = '/' # Not using os.sep, always the / value even on windows. + for name in zfile.namelist(): + if zsep not in name: + continue + top_folder = name[:name.index(zsep) + 1] + if name == top_folder + zsep: + continue # skip top level folder + sub_path = name[name.index(zsep) + 1:] + if name.endswith(zsep): + try: + os.mkdir(os.path.join(outdir, sub_path)) + self.print_verbose( + "Extract - mkdir: " + os.path.join(outdir, sub_path)) + except OSError as exc: + if exc.errno != errno.EEXIST: + self._error = "Install failed" + self._error_msg = "Could not create folder from zip" + self.print_trace() + return -1 + else: + with open(os.path.join(outdir, sub_path), "wb") as outfile: + data = zfile.read(name) + outfile.write(data) + self.print_verbose( + "Extract - create: " + os.path.join(outdir, sub_path)) + + self.print_verbose("Extracted source") unpath = os.path.join(self._updater_path, "source") if not os.path.isdir(unpath): @@ -851,8 +960,8 @@ def unpack_staged_zip(self, clean=False): self._subfolder_path.replace('/', os.path.sep) self._subfolder_path.replace('\\', os.path.sep) - # either directly in root of zip/one subfolder, or use specified path - if os.path.isfile(os.path.join(unpath, "__init__.py")) is False: + # Either directly in root of zip/one subfolder, or use specified path. + if not os.path.isfile(os.path.join(unpath, "__init__.py")): dirlist = os.listdir(unpath) if len(dirlist) > 0: if self._subfolder_path == "" or self._subfolder_path is None: @@ -860,160 +969,172 @@ def unpack_staged_zip(self, clean=False): else: unpath = os.path.join(unpath, self._subfolder_path) - # smarter check for additional sub folders for a single folder - # containing __init__.py - if os.path.isfile(os.path.join(unpath, "__init__.py")) is False: - if self._verbose: - print("not a valid addon found") - print("Paths:") - print(dirlist) - - raise ValueError("__init__ file not found in new source") - - # merge code with running addon directory, using blender default behavior - # plus any modifiers indicated by user (e.g. force remove/keep) - self.deepMergeDirectory(self._addon_root, unpath, clean) - - # Now save the json state - # Change to True, to trigger the handler on other side - # if allowing reloading within same blender instance + # Smarter check for additional sub folders for a single folder + # containing the __init__.py file. + if not os.path.isfile(os.path.join(unpath, "__init__.py")): + print("Not a valid addon found") + print("Paths:") + print(dirlist) + self._error = "Install failed" + self._error_msg = "No __init__ file found in new source" + return -1 + + # Merge code with the addon directory, using blender default behavior, + # plus any modifiers indicated by user (e.g. force remove/keep). + self.deep_merge_directory(self._addon_root, unpath, clean) + + # Now save the json state. + # Change to True to trigger the handler on other side if allowing + # reloading within same blender session. self._json["just_updated"] = True self.save_updater_json() self.reload_addon() self._update_ready = False + return 0 - - # merge folder 'merger' into folder 'base' without deleting existing - def deepMergeDirectory(self, base, merger, clean=False): + def deep_merge_directory(self, base, merger, clean=False): + """Merge folder 'merger' into 'base' without deleting existing""" if not os.path.exists(base): - if self._verbose: - print("Base path does not exist:", base) + self.print_verbose("Base path does not exist:" + str(base)) return -1 elif not os.path.exists(merger): - if self._verbose: - print("Merger path does not exist") + self.print_verbose("Merger path does not exist") return -1 - # paths to be aware of and not overwrite/remove/etc + # Path to be aware of and not overwrite/remove/etc. staging_path = os.path.join(self._updater_path, "update_staging") - backup_path = os.path.join(self._updater_path, "backup") # If clean install is enabled, clear existing files ahead of time - # note: will not delete the update.json, update folder, staging, or staging - # but will delete all other folders/files in addon directory + # note: will not delete the update.json, update folder, staging, or + # staging but will delete all other folders/files in addon directory. error = None - if clean is True: + if clean: try: - # implement clearing of all folders/files, except the - # updater folder and updater json + # Implement clearing of all folders/files, except the updater + # folder and updater json. # Careful, this deletes entire subdirectories recursively... - # make sure that base is not a high level shared folder, but - # is dedicated just to the addon itself - if self._verbose: print("clean=True, clearing addon folder to fresh install state") + # Make sure that base is not a high level shared folder, but + # is dedicated just to the addon itself. + self.print_verbose( + "clean=True, clearing addon folder to fresh install state") - # remove root files and folders (except update folder) - files = [f for f in os.listdir(base) if os.path.isfile(os.path.join(base, f))] - folders = [f for f in os.listdir(base) if os.path.isdir(os.path.join(base, f))] + # Remove root files and folders (except update folder). + files = [f for f in os.listdir(base) + if os.path.isfile(os.path.join(base, f))] + folders = [f for f in os.listdir(base) + if os.path.isdir(os.path.join(base, f))] for f in files: os.remove(os.path.join(base, f)) - print("Clean removing file {}".format(os.path.join(base, f))) + self.print_verbose( + "Clean removing file {}".format(os.path.join(base, f))) for f in folders: - if os.path.join(base, f) == self._updater_path: + if os.path.join(base, f) is self._updater_path: continue shutil.rmtree(os.path.join(base, f)) - print("Clean removing folder and contents {}".format(os.path.join(base, f))) + self.print_verbose( + "Clean removing folder and contents {}".format( + os.path.join(base, f))) except Exception as err: error = "failed to create clean existing addon folder" print(error, str(err)) + self.print_trace() # Walk through the base addon folder for rules on pre-removing - # but avoid removing/altering backup and updater file + # but avoid removing/altering backup and updater file. for path, dirs, files in os.walk(base): - # prune ie skip updater folder - dirs[:] = [d for d in dirs if os.path.join(path, d) not in [self._updater_path]] + # Prune ie skip updater folder. + dirs[:] = [d for d in dirs + if os.path.join(path, d) not in [self._updater_path]] for file in files: - for ptrn in self.remove_pre_update_patterns: - if fnmatch.filter([file], ptrn): + for pattern in self.remove_pre_update_patterns: + if fnmatch.filter([file], pattern): try: fl = os.path.join(path, file) os.remove(fl) - if self._verbose: - print("Pre-removed file "+file) + self.print_verbose("Pre-removed file " + file) except OSError: - print("Failed to pre-remove "+file) + print("Failed to pre-remove " + file) + self.print_trace() # Walk through the temp addon sub folder for replacements # this implements the overwrite rules, which apply after # the above pre-removal rules. This also performs the - # actual file copying/replacements + # actual file copying/replacements. for path, dirs, files in os.walk(merger): - # verify this structure works to prune updater sub folder overwriting - dirs[:] = [d for d in dirs if os.path.join(path, d) not in [self._updater_path]] - relPath = os.path.relpath(path, merger) - destPath = os.path.join(base, relPath) - if not os.path.exists(destPath): - os.makedirs(destPath) + # Verify structure works to prune updater sub folder overwriting. + dirs[:] = [d for d in dirs + if os.path.join(path, d) not in [self._updater_path]] + rel_path = os.path.relpath(path, merger) + dest_path = os.path.join(base, rel_path) + if not os.path.exists(dest_path): + os.makedirs(dest_path) for file in files: - # bring in additional logic around copying/replacing - # Blender default: overwrite .py's, don't overwrite the rest - destFile = os.path.join(destPath, file) + # Bring in additional logic around copying/replacing. + # Blender default: overwrite .py's, don't overwrite the rest. + dest_file = os.path.join(dest_path, file) srcFile = os.path.join(path, file) - # decide whether to replace if file already exists, and copy new over - if os.path.isfile(destFile): - # otherwise, check each file to see if matches an overwrite pattern + # Decide to replace if file already exists, and copy new over. + if os.path.isfile(dest_file): + # Otherwise, check each file for overwrite pattern match. replaced = False - for ptrn in self._overwrite_patterns: - if fnmatch.filter([destFile], ptrn): + for pattern in self._overwrite_patterns: + if fnmatch.filter([file], pattern): replaced = True break if replaced: - os.remove(destFile) - os.rename(srcFile, destFile) - if self._verbose: - print("Overwrote file "+os.path.basename(destFile)) + os.remove(dest_file) + os.rename(srcFile, dest_file) + self.print_verbose( + "Overwrote file " + os.path.basename(dest_file)) else: - if self._verbose: - print("Pattern not matched to "+os.path.basename(destFile)+", not overwritten") + self.print_verbose( + "Pattern not matched to {}, not overwritten".format( + os.path.basename(dest_file))) else: - # file did not previously exist, simply move it over - os.rename(srcFile, destFile) - if self._verbose: - print("New file "+os.path.basename(destFile)) + # File did not previously exist, simply move it over. + os.rename(srcFile, dest_file) + self.print_verbose( + "New file " + os.path.basename(dest_file)) # now remove the temp staging folder and downloaded zip try: shutil.rmtree(staging_path) except: - error = "Error: Failed to remove existing staging directory, consider manually removing "+staging_path - if self._verbose: - print(error) - + error = ("Error: Failed to remove existing staging directory, " + "consider manually removing ") + staging_path + self.print_verbose(error) + self.print_trace() def reload_addon(self): # if post_update false, skip this function # else, unload/reload addon & trigger popup - if self._auto_reload_post_update is False: + if not self._auto_reload_post_update: print("Restart blender to reload addon and complete update") return - if self._verbose: print("Reloading addon...") + self.print_verbose("Reloading addon...") addon_utils.modules(refresh=True) bpy.utils.refresh_script_paths() # not allowed in restricted context, such as register module # toggle to refresh - bpy.ops.wm.addon_disable(module=self._addon_package) - bpy.ops.wm.addon_refresh() - bpy.ops.wm.addon_enable(module=self._addon_package) - + if "addon_disable" in dir(bpy.ops.wm): # 2.7 + bpy.ops.wm.addon_disable(module=self._addon_package) + bpy.ops.wm.addon_refresh() + bpy.ops.wm.addon_enable(module=self._addon_package) + print("2.7 reload complete") + else: # 2.8 + bpy.ops.preferences.addon_disable(module=self._addon_package) + bpy.ops.preferences.addon_refresh() + bpy.ops.preferences.addon_enable(module=self._addon_package) + print("2.8 reload complete") # ------------------------------------------------------------------------- # Other non-api functions and setups # ------------------------------------------------------------------------- - def clear_state(self): self._update_ready = None self._update_link = None @@ -1022,78 +1143,82 @@ def clear_state(self): self._error = None self._error_msg = None - # custom urlretrieve implementation - def urlretrieve(self, urlfile, filepath): - chunk = 1024*8 + def url_retrieve(self, url_file, filepath): + """Custom urlretrieve implementation""" + chunk = 1024 * 8 f = open(filepath, "wb") while 1: - data = urlfile.read(chunk) + data = url_file.read(chunk) if not data: - #print("done.") + # print("done.") break f.write(data) - #print("Read %s bytes"%len(data)) + # print("Read %s bytes" % len(data)) f.close() - def version_tuple_from_text(self, text): - if text is None: return () + """Convert text into a tuple of numbers (int). + + Should go through string and remove all non-integers, and for any + given break split into a different section. + """ + if text is None: + return () - # should go through string and remove all non-integers, - # and for any given break split into a different section - segments = [] + segments = list() tmp = '' - for l in str(text): - if l.isdigit() is False: + for char in str(text): + if not char.isdigit(): if len(tmp) > 0: segments.append(int(tmp)) tmp = '' else: - tmp += l + tmp += char if len(tmp) > 0: segments.append(int(tmp)) if len(segments) == 0: - if self._verbose: - print("No version strings found text: ", text) - if self._include_branches is False: + self.print_verbose("No version strings found text: " + str(text)) + if not self._include_branches: return () else: - return (text,) + return (text) return tuple(segments) - # called for running check in a background thread def check_for_update_async(self, callback=None): - - if self._json is not None and "update_ready" in self._json and self._json["version_text"] != {}: - if self._json["update_ready"] is True: - self._update_ready = True - self._update_link = self._json["version_text"]["link"] - self._update_version = str(self._json["version_text"]["version"]) - # cached update - callback(True) - return + """Called for running check in a background thread""" + is_ready = ( + self._json is not None + and "update_ready" in self._json + and self._json["version_text"] != dict() + and self._json["update_ready"]) + + if is_ready: + self._update_ready = True + self._update_link = self._json["version_text"]["link"] + self._update_version = str(self._json["version_text"]["version"]) + # Cached update. + callback(True) + return # do the check - if self._check_interval_enable is False: + if not self._check_interval_enabled: return - elif self._async_checking is True: - if self._verbose: - print("Skipping async check, already started") - return # already running the bg thread + elif self._async_checking: + self.print_verbose("Skipping async check, already started") + # already running the bg thread elif self._update_ready is None: + print("{} updater: Running background check for update".format( + self.addon)) self.start_async_check_update(False, callback) - def check_for_update_now(self, callback=None): - self._error = None self._error_msg = None - - if self._verbose: - print("Check update pressed, first getting current status") - if self._async_checking is True: - if self._verbose: print("Skipping async check, already started") + self.print_verbose( + "Check update pressed, first getting current status") + if self._async_checking: + self.print_verbose("Skipping async check, already started") return # already running the bg thread elif self._update_ready is None: self.start_async_check_update(True, callback) @@ -1101,11 +1226,13 @@ def check_for_update_now(self, callback=None): self._update_ready = None self.start_async_check_update(True, callback) - - # this function is not async, will always return in sequential fashion - # but should have a parent which calls it in another thread def check_for_update(self, now=False): - if self._verbose: print("Checking for update function") + """Check for update not in a syncrhonous manner. + + This function is not async, will always return in sequential fashion + but should have a parent which calls it in another thread. + """ + self.print_verbose("Checking for update function") # clear the errors if any self._error = None @@ -1113,41 +1240,47 @@ def check_for_update(self, now=False): # avoid running again in, just return past result if found # but if force now check, then still do it - if self._update_ready is not None and now is False: - return (self._update_ready, self._update_version, self._update_link) + if self._update_ready is not None and not now: + return (self._update_ready, + self._update_version, + self._update_link) if self._current_version is None: raise ValueError("current_version not yet defined") + if self._repo is None: raise ValueError("repo not yet defined") + if self._user is None: raise ValueError("username not yet defined") self.set_updater_json() # self._json - if now is False and self.past_interval_timestamp() is False: - if self._verbose: - print("Aborting check for updated, check interval not reached") + if not now and not self.past_interval_timestamp(): + self.print_verbose( + "Aborting check for updated, check interval not reached") return (False, None, None) # check if using tags or releases # note that if called the first time, this will pull tags from online - if self._fake_install is True: - if self._verbose: - print("fake_install = True, setting fake version as ready") + if self._fake_install: + self.print_verbose( + "fake_install = True, setting fake version as ready") self._update_ready = True self._update_version = "(999,999,999)" self._update_link = "http://127.0.0.1" - return (self._update_ready, self._update_version, self._update_link) + return (self._update_ready, + self._update_version, + self._update_link) - # primary internet call - self.get_tags() # sets self._tags and self._tag_latest + # Primary internet call, sets self._tags and self._tag_latest. + self.get_tags() self._json["last_check"] = str(datetime.now()) self.save_updater_json() - # can be () or ('master') in addition to branches, and version tag + # Can be () or ('master') in addition to branches, and version tag. new_version = self.version_tuple_from_text(self.tag_latest) if len(self._tags) == 0: @@ -1155,7 +1288,8 @@ def check_for_update(self, now=False): self._update_version = None self._update_link = None return (False, None, None) - if self._include_branches is False: + + if not self._include_branches: link = self.select_link(self, self._tags[0]) else: n = len(self._include_branch_list) @@ -1172,27 +1306,24 @@ def check_for_update(self, now=False): self._update_link = None return (False, None, None) elif str(new_version).lower() in self._include_branch_list: - # handle situation where master/whichever branch is included + # Handle situation where master/whichever branch is included # however, this code effectively is not triggered now - # as new_version will only be tag names, not branch names - if self._include_branch_autocheck is False: - # don't offer update as ready, - # but set the link for the default - # branch for installing - self._update_ready = True + # as new_version will only be tag names, not branch names. + if not self._include_branch_auto_check: + # Don't offer update as ready, but set the link for the + # default branch for installing. + self._update_ready = False self._update_version = new_version self._update_link = link self.save_updater_json() return (True, new_version, link) else: + # Bypass releases and look at timestamp of last update from a + # branch compared to now, see if commit values match or not. raise ValueError("include_branch_autocheck: NOT YET DEVELOPED") - # bypass releases and look at timestamp of last update - # from a branch compared to now, see if commit values - # match or not. else: - # situation where branches not included - + # Situation where branches not included. if new_version > self._current_version: self._update_ready = True @@ -1201,40 +1332,44 @@ def check_for_update(self, now=False): self.save_updater_json() return (True, new_version, link) - # elif new_version != self._current_version: - # self._update_ready = False - # self._update_version = new_version - # self._update_link = link - # self.save_updater_json() - # return (True, new_version, link) - - # if no update, set ready to False from None + # If no update, set ready to False from None to show it was checked. self._update_ready = False self._update_version = None self._update_link = None return (False, None, None) - def set_tag(self, name): + """Assign the tag name and url to update to""" tg = None for tag in self._tags: if name == tag["name"]: tg = tag break - if tg is None: - raise ValueError("Version tag not found: "+name) - new_version = self.version_tuple_from_text(self.tag_latest) - self._update_version = new_version - self._update_link = self.select_link(self, tg) - + if tg: + new_version = self.version_tuple_from_text(self.tag_latest) + self._update_version = new_version + self._update_link = self.select_link(self, tg) + elif self._include_branches and name in self._include_branch_list: + # scenario if reverting to a specific branch name instead of tag + tg = name + link = self.form_branch_url(tg) + self._update_version = name # this will break things + self._update_link = link + if not tg: + raise ValueError("Version tag not found: " + name) def run_update(self, force=False, revert_tag=None, clean=False, callback=None): - # revert_tag: could e.g. get from drop down list - # different versions of the addon to revert back to - # clean: not used, but in future could use to totally refresh addon + """Runs an install, update, or reversion of an addon from online source + + Arguments: + force: Install assigned link, even if self.update_ready is False + revert_tag: Version to install, if none uses detected update link + clean: not used, but in future could use to totally refresh addon + callback: used to run function on update completion + """ self._json["update_ready"] = False self._json["ignore"] = False # clear ignore flag - self._json["version_text"] = {} + self._json["version_text"] = dict() if revert_tag is not None: self.set_tag(revert_tag) @@ -1244,13 +1379,13 @@ def run_update(self, force=False, revert_tag=None, clean=False, callback=None): self._error = None self._error_msg = None - if self._verbose: print("Running update") + self.print_verbose("Running update") - if self._fake_install is True: - # change to True, to trigger the reload/"update installed" handler - if self._verbose: - print("fake_install=True") - print("Just reloading and running any handler triggers") + if self._fake_install: + # Change to True, to trigger the reload/"update installed" handler. + self.print_verbose("fake_install=True") + self.print_verbose( + "Just reloading and running any handler triggers") self._json["just_updated"] = True self.save_updater_json() if self._backup_current is True: @@ -1259,110 +1394,120 @@ def run_update(self, force=False, revert_tag=None, clean=False, callback=None): self._update_ready = False res = True # fake "success" zip download flag - elif force is False: - if self._update_ready is not True: - if self._verbose: print("Update stopped, new version not ready") + elif not force: + if not self._update_ready: + self.print_verbose("Update stopped, new version not ready") + if callback: + callback( + self._addon_package, + "Update stopped, new version not ready") return "Update stopped, new version not ready" elif self._update_link is None: # this shouldn't happen if update is ready - if self._verbose: print("Update stopped, update link unavailable") + self.print_verbose("Update stopped, update link unavailable") + if callback: + callback(self._addon_package, + "Update stopped, update link unavailable") return "Update stopped, update link unavailable" - if self._verbose and revert_tag is None: - print("Staging update") - elif self._verbose: - print("Staging install") + if revert_tag is None: + self.print_verbose("Staging update") + else: + self.print_verbose("Staging install") res = self.stage_repository(self._update_link) - if res is not True: - print("Error in staging repository: "+str(res)) - if callback is not None: callback(self._error_msg) + if not res: + print("Error in staging repository: " + str(res)) + if callback is not None: + callback(self._addon_package, self._error_msg) return self._error_msg - self.unpack_staged_zip(clean) + res = self.unpack_staged_zip(clean) + if res < 0: + if callback: + callback(self._addon_package, self._error_msg) + return res else: if self._update_link is None: - if self._verbose: print("Update stopped, could not get link") + self.print_verbose("Update stopped, could not get link") return "Update stopped, could not get link" - if self._verbose: print("Forcing update") + self.print_verbose("Forcing update") res = self.stage_repository(self._update_link) - if res is not True: - print("Error in staging repository: "+str(res)) - if callback is not None: callback(self._error_msg) + if not res: + print("Error in staging repository: " + str(res)) + if callback: + callback(self._addon_package, self._error_msg) return self._error_msg - self.unpack_staged_zip(clean) + res = self.unpack_staged_zip(clean) + if res < 0: + return res # would need to compare against other versions held in tags # run the front-end's callback if provided - if callback is not None: callback() + if callback: + callback(self._addon_package) # return something meaningful, 0 means it worked return 0 - def past_interval_timestamp(self): - if self._check_interval_enable is False: + if not self._check_interval_enabled: return True # ie this exact feature is disabled if "last_check" not in self._json or self._json["last_check"] == "": return True - else: - now = datetime.now() - last_check = datetime.strptime(self._json["last_check"], - "%Y-%m-%d %H:%M:%S.%f") - next_check = last_check - offset = timedelta( - days=self._check_interval_days + 30*self._check_interval_months, - hours=self._check_interval_hours, - minutes=self._check_interval_minutes - ) - - delta = (now - offset) - last_check - if delta.total_seconds() > 0: - if self._verbose: - print("{} Updater: Time to check for updates!".format(self._addon)) - return True - else: - if self._verbose: - print("{} Updater: Determined it's not yet time to check for updates".format(self._addon)) - return False + + now = datetime.now() + last_check = datetime.strptime( + self._json["last_check"], "%Y-%m-%d %H:%M:%S.%f") + offset = timedelta( + days=self._check_interval_days + 30 * self._check_interval_months, + hours=self._check_interval_hours, + minutes=self._check_interval_minutes) + + delta = (now - offset) - last_check + if delta.total_seconds() > 0: + self.print_verbose("Time to check for updates!") + return True + + self.print_verbose("Determined it's not yet time to check for updates") + return False def get_json_path(self): - """Returns the full path to the json state file used by this updater. + """Returns the full path to the JSON state file used by this updater. - Will also rename old file paths to addon-specific path if found + Will also rename old file paths to addon-specific path if found. """ - json_path = os.path.join(self._updater_path, - "{}_updater_status.json".format(self._addon_package)) + json_path = os.path.join( + self._updater_path, + "{}_updater_status.json".format(self._addon_package)) old_json_path = os.path.join(self._updater_path, "updater_status.json") - # rename old file if it exists + # Rename old file if it exists. try: os.rename(old_json_path, json_path) except FileNotFoundError: pass except Exception as err: - print("Other OS error occured while trying to rename old json") + print("Other OS error occurred while trying to rename old JSON") print(err) + self.print_trace() return json_path def set_updater_json(self): - """Load or initialize json dictionary data for updater state""" + """Load or initialize JSON dictionary data for updater state""" if self._updater_path is None: raise ValueError("updater_path is not defined") - elif os.path.isdir(self._updater_path) is False: + elif not os.path.isdir(self._updater_path): os.makedirs(self._updater_path) jpath = self.get_json_path() if os.path.isfile(jpath): with open(jpath) as data_file: self._json = json.load(data_file) - if self._verbose: - print("{} Updater: Read in json settings from file".format( - self._addon)) + self.print_verbose("Read in JSON settings from file") else: - # set data structure self._json = { "last_check": "", "backup_date": "", @@ -1370,63 +1515,64 @@ def set_updater_json(self): "ignore": False, "just_restored": False, "just_updated": False, - "version_text":{} + "version_text": dict() } self.save_updater_json() - def save_updater_json(self): - # first save the state - if self._update_ready is True: + """Trigger save of current json structure into file within addon""" + if self._update_ready: if isinstance(self._update_version, tuple): self._json["update_ready"] = True self._json["version_text"]["link"] = self._update_link self._json["version_text"]["version"] = self._update_version else: self._json["update_ready"] = False - self._json["version_text"] = {} + self._json["version_text"] = dict() else: self._json["update_ready"] = False - self._json["version_text"] = {} + self._json["version_text"] = dict() jpath = self.get_json_path() - outf = open(jpath, 'w') - data_out = json.dumps(self._json, indent=4) - outf.write(data_out) - outf.close() - if self._verbose: - print(self._addon+": Wrote out updater json settings to file, with the contents:") - print(self._json) + if not os.path.isdir(os.path.dirname(jpath)): + print("State error: Directory does not exist, cannot save json: ", + os.path.basename(jpath)) + return + try: + with open(jpath, 'w') as outf: + data_out = json.dumps(self._json, indent=4) + outf.write(data_out) + except: + print("Failed to open/save data to json: ", jpath) + self.print_trace() + self.print_verbose("Wrote out updater JSON settings with content:") + self.print_verbose(str(self._json)) def json_reset_postupdate(self): self._json["just_updated"] = False self._json["update_ready"] = False - self._json["version_text"] = {} + self._json["version_text"] = dict() self.save_updater_json() def json_reset_restore(self): self._json["just_restored"] = False self._json["update_ready"] = False - self._json["version_text"] = {} + self._json["version_text"] = dict() self.save_updater_json() - self._update_ready = None # reset so you could check update again + self._update_ready = None # Reset so you could check update again. def ignore_update(self): self._json["ignore"] = True self.save_updater_json() - # ------------------------------------------------------------------------- - # ASYNC stuff + # ASYNC related methods # ------------------------------------------------------------------------- - def start_async_check_update(self, now=False, callback=None): """Start a background thread which will check for updates""" - if self._async_checking is True: + if self._async_checking: return - if self._verbose: - print("{} updater: Starting background checking thread".format( - self._addon)) + self.print_verbose("Starting background checking thread") check_thread = threading.Thread(target=self.async_check_update, args=(now, callback,)) check_thread.daemon = True @@ -1436,28 +1582,28 @@ def start_async_check_update(self, now=False, callback=None): def async_check_update(self, now, callback=None): """Perform update check, run as target of background thread""" self._async_checking = True - if self._verbose: - print("{} BG thread: Checking for update now in background".format( - self._addon)) - # time.sleep(3) # to test background, in case internet too fast to tell - # try: - self.check_for_update(now=now) - # except Exception as exception: - # print("Checking for update error:") - # print(exception) - # self._update_ready = False - # self._update_version = None - # self._update_link = None - # self._error = "Error occurred" - # self._error_msg = "Encountered an error while checking for updates" + self.print_verbose("Checking for update now in background") + + try: + self.check_for_update(now=now) + except Exception as exception: + print("Checking for update error:") + print(exception) + self.print_trace() + if not self._error: + self._update_ready = False + self._update_version = None + self._update_link = None + self._error = "Error occurred" + self._error_msg = "Encountered an error while checking for updates" self._async_checking = False self._check_thread = None - if self._verbose: - print("{} BG thread: Finished checking for update, doing callback".format(self._addon)) - if callback is not None: + if callback: + self.print_verbose("Finished check update, doing callback") callback(self._update_ready) + self.print_verbose("BG thread: Finished check update, no callback") def stop_async_check_update(self): """Method to give impression of stopping check for update. @@ -1469,10 +1615,10 @@ def stop_async_check_update(self): on next UI refresh (ie no update, or update available). """ if self._check_thread is not None: - if self._verbose: print("Thread will end in normal course.") + self.print_verbose("Thread will end in normal course.") # however, "There is no direct kill method on a thread object." # better to let it run its course - #self._check_thread.stop() + # self._check_thread.stop() self._async_checking = False self._error = None self._error_msg = None @@ -1483,7 +1629,7 @@ def stop_async_check_update(self): # ----------------------------------------------------------------------------- -class BitbucketEngine(object): +class BitbucketEngine: """Integration to Bitbucket API for git-formatted repositories""" def __init__(self): @@ -1492,7 +1638,8 @@ def __init__(self): self.name = "bitbucket" def form_repo_url(self, updater): - return self.api_url+"/2.0/repositories/"+updater.user+"/"+updater.repo + return "{}/2.0/repositories/{}/{}".format( + self.api_url, updater.user, updater.repo) def form_tags_url(self, updater): return self.form_repo_url(updater) + "/refs/tags?sort=-name" @@ -1508,11 +1655,15 @@ def get_zip_url(self, name, updater): def parse_tags(self, response, updater): if response is None: - return [] - return [{"name": tag["name"], "zipball_url": self.get_zip_url(tag["name"], updater)} for tag in response["values"]] + return list() + return [ + { + "name": tag["name"], + "zipball_url": self.get_zip_url(tag["name"], updater) + } for tag in response["values"]] -class GithubEngine(object): +class GithubEngine: """Integration to Github API""" def __init__(self): @@ -1521,29 +1672,28 @@ def __init__(self): self.name = "github" def form_repo_url(self, updater): - return "{}{}{}{}{}".format(self.api_url, "/repos/", updater.user, - "/",updater.repo) + return "{}/repos/{}/{}".format( + self.api_url, updater.user, updater.repo) def form_tags_url(self, updater): if updater.use_releases: - return "{}{}".format(self.form_repo_url(updater), "/releases") + return "{}/releases".format(self.form_repo_url(updater)) else: - return "{}{}".format(self.form_repo_url(updater), "/tags") + return "{}/tags".format(self.form_repo_url(updater)) def form_branch_list_url(self, updater): - return "{}{}".format(self.form_repo_url(updater), "/branches") + return "{}/branches".format(self.form_repo_url(updater)) def form_branch_url(self, branch, updater): - return "{}{}{}".format(self.form_repo_url(updater), - "/zipball/", branch) + return "{}/zipball/{}".format(self.form_repo_url(updater), branch) def parse_tags(self, response, updater): if response is None: - return [] + return list() return response -class GitlabEngine(object): +class GitlabEngine: """Integration to GitLab API""" def __init__(self): @@ -1552,25 +1702,21 @@ def __init__(self): self.name = "gitlab" def form_repo_url(self, updater): - return "{}{}{}".format(self.api_url, "/api/v4/projects/", updater.repo) + return "{}/api/v4/projects/{}".format(self.api_url, updater.repo) def form_tags_url(self, updater): - return "{}{}".format(self.form_repo_url(updater), "/repository/tags") + return "{}/repository/tags".format(self.form_repo_url(updater)) def form_branch_list_url(self, updater): # does not validate branch name. - return "{}{}".format( - self.form_repo_url(updater), - "/repository/branches") + return "{}/repository/branches".format( + self.form_repo_url(updater)) def form_branch_url(self, branch, updater): - # Could clash with tag names and if it does, it will - # download TAG zip instead of branch zip to get - # direct path, would need. - return "{}{}{}".format( - self.form_repo_url(updater), - "/repository/archive.zip?sha=", - branch) + # Could clash with tag names and if it does, it will download TAG zip + # instead of branch zip to get direct path, would need. + return "{}/repository/archive.zip?sha={}".format( + self.form_repo_url(updater), branch) def get_zip_url(self, sha, updater): return "{base}/repository/archive.zip?sha={sha}".format( @@ -1578,12 +1724,16 @@ def get_zip_url(self, sha, updater): sha=sha) # def get_commit_zip(self, id, updater): - # return self.form_repo_url(updater)+"/repository/archive.zip?sha:"+id + # return self.form_repo_url(updater)+"/repository/archive.zip?sha:"+id def parse_tags(self, response, updater): if response is None: - return [] - return [{"name": tag["name"], "zipball_url": self.get_zip_url(tag["commit"]["id"], updater)} for tag in response] + return list() + return [ + { + "name": tag["name"], + "zipball_url": self.get_zip_url(tag["commit"]["id"], updater) + } for tag in response] # ----------------------------------------------------------------------------- @@ -1591,4 +1741,4 @@ def parse_tags(self, response, updater): # should be what's imported to other files # ----------------------------------------------------------------------------- -Updater = Singleton_updater() +Updater = SingletonUpdater() diff --git a/addon_updater_ops.py b/addon_updater_ops.py index be0e86d9..e42b3709 100644 --- a/addon_updater_ops.py +++ b/addon_updater_ops.py @@ -16,55 +16,78 @@ # # ##### END GPL LICENSE BLOCK ##### +"""Blender UI integrations for the addon updater. + +Implements draw calls, popups, and operators that use the addon_updater. +""" + import os +import traceback import bpy from bpy.app.handlers import persistent -# updater import, import safely +# Safely import the updater. # Prevents popups for users with invalid python installs e.g. missing libraries +# and will replace with a fake class instead if it fails (so UI draws work). try: from .addon_updater import Updater as updater except Exception as e: print("ERROR INITIALIZING UPDATER") print(str(e)) - class Singleton_updater_none(object): + traceback.print_exc() + + class SingletonUpdaterNone(object): + """Fake, bare minimum fields and functions for the updater object.""" + def __init__(self): + self.invalid_updater = True # Used to distinguish bad install. + self.addon = None self.verbose = False - self.invalidupdater = True # used to distinguish bad install + self.use_print_traces = True self.error = None self.error_msg = None self.async_checking = None + def clear_state(self): self.addon = None self.verbose = False - self.invalidupdater = True + self.invalid_updater = True self.error = None self.error_msg = None self.async_checking = None - def run_update(self): pass - def check_for_update(self): pass - updater = Singleton_updater_none() + + def run_update(self, force, callback, clean): + pass + + def check_for_update(self, now): + pass + + updater = SingletonUpdaterNone() updater.error = "Error initializing updater module" updater.error_msg = str(e) -# Must declare this before classes are loaded -# otherwise the bl_idname's will not match and have errors. -# Must be all lowercase and no spaces -updater.addon = "mb_lab" +# Must declare this before classes are loaded, otherwise the bl_idname's will +# not match and have errors. Must be all lowercase and no spaces! Should also +# be unique among any other addons that could exist (using this updater code), +# to avoid clashes in operator registration. +updater.addon = "addon_updater_demo" # ----------------------------------------------------------------------------- # Blender version utils # ----------------------------------------------------------------------------- - - def make_annotations(cls): - """Add annotation attribute to class fields to avoid Blender 2.8 warnings""" + """Add annotation attribute to fields to avoid Blender 2.8+ warnings""" if not hasattr(bpy.app, "version") or bpy.app.version < (2, 80): return cls - bl_props = {k: v for k, v in cls.__dict__.items() if isinstance(v, tuple)} + if bpy.app.version < (2, 93, 0): + bl_props = {k: v for k, v in cls.__dict__.items() + if isinstance(v, tuple)} + else: + bl_props = {k: v for k, v in cls.__dict__.items() + if isinstance(v, bpy.props._PropertyDeferred)} if bl_props: if '__annotations__' not in cls.__dict__: setattr(cls, '__annotations__', {}) @@ -103,12 +126,12 @@ def get_user_preferences(context=None): # ----------------------------------------------------------------------------- -# simple popup for prompting checking for update & allow to install if available -class addon_updater_install_popup(bpy.types.Operator): +# Simple popup to prompt use to check for update & offer install if available. +class AddonUpdaterInstallPopup(bpy.types.Operator): """Check and install update if available""" bl_label = "Update {x} addon".format(x=updater.addon) - bl_idname = updater.addon+".updater_install_popup" - bl_description = "Popup menu to check and display current updates available" + bl_idname = updater.addon + ".updater_install_popup" + bl_description = "Popup to check and display current updates available" bl_options = {'REGISTER', 'INTERNAL'} # if true, run clean install - ie remove all files before adding new @@ -116,10 +139,12 @@ class addon_updater_install_popup(bpy.types.Operator): # updater folder/backup folder remains clean_install = bpy.props.BoolProperty( name="Clean install", - description="If enabled, completely clear the addon's folder before installing new update, creating a fresh install", + description=("If enabled, completely clear the addon's folder before " + "installing new update, creating a fresh install"), default=False, options={'HIDDEN'} ) + ignore_enum = bpy.props.EnumProperty( name="Process update", description="Decide to install, ignore, or defer new addon update", @@ -139,56 +164,54 @@ def invoke(self, context, event): def draw(self, context): layout = self.layout - if updater.invalidupdater is True: + if updater.invalid_updater: layout.label(text="Updater module error") return - elif updater.update_ready is True: + elif updater.update_ready: col = layout.column() col.scale_y = 0.7 - col.label(text="Update {} ready!".format(str(updater.update_version)), + col.label(text="Update {} ready!".format(updater.update_version), icon="LOOP_FORWARDS") - col.label(text="Choose 'Update Now' & press OK to install, ", icon="BLANK1") + col.label(text="Choose 'Update Now' & press OK to install, ", + icon="BLANK1") col.label(text="or click outside window to defer", icon="BLANK1") row = col.row() row.prop(self, "ignore_enum", expand=True) col.split() - elif updater.update_ready is False: + elif not updater.update_ready: col = layout.column() col.scale_y = 0.7 col.label(text="No updates available") col.label(text="Press okay to dismiss dialog") # add option to force install else: - # case: updater.update_ready = None - # we have not yet checked for the update + # Case: updater.update_ready = None + # we have not yet checked for the update. layout.label(text="Check for update now?") - # potentially in future, could have UI for 'check to select old version' - # to revert back to. + # Potentially in future, UI to 'check to select/revert to old version'. def execute(self, context): - - # in case of error importing updater - if updater.invalidupdater is True: + # In case of error importing updater. + if updater.invalid_updater: return {'CANCELLED'} - if updater.manual_only is True: + if updater.manual_only: bpy.ops.wm.url_open(url=updater.website) - elif updater.update_ready is True: + elif updater.update_ready: - # action based on enum selection + # Action based on enum selection. if self.ignore_enum == 'defer': return {'FINISHED'} elif self.ignore_enum == 'ignore': updater.ignore_update() return {'FINISHED'} - #else: "install update now!" - res = updater.run_update( - force=False, - callback=post_update_callback, - clean=self.clean_install) - # should return 0, if not something happened + res = updater.run_update(force=False, + callback=post_update_callback, + clean=self.clean_install) + + # Should return 0, if not something happened. if updater.verbose: if res == 0: print("Updater returned successful") @@ -197,69 +220,68 @@ def execute(self, context): elif updater.update_ready is None: _ = updater.check_for_update(now=True) - # re-launch this dialog - atr = addon_updater_install_popup.bl_idname.split(".") + # Re-launch this dialog. + atr = AddonUpdaterInstallPopup.bl_idname.split(".") getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') else: - if updater.verbose: - print("Doing nothing, not ready for update") + updater.print_verbose("Doing nothing, not ready for update") return {'FINISHED'} # User preference check-now operator -class addon_updater_check_now(bpy.types.Operator): - bl_label = "Check now for "+updater.addon+" update" - bl_idname = updater.addon+".updater_check_now" - bl_description = "Check now for an update to the {x} addon".format( - x=updater.addon) +class AddonUpdaterCheckNow(bpy.types.Operator): + bl_label = "Check now for " + updater.addon + " update" + bl_idname = updater.addon + ".updater_check_now" + bl_description = "Check now for an update to the {} addon".format( + updater.addon) bl_options = {'REGISTER', 'INTERNAL'} def execute(self, context): - if updater.invalidupdater is True: + if updater.invalid_updater: return {'CANCELLED'} - if updater.async_checking is True and updater.error is None: - # Check already happened - # Used here to just avoid constant applying settings below - # Ignoring if error, to prevent being stuck on the error screen + if updater.async_checking and updater.error is None: + # Check already happened. + # Used here to just avoid constant applying settings below. + # Ignoring if error, to prevent being stuck on the error screen. return {'CANCELLED'} # apply the UI settings settings = get_user_preferences(context) if not settings: - if updater.verbose: - print("Could not get {} preferences, update check skipped".format( + updater.print_verbose( + "Could not get {} preferences, update check skipped".format( __package__)) return {'CANCELLED'} + updater.set_check_interval( - enable=settings.auto_check_update, - months=settings.updater_intrval_months, - days=settings.updater_intrval_days, - hours=settings.updater_intrval_hours, - minutes=settings.updater_intrval_minutes - ) # optional, if auto_check_update - - # input is an optional callback function - # this function should take a bool input, if true: update ready - # if false, no update ready + enabled=settings.auto_check_update, + months=settings.updater_interval_months, + days=settings.updater_interval_days, + hours=settings.updater_interval_hours, + minutes=settings.updater_interval_minutes) + + # Input is an optional callback function. This function should take a + # bool input. If true: update ready, if false: no update ready. updater.check_for_update_now(ui_refresh) return {'FINISHED'} -class addon_updater_update_now(bpy.types.Operator): - bl_label = "Update "+updater.addon+" addon now" - bl_idname = updater.addon+".updater_update_now" +class AddonUpdaterUpdateNow(bpy.types.Operator): + bl_label = "Update " + updater.addon + " addon now" + bl_idname = updater.addon + ".updater_update_now" bl_description = "Update to the latest version of the {x} addon".format( x=updater.addon) bl_options = {'REGISTER', 'INTERNAL'} - # if true, run clean install - ie remove all files before adding new - # equivalent to deleting the addon and reinstalling, except the - # updater folder/backup folder remains + # If true, run clean install - ie remove all files before adding new + # equivalent to deleting the addon and reinstalling, except the updater + # folder/backup folder remains. clean_install = bpy.props.BoolProperty( name="Clean install", - description="If enabled, completely clear the addon's folder before installing new update, creating a fresh install", + description=("If enabled, completely clear the addon's folder before " + "installing new update, creating a fresh install"), default=False, options={'HIDDEN'} ) @@ -267,52 +289,57 @@ class addon_updater_update_now(bpy.types.Operator): def execute(self, context): # in case of error importing updater - if updater.invalidupdater is True: + if updater.invalid_updater: return {'CANCELLED'} - if updater.manual_only is True: + if updater.manual_only: bpy.ops.wm.url_open(url=updater.website) - if updater.update_ready is True: + if updater.update_ready: # if it fails, offer to open the website instead try: - res = updater.run_update( - force=False, - callback=post_update_callback, - clean=self.clean_install) + res = updater.run_update(force=False, + callback=post_update_callback, + clean=self.clean_install) - # should return 0, if not something happened + # Should return 0, if not something happened. if updater.verbose: - if res == 0: print("Updater returned successful") - else: print("Updater returned "+str(res)+", error occurred") - except Exception as e: + if res == 0: + print("Updater returned successful") + else: + print("Updater error response: {}".format(res)) + except Exception as expt: updater._error = "Error trying to run update" - updater._error_msg = str(e) - atr = addon_updater_install_manually.bl_idname.split(".") + updater._error_msg = str(expt) + updater.print_trace() + atr = AddonUpdaterInstallManually.bl_idname.split(".") getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') elif updater.update_ready is None: (update_ready, version, link) = updater.check_for_update(now=True) - # re-launch this dialog - atr = addon_updater_install_popup.bl_idname.split(".") + # Re-launch this dialog. + atr = AddonUpdaterInstallPopup.bl_idname.split(".") getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') - elif updater.update_ready is False: + elif not updater.update_ready: self.report({'INFO'}, "Nothing to update") + return {'CANCELLED'} else: - self.report({'ERROR'}, "Encountered problem while trying to update") + self.report( + {'ERROR'}, "Encountered a problem while trying to update") + return {'CANCELLED'} return {'FINISHED'} -class addon_updater_update_target(bpy.types.Operator): - bl_label = updater.addon+" version target" - bl_idname = updater.addon+".updater_update_target" +class AddonUpdaterUpdateTarget(bpy.types.Operator): + bl_label = updater.addon + " version target" + bl_idname = updater.addon + ".updater_update_target" bl_description = "Install a targeted version of the {x} addon".format( x=updater.addon) bl_options = {'REGISTER', 'INTERNAL'} def target_version(self, context): - # in case of error importing updater - if updater.invalidupdater is True: + # In case of error importing updater. + if updater.invalid_updater: ret = [] ret = [] @@ -326,42 +353,42 @@ def target_version(self, context): name="Target version to install", description="Select the version to install", items=target_version - ) + ) - # if true, run clean install - ie remove all files before adding new + # If true, run clean install - ie remove all files before adding new # equivalent to deleting the addon and reinstalling, except the - # updater folder/backup folder remains + # updater folder/backup folder remains. clean_install = bpy.props.BoolProperty( name="Clean install", - description="If enabled, completely clear the addon's folder before installing new update, creating a fresh install", + description=("If enabled, completely clear the addon's folder before " + "installing new update, creating a fresh install"), default=False, options={'HIDDEN'} ) @classmethod def poll(cls, context): - if updater.invalidupdater is True: return False - return (updater.update_ready is not None) and (len(updater.tags) > 0) + if updater.invalid_updater: + return False + return updater.update_ready is not None and len(updater.tags) > 0 def invoke(self, context, event): return context.window_manager.invoke_props_dialog(self) def draw(self, context): layout = self.layout - if updater.invalidupdater is True: + if updater.invalid_updater: layout.label(text="Updater error") return - split = layout_split(layout, factor=0.66) - subcol = split.column() - subcol.label(text="Select install version") - subcol = split.column() - subcol.prop(self, "target", text="") - + split = layout_split(layout, factor=0.5) + sub_col = split.column() + sub_col.label(text="Select install version") + sub_col = split.column() + sub_col.prop(self, "target", text="") def execute(self, context): - - # in case of error importing updater - if updater.invalidupdater is True: + # In case of error importing updater. + if updater.invalid_updater: return {'CANCELLED'} res = updater.run_update( @@ -370,22 +397,21 @@ def execute(self, context): callback=post_update_callback, clean=self.clean_install) - # should return 0, if not something happened + # Should return 0, if not something happened. if res == 0: - if updater.verbose: - print("Updater returned successful") + updater.print_verbose("Updater returned successful") else: - if updater.verbose: - print("Updater returned "+str(res)+", error occurred") + updater.print_verbose( + "Updater returned {}, , error occurred".format(res)) return {'CANCELLED'} return {'FINISHED'} -class addon_updater_install_manually(bpy.types.Operator): +class AddonUpdaterInstallManually(bpy.types.Operator): """As a fallback, direct the user to download the addon manually""" bl_label = "Install update manually" - bl_idname = "object.updater_install_manually" + bl_idname = updater.addon + ".updater_install_manually" bl_description = "Proceed to manually install update" bl_options = {'REGISTER', 'INTERNAL'} @@ -393,7 +419,7 @@ class addon_updater_install_manually(bpy.types.Operator): name="Error Occurred", default="", options={'HIDDEN'} - ) + ) def invoke(self, context, event): return context.window_manager.invoke_popup(self) @@ -401,16 +427,18 @@ def invoke(self, context, event): def draw(self, context): layout = self.layout - if updater.invalidupdater is True: + if updater.invalid_updater: layout.label(text="Updater error") return - # use a "failed flag"? it shows this label if the case failed. - if self.error != str(): + # Display error if a prior autoamted install failed. + if self.error != "": col = layout.column() col.scale_y = 0.7 - col.label(text="There was an issue trying to auto-install", icon="ERROR") - col.label(text="Press the download button below and install", icon="BLANK1") + col.label(text="There was an issue trying to auto-install", + icon="ERROR") + col.label(text="Press the download button below and install", + icon="BLANK1") col.label(text="the zip file like a normal addon.", icon="BLANK1") else: col = layout.column() @@ -419,35 +447,37 @@ def draw(self, context): col.label(text="Press the download button below and install") col.label(text="the zip file like a normal addon.") - # if check hasn't happened, i.e. accidentally called this menu - # allow to check here + # If check hasn't happened, i.e. accidentally called this menu, + # allow to check here. row = layout.row() if updater.update_link is not None: - row.operator("wm.url_open", text="Direct download").url =\ - updater.update_link + row.operator( + "wm.url_open", + text="Direct download").url = updater.update_link else: - row.operator("wm.url_open", text="(failed to retrieve direct download)") + row.operator( + "wm.url_open", + text="(failed to retrieve direct download)") row.enabled = False if updater.website is not None: row = layout.row() - row.operator("wm.url_open", text="Open website").url =\ - updater.website + ops = row.operator("wm.url_open", text="Open website") + ops.url = updater.website else: row = layout.row() row.label(text="See source website to download the update") def execute(self, context): - return {'FINISHED'} -class addon_updater_updated_successful(bpy.types.Operator): +class AddonUpdaterUpdatedSuccessful(bpy.types.Operator): """Addon in place, popup telling user it completed or what went wrong""" bl_label = "Installation Report" - bl_idname = "object.updater_update_successful" + bl_idname = updater.addon + ".updater_update_successful" bl_description = "Update installation response" bl_options = {'REGISTER', 'INTERNAL', 'UNDO'} @@ -455,7 +485,7 @@ class addon_updater_updated_successful(bpy.types.Operator): name="Error Occurred", default="", options={'HIDDEN'} - ) + ) def invoke(self, context, event): return context.window_manager.invoke_props_popup(self, event) @@ -463,7 +493,7 @@ def invoke(self, context, event): def draw(self, context): layout = self.layout - if updater.invalidupdater is True: + if updater.invalid_updater: layout.label(text="Updater error") return @@ -472,50 +502,67 @@ def draw(self, context): col = layout.column() col.scale_y = 0.7 col.label(text="Error occurred, did not install", icon="ERROR") - col.label(text=updater.error_msg, icon="BLANK1") + if updater.error_msg: + msg = updater.error_msg + else: + msg = self.error + col.label(text=str(msg), icon="BLANK1") rw = col.row() rw.scale_y = 2 - rw.operator("wm.url_open", - text="Click for manual download.", - icon="BLANK1" - ).url = updater.website - # manual download button here - elif updater.auto_reload_post_update is False: - # tell user to restart blender - if "just_restored" in saved and saved["just_restored"] is True: + rw.operator( + "wm.url_open", + text="Click for manual download.", + icon="BLANK1").url = updater.website + elif not updater.auto_reload_post_update: + # Tell user to restart blender after an update/restore! + if "just_restored" in saved and saved["just_restored"]: col = layout.column() - col.scale_y = 0.7 col.label(text="Addon restored", icon="RECOVER_LAST") - col.label(text="Restart blender to reload.", icon="BLANK1") + alert_row = col.row() + alert_row.alert = True + alert_row.operator( + "wm.quit_blender", + text="Restart blender to reload", + icon="BLANK1") updater.json_reset_restore() else: col = layout.column() - col.scale_y = 0.7 - col.label(text="Addon successfully installed", icon="FILE_TICK") - col.label(text="Restart blender to reload.", icon="BLANK1") + col.label( + text="Addon successfully installed", icon="FILE_TICK") + alert_row = col.row() + alert_row.alert = True + alert_row.operator( + "wm.quit_blender", + text="Restart blender to reload", + icon="BLANK1") else: # reload addon, but still recommend they restart blender - if "just_restored" in saved and saved["just_restored"] is True: + if "just_restored" in saved and saved["just_restored"]: col = layout.column() col.scale_y = 0.7 col.label(text="Addon restored", icon="RECOVER_LAST") - col.label(text="Consider restarting blender to fully reload.", icon="BLANK1") + col.label( + text="Consider restarting blender to fully reload.", + icon="BLANK1") updater.json_reset_restore() else: col = layout.column() col.scale_y = 0.7 - col.label(text="Addon successfully installed", icon="FILE_TICK") - col.label(text="Consider restarting blender to fully reload.", icon="BLANK1") + col.label( + text="Addon successfully installed", icon="FILE_TICK") + col.label( + text="Consider restarting blender to fully reload.", + icon="BLANK1") def execute(self, context): return {'FINISHED'} -class addon_updater_restore_backup(bpy.types.Operator): +class AddonUpdaterRestoreBackup(bpy.types.Operator): """Restore addon from backup""" bl_label = "Restore backup" - bl_idname = "object.updater_restore_backup" + bl_idname = updater.addon + ".updater_restore_backup" bl_description = "Restore addon from backup" bl_options = {'REGISTER', 'INTERNAL'} @@ -528,54 +575,47 @@ def poll(cls, context): def execute(self, context): # in case of error importing updater - if updater.invalidupdater is True: + if updater.invalid_updater: return {'CANCELLED'} updater.restore_backup() return {'FINISHED'} -class addon_updater_ignore(bpy.types.Operator): - """Prevent future update notice popups""" +class AddonUpdaterIgnore(bpy.types.Operator): + """Ignore update to prevent future popups""" bl_label = "Ignore update" - bl_idname = "object.updater_ignore" + bl_idname = updater.addon + ".updater_ignore" bl_description = "Ignore update to prevent future popups" bl_options = {'REGISTER', 'INTERNAL'} @classmethod def poll(cls, context): - if updater.invalidupdater is True: + if updater.invalid_updater: return False - elif updater.update_ready is True: + elif updater.update_ready: return True else: return False def execute(self, context): # in case of error importing updater - if updater.invalidupdater is True: + if updater.invalid_updater: return {'CANCELLED'} updater.ignore_update() self.report({"INFO"}, "Open addon preferences for updater options") return {'FINISHED'} -class addon_updater_end_background(bpy.types.Operator): +class AddonUpdaterEndBackground(bpy.types.Operator): """Stop checking for update in the background""" bl_label = "End background check" - bl_idname = "object.end_background_check" + bl_idname = updater.addon + ".end_background_check" bl_description = "Stop checking for update in the background" bl_options = {'REGISTER', 'INTERNAL'} - # @classmethod - # def poll(cls, context): - # if updater.async_checking is True: - # return True - # else: - # return False - def execute(self, context): # in case of error importing updater - if updater.invalidupdater is True: + if updater.invalid_updater: return {'CANCELLED'} updater.stop_async_check_update() return {'FINISHED'} @@ -587,120 +627,143 @@ def execute(self, context): # global vars used to prevent duplicate popup handlers -ran_autocheck_install_popup = False -ran_update_sucess_popup = False +ran_auto_check_install_popup = False +ran_update_success_popup = False # global var for preventing successive calls ran_background_check = False + @persistent def updater_run_success_popup_handler(scene): - global ran_update_sucess_popup - ran_update_sucess_popup = True + global ran_update_success_popup + ran_update_success_popup = True # in case of error importing updater - if updater.invalidupdater is True: + if updater.invalid_updater: return try: - bpy.app.handlers.scene_update_post.remove( - updater_run_success_popup_handler) + if "scene_update_post" in dir(bpy.app.handlers): + bpy.app.handlers.scene_update_post.remove( + updater_run_success_popup_handler) + else: + bpy.app.handlers.depsgraph_update_post.remove( + updater_run_success_popup_handler) except: pass - atr = addon_updater_updated_successful.bl_idname.split(".") + atr = AddonUpdaterUpdatedSuccessful.bl_idname.split(".") getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') @persistent def updater_run_install_popup_handler(scene): - global ran_autocheck_install_popup - ran_autocheck_install_popup = True + global ran_auto_check_install_popup + ran_auto_check_install_popup = True + updater.print_verbose("Running the install popup handler.") # in case of error importing updater - if updater.invalidupdater is True: + if updater.invalid_updater: return try: - bpy.app.handlers.scene_update_post.remove( - updater_run_install_popup_handler) + if "scene_update_post" in dir(bpy.app.handlers): + bpy.app.handlers.scene_update_post.remove( + updater_run_install_popup_handler) + else: + bpy.app.handlers.depsgraph_update_post.remove( + updater_run_install_popup_handler) except: pass - if "ignore" in updater.json and updater.json["ignore"] is True: - return # don't do popup if ignore pressed - # elif type(updater.update_version) != type((0,0,0)): - # # likely was from master or another branch, shouldn't trigger popup - # updater.json_reset_restore() - # return - elif "version_text" in updater.json and "version" in updater.json["version_text"]: + if "ignore" in updater.json and updater.json["ignore"]: + return # Don't do popup if ignore pressed. + elif "version_text" in updater.json and updater.json["version_text"].get("version"): version = updater.json["version_text"]["version"] ver_tuple = updater.version_tuple_from_text(version) if ver_tuple < updater.current_version: - # user probably manually installed to get the up to date addon - # in here. Clear out the update flag using this function - if updater.verbose: - print("{} updater: appears user updated, clearing flag".format(\ - updater.addon)) + # User probably manually installed to get the up to date addon + # in here. Clear out the update flag using this function. + updater.print_verbose( + "{} updater: appears user updated, clearing flag".format( + updater.addon)) updater.json_reset_restore() return - atr = addon_updater_install_popup.bl_idname.split(".") + atr = AddonUpdaterInstallPopup.bl_idname.split(".") getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') def background_update_callback(update_ready): """Passed into the updater, background thread updater""" - global ran_autocheck_install_popup + global ran_auto_check_install_popup + updater.print_verbose("Running background update callback") - # in case of error importing updater - if updater.invalidupdater is True: + # In case of error importing updater. + if updater.invalid_updater: + return + if not updater.show_popups: return - if updater.showpopups is False: + if not update_ready: return - if update_ready is not True: + + # See if we need add to the update handler to trigger the popup. + handlers = [] + if "scene_update_post" in dir(bpy.app.handlers): # 2.7x + handlers = bpy.app.handlers.scene_update_post + else: # 2.8+ + handlers = bpy.app.handlers.depsgraph_update_post + in_handles = updater_run_install_popup_handler in handlers + + if in_handles or ran_auto_check_install_popup: return - if updater_run_install_popup_handler not in \ - bpy.app.handlers.scene_update_post and \ - ran_autocheck_install_popup is False: + + if "scene_update_post" in dir(bpy.app.handlers): # 2.7x bpy.app.handlers.scene_update_post.append( updater_run_install_popup_handler) - ran_autocheck_install_popup = True + else: # 2.8+ + bpy.app.handlers.depsgraph_update_post.append( + updater_run_install_popup_handler) + ran_auto_check_install_popup = True + updater.print_verbose("Attempted popup prompt") + +def post_update_callback(module_name, res=None): + """Callback for once the run_update function has completed. -def post_update_callback(res=None): - """Callback for once the updater has completed + Only makes sense to use this if "auto_reload_post_update" == False, + i.e. don't auto-restart the addon. - Only makes sense to use this if "auto_reload_post_update" is False, - i.e. don't auto-restart the addon + Arguments: + module_name: returns the module name from updater, but unused here. + res: If an error occurred, this is the detail string. """ - # in case of error importing updater - if updater.invalidupdater is True: + # In case of error importing updater. + if updater.invalid_updater: return if res is None: - # this is the same code as in conditional at the end of the register function - # ie if "auto_reload_post_update" is True, comment out this code - if updater.verbose: - print("{} updater: Running post update callback".format(updater.addon)) - #bpy.app.handlers.scene_update_post.append(updater_run_success_popup_handler) + # This is the same code as in conditional at the end of the register + # function, ie if "auto_reload_post_update" == True, skip code. + updater.print_verbose( + "{} updater: Running post update callback".format(updater.addon)) - atr = addon_updater_updated_successful.bl_idname.split(".") + atr = AddonUpdaterUpdatedSuccessful.bl_idname.split(".") getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') - global ran_update_sucess_popup - ran_update_sucess_popup = True + global ran_update_success_popup + ran_update_success_popup = True else: - # some kind of error occured and it was unable to install, - # offer manual download instead - atr = addon_updater_updated_successful.bl_idname.split(".") + # Some kind of error occurred and it was unable to install, offer + # manual download instead. + atr = AddonUpdaterUpdatedSuccessful.bl_idname.split(".") getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT', error=res) return def ui_refresh(update_status): - # find a way to just re-draw self? - # callback intended for trigger by async thread + """Redraw the ui once an async thread has completed""" for windowManager in bpy.data.window_managers: for window in windowManager.windows: for area in window.screen.areas: @@ -710,172 +773,187 @@ def ui_refresh(update_status): def check_for_update_background(): """Function for asynchronous background check. - *Could* be called on register, but would be bad practice. + *Could* be called on register, but would be bad practice as the bare + minimum code should run at the moment of registration (addon ticked). """ - if updater.invalidupdater is True: + if updater.invalid_updater: return global ran_background_check - if ran_background_check is True: - # Global var ensures check only happens once + if ran_background_check: + # Global var ensures check only happens once. return - elif updater.update_ready is not None or updater.async_checking is True: - # Check already happened - # Used here to just avoid constant applying settings below + elif updater.update_ready is not None or updater.async_checking: + # Check already happened. + # Used here to just avoid constant applying settings below. return - # apply the UI settings + # Apply the UI settings. settings = get_user_preferences(bpy.context) if not settings: return - updater.set_check_interval( - enable=settings.auto_check_update, - months=settings.updater_intrval_months, - days=settings.updater_intrval_days, - hours=settings.updater_intrval_hours, - minutes=settings.updater_intrval_minutes - ) # optional, if auto_check_update - - # input is an optional callback function - # this function should take a bool input, if true: update ready - # if false, no update ready - if updater.verbose: - print("{} updater: Running background check for update".format(\ - updater.addon)) + updater.set_check_interval(enabled=settings.auto_check_update, + months=settings.updater_interval_months, + days=settings.updater_interval_days, + hours=settings.updater_interval_hours, + minutes=settings.updater_interval_minutes) + + # Input is an optional callback function. This function should take a bool + # input, if true: update ready, if false: no update ready. updater.check_for_update_async(background_update_callback) ran_background_check = True def check_for_update_nonthreaded(self, context): """Can be placed in front of other operators to launch when pressed""" - if updater.invalidupdater is True: + if updater.invalid_updater: return - # only check if it's ready, ie after the time interval specified - # should be the async wrapper call here - + # Only check if it's ready, ie after the time interval specified should + # be the async wrapper call here. settings = get_user_preferences(bpy.context) if not settings: if updater.verbose: print("Could not get {} preferences, update check skipped".format( __package__)) return - updater.set_check_interval( - enable=settings.auto_check_update, - months=settings.updater_intrval_months, - days=settings.updater_intrval_days, - hours=settings.updater_intrval_hours, - minutes=settings.updater_intrval_minutes - ) # optional, if auto_check_update + updater.set_check_interval(enabled=settings.auto_check_update, + months=settings.updater_interval_months, + days=settings.updater_interval_days, + hours=settings.updater_interval_hours, + minutes=settings.updater_interval_minutes) (update_ready, version, link) = updater.check_for_update(now=False) - if update_ready is True: - atr = addon_updater_install_popup.bl_idname.split(".") + if update_ready: + atr = AddonUpdaterInstallPopup.bl_idname.split(".") getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') else: - if updater.verbose: print("No update ready") + updater.print_verbose("No update ready") self.report({'INFO'}, "No update ready") -def showReloadPopup(): - """For use in register only, to show popup after re-enabling the addon +def show_reload_popup(): + """For use in register only, to show popup after re-enabling the addon. - Must be enabled by developer + Must be enabled by developer. """ - if updater.invalidupdater is True: + if updater.invalid_updater: return saved_state = updater.json - global ran_update_sucess_popup + global ran_update_success_popup - a = saved_state is not None - b = "just_updated" in saved_state - c = saved_state["just_updated"] + has_state = saved_state is not None + just_updated = "just_updated" in saved_state + updated_info = saved_state["just_updated"] - if a and b and c: - updater.json_reset_postupdate() # so this only runs once + if not (has_state and just_updated and updated_info): + return - # no handlers in this case - if updater.auto_reload_post_update is False: return + updater.json_reset_postupdate() # So this only runs once. - if updater_run_success_popup_handler not in \ - bpy.app.handlers.scene_update_post \ - and ran_update_sucess_popup is False: - bpy.app.handlers.scene_update_post.append( - updater_run_success_popup_handler) - ran_update_sucess_popup = True + # No handlers in this case. + if not updater.auto_reload_post_update: + return + + # See if we need add to the update handler to trigger the popup. + handlers = [] + if "scene_update_post" in dir(bpy.app.handlers): # 2.7x + handlers = bpy.app.handlers.scene_update_post + else: # 2.8+ + handlers = bpy.app.handlers.depsgraph_update_post + in_handles = updater_run_success_popup_handler in handlers + + if in_handles or ran_update_success_popup: + return + + if "scene_update_post" in dir(bpy.app.handlers): # 2.7x + bpy.app.handlers.scene_update_post.append( + updater_run_success_popup_handler) + else: # 2.8+ + bpy.app.handlers.depsgraph_update_post.append( + updater_run_success_popup_handler) + ran_update_success_popup = True # ----------------------------------------------------------------------------- # Example UI integrations # ----------------------------------------------------------------------------- - - def update_notice_box_ui(self, context): - """ Panel - Update Available for placement at end/beginning of panel + """Update notice draw, to add to the end or beginning of a panel. After a check for update has occurred, this function will draw a box saying an update is ready, and give a button for: update now, open website, - or ignore popup. Ideal to be placed at the end / beginning of a panel + or ignore popup. Ideal to be placed at the end / beginning of a panel. """ - if updater.invalidupdater is True: + if updater.invalid_updater: return saved_state = updater.json - if updater.auto_reload_post_update is False: - if "just_updated" in saved_state and saved_state["just_updated"] is True: + if not updater.auto_reload_post_update: + if "just_updated" in saved_state and saved_state["just_updated"]: layout = self.layout box = layout.box() col = box.column() - col.scale_y = 0.7 - col.label(text="Restart blender", icon="ERROR") + alert_row = col.row() + alert_row.alert = True + alert_row.operator( + "wm.quit_blender", + text="Restart blender", + icon="ERROR") col.label(text="to complete update") return - # if user pressed ignore, don't draw the box - if "ignore" in updater.json and updater.json["ignore"] is True: + # If user pressed ignore, don't draw the box. + if "ignore" in updater.json and updater.json["ignore"]: return - if updater.update_ready is not True: + if not updater.update_ready: return layout = self.layout box = layout.box() col = box.column(align=True) + col.alert = True col.label(text="Update ready!", icon="ERROR") + col.alert = False col.separator() row = col.row(align=True) split = row.split(align=True) colL = split.column(align=True) colL.scale_y = 1.5 - colL.operator(addon_updater_ignore.bl_idname, icon="X", text="Ignore") + colL.operator(AddonUpdaterIgnore.bl_idname, icon="X", text="Ignore") colR = split.column(align=True) colR.scale_y = 1.5 - if updater.manual_only is False: - colR.operator(addon_updater_update_now.bl_idname, + if not updater.manual_only: + colR.operator(AddonUpdaterUpdateNow.bl_idname, text="Update", icon="LOOP_FORWARDS") col.operator("wm.url_open", text="Open website").url = updater.website - #col.operator("wm.url_open",text="Direct download").url=updater.update_link - col.operator(addon_updater_install_manually.bl_idname, text="Install manually") + # ops = col.operator("wm.url_open",text="Direct download") + # ops.url=updater.update_link + col.operator(AddonUpdaterInstallManually.bl_idname, + text="Install manually") else: - #col.operator("wm.url_open",text="Direct download").url=updater.update_link - col.operator("wm.url_open", text="Get it now").url = \ - updater.website + # ops = col.operator("wm.url_open", text="Direct download") + # ops.url=updater.update_link + col.operator("wm.url_open", text="Get it now").url = updater.website def update_settings_ui(self, context, element=None): """Preferences - for drawing with full width inside user preferences - Create a function that can be run inside user preferences panel for prefs UI - Place inside UI draw using: addon_updater_ops.updaterSettingsUI(self, context) - or by: addon_updater_ops.updaterSettingsUI(context) + A function that can be run inside user preferences panel for prefs UI. + Place inside UI draw using: + addon_updater_ops.update_settings_ui(self, context) + or by: + addon_updater_ops.update_settings_ui(context) """ - # element is a UI element, such as layout, a row, column, or box + # Element is a UI element, such as layout, a row, column, or box. if element is None: element = self.layout box = element.box() - # in case of error importing updater - if updater.invalidupdater is True: + # In case of error importing updater. + if updater.invalid_updater: box.label(text="Error initializing updater code:") box.label(text=updater.error_msg) return @@ -889,157 +967,161 @@ def update_settings_ui(self, context, element=None): row = box.row() # special case to tell user to restart blender, if set that way - if updater.auto_reload_post_update is False: + if not updater.auto_reload_post_update: saved_state = updater.json - if "just_updated" in saved_state and saved_state["just_updated"] is True: - row.label(text="Restart blender to complete update", icon="ERROR") + if "just_updated" in saved_state and saved_state["just_updated"]: + row.alert = True + row.operator("wm.quit_blender", + text="Restart blender to complete update", + icon="ERROR") return - split = layout_split(row, factor=0.3) - subcol = split.column() - subcol.prop(settings, "auto_check_update") - subcol = split.column() - - if settings.auto_check_update is False: - subcol.enabled = False - subrow = subcol.row() - subrow.label(text="Interval between checks") - subrow = subcol.row(align=True) - checkcol = subrow.column(align=True) - checkcol.prop(settings, "updater_intrval_months") - checkcol = subrow.column(align=True) - checkcol.prop(settings, "updater_intrval_days") - checkcol = subrow.column(align=True) - checkcol.prop(settings, "updater_intrval_hours") - checkcol = subrow.column(align=True) - checkcol.prop(settings, "updater_intrval_minutes") - - # checking / managing updates + split = layout_split(row, factor=0.4) + sub_col = split.column() + sub_col.prop(settings, "auto_check_update") + sub_col = split.column() + + if not settings.auto_check_update: + sub_col.enabled = False + sub_row = sub_col.row() + sub_row.label(text="Interval between checks") + sub_row = sub_col.row(align=True) + check_col = sub_row.column(align=True) + check_col.prop(settings, "updater_interval_months") + check_col = sub_row.column(align=True) + check_col.prop(settings, "updater_interval_days") + check_col = sub_row.column(align=True) + + # Consider un-commenting for local dev (e.g. to set shorter intervals) + # check_col.prop(settings,"updater_interval_hours") + # check_col = sub_row.column(align=True) + # check_col.prop(settings,"updater_interval_minutes") + + # Checking / managing updates. row = box.row() col = row.column() if updater.error is not None: - subcol = col.row(align=True) - subcol.scale_y = 1 - split = subcol.split(align=True) + sub_col = col.row(align=True) + sub_col.scale_y = 1 + split = sub_col.split(align=True) split.scale_y = 2 if "ssl" in updater.error_msg.lower(): split.enabled = True - split.operator(addon_updater_install_manually.bl_idname, + split.operator(AddonUpdaterInstallManually.bl_idname, text=updater.error) else: split.enabled = False - split.operator(addon_updater_check_now.bl_idname, + split.operator(AddonUpdaterCheckNow.bl_idname, text=updater.error) - split = subcol.split(align=True) + split = sub_col.split(align=True) split.scale_y = 2 - split.operator(addon_updater_check_now.bl_idname, + split.operator(AddonUpdaterCheckNow.bl_idname, text="", icon="FILE_REFRESH") - elif updater.update_ready is None and updater.async_checking is False: + elif updater.update_ready is None and not updater.async_checking: col.scale_y = 2 - col.operator(addon_updater_check_now.bl_idname) - elif updater.update_ready is None: # async is running - subcol = col.row(align=True) - subcol.scale_y = 1 - split = subcol.split(align=True) + col.operator(AddonUpdaterCheckNow.bl_idname) + elif updater.update_ready is None: # async is running + sub_col = col.row(align=True) + sub_col.scale_y = 1 + split = sub_col.split(align=True) split.enabled = False split.scale_y = 2 - split.operator(addon_updater_check_now.bl_idname, - text="Checking...") - split = subcol.split(align=True) + split.operator(AddonUpdaterCheckNow.bl_idname, text="Checking...") + split = sub_col.split(align=True) split.scale_y = 2 - split.operator(addon_updater_end_background.bl_idname, - text="", icon="X") - - elif updater.include_branches is True and \ - len(updater.tags) == len(updater.include_branch_list) and \ - updater.manual_only is False: - # no releases found, but still show the appropriate branch - subcol = col.row(align=True) - subcol.scale_y = 1 - split = subcol.split(align=True) + split.operator(AddonUpdaterEndBackground.bl_idname, text="", icon="X") + + elif updater.include_branches and \ + len(updater.tags) == len(updater.include_branch_list) and not \ + updater.manual_only: + # No releases found, but still show the appropriate branch. + sub_col = col.row(align=True) + sub_col.scale_y = 1 + split = sub_col.split(align=True) split.scale_y = 2 - split.operator(addon_updater_update_now.bl_idname, - text="Update directly to "+str(updater.include_branch_list[0])) - split = subcol.split(align=True) + update_now_txt = "Update directly to {}".format( + updater.include_branch_list[0]) + split.operator(AddonUpdaterUpdateNow.bl_idname, text=update_now_txt) + split = sub_col.split(align=True) split.scale_y = 2 - split.operator(addon_updater_check_now.bl_idname, + split.operator(AddonUpdaterCheckNow.bl_idname, text="", icon="FILE_REFRESH") - elif (updater.update_ready is True) and (updater.manual_only is False): - subcol = col.row(align=True) - subcol.scale_y = 1 - split = subcol.split(align=True) + elif updater.update_ready and not updater.manual_only: + sub_col = col.row(align=True) + sub_col.scale_y = 1 + split = sub_col.split(align=True) split.scale_y = 2 - split.operator(addon_updater_update_now.bl_idname, - text="Update now to "+str(updater.update_version)) - split = subcol.split(align=True) + split.operator(AddonUpdaterUpdateNow.bl_idname, + text="Update now to " + str(updater.update_version)) + split = sub_col.split(align=True) split.scale_y = 2 - split.operator(addon_updater_check_now.bl_idname, + split.operator(AddonUpdaterCheckNow.bl_idname, text="", icon="FILE_REFRESH") - elif (updater.update_ready is True) and (updater.manual_only is True): + elif updater.update_ready and updater.manual_only: col.scale_y = 2 + dl_now_txt = "Download " + str(updater.update_version) col.operator("wm.url_open", - text="Download "+str(updater.update_version)).url = updater.website - else: # i.e. that updater.update_ready is False - subcol = col.row(align=True) - subcol.scale_y = 1 - split = subcol.split(align=True) + text=dl_now_txt).url = updater.website + else: # i.e. that updater.update_ready == False. + sub_col = col.row(align=True) + sub_col.scale_y = 1 + split = sub_col.split(align=True) split.enabled = False split.scale_y = 2 - split.operator(addon_updater_check_now.bl_idname, + split.operator(AddonUpdaterCheckNow.bl_idname, text="Addon is up to date") - split = subcol.split(align=True) + split = sub_col.split(align=True) split.scale_y = 2 - split.operator(addon_updater_check_now.bl_idname, + split.operator(AddonUpdaterCheckNow.bl_idname, text="", icon="FILE_REFRESH") - if updater.manual_only is False: + if not updater.manual_only: col = row.column(align=True) - #col.operator(addon_updater_update_target.bl_idname, - if (updater.include_branches is True) and (len(updater.include_branch_list) > 0): + if updater.include_branches and len(updater.include_branch_list) > 0: branch = updater.include_branch_list[0] - col.operator(addon_updater_update_target.bl_idname, - text="Install latest {} / old version".format(branch)) + col.operator(AddonUpdaterUpdateTarget.bl_idname, + text="Install {} / old version".format(branch)) else: - col.operator(addon_updater_update_target.bl_idname, - text="Reinstall / install old version") - lastdate = "none found" - backuppath = os.path.join(updater.stage_path, "backup") - if "backup_date" in updater.json and os.path.isdir(backuppath): + col.operator(AddonUpdaterUpdateTarget.bl_idname, + text="(Re)install addon version") + last_date = "none found" + backup_path = os.path.join(updater.stage_path, "backup") + if "backup_date" in updater.json and os.path.isdir(backup_path): if updater.json["backup_date"] == "": - lastdate = "Date not found" + last_date = "Date not found" else: - lastdate = updater.json["backup_date"] - backuptext = "Restore addon backup ({})".format(lastdate) - col.operator(addon_updater_restore_backup.bl_idname, text=backuptext) + last_date = updater.json["backup_date"] + backup_text = "Restore addon backup ({})".format(last_date) + col.operator(AddonUpdaterRestoreBackup.bl_idname, text=backup_text) row = box.row() row.scale_y = 0.7 - lastcheck = updater.json["last_check"] + last_check = updater.json["last_check"] if updater.error is not None and updater.error_msg is not None: row.label(text=updater.error_msg) - elif lastcheck != "" and lastcheck is not None: - lastcheck = lastcheck[0: lastcheck.index(".")] - row.label(text="Last update check: " + lastcheck) + elif last_check: + last_check = last_check[0: last_check.index(".")] + row.label(text="Last update check: " + last_check) else: row.label(text="Last update check: Never") def update_settings_ui_condensed(self, context, element=None): - """Preferences - Condensed drawing within preferences + """Preferences - Condensed drawing within preferences. - Alternate draw for user preferences or other places, does not draw a box + Alternate draw for user preferences or other places, does not draw a box. """ - # element is a UI element, such as layout, a row, column, or box + # Element is a UI element, such as layout, a row, column, or box. if element is None: element = self.layout row = element.row() - # in case of error importing updater - if updater.invalidupdater is True: + # In case of error importing updater. + if updater.invalid_updater: row.label(text="Error initializing updater code:") row.label(text=updater.error_msg) return @@ -1048,91 +1130,92 @@ def update_settings_ui_condensed(self, context, element=None): row.label(text="Error getting updater preferences", icon='ERROR') return - # special case to tell user to restart blender, if set that way - if updater.auto_reload_post_update is False: + # Special case to tell user to restart blender, if set that way. + if not updater.auto_reload_post_update: saved_state = updater.json - if "just_updated" in saved_state and saved_state["just_updated"] is True: - row.label(text="Restart blender to complete update", icon="ERROR") + if "just_updated" in saved_state and saved_state["just_updated"]: + row.alert = True # mark red + row.operator( + "wm.quit_blender", + text="Restart blender to complete update", + icon="ERROR") return col = row.column() if updater.error is not None: - subcol = col.row(align=True) - subcol.scale_y = 1 - split = subcol.split(align=True) + sub_col = col.row(align=True) + sub_col.scale_y = 1 + split = sub_col.split(align=True) split.scale_y = 2 if "ssl" in updater.error_msg.lower(): split.enabled = True - split.operator(addon_updater_install_manually.bl_idname, + split.operator(AddonUpdaterInstallManually.bl_idname, text=updater.error) else: split.enabled = False - split.operator(addon_updater_check_now.bl_idname, + split.operator(AddonUpdaterCheckNow.bl_idname, text=updater.error) - split = subcol.split(align=True) + split = sub_col.split(align=True) split.scale_y = 2 - split.operator(addon_updater_check_now.bl_idname, + split.operator(AddonUpdaterCheckNow.bl_idname, text="", icon="FILE_REFRESH") - elif updater.update_ready is None and updater.async_checking is False: + elif updater.update_ready is None and not updater.async_checking: col.scale_y = 2 - col.operator(addon_updater_check_now.bl_idname) - elif updater.update_ready is None: # async is running - subcol = col.row(align=True) - subcol.scale_y = 1 - split = subcol.split(align=True) + col.operator(AddonUpdaterCheckNow.bl_idname) + elif updater.update_ready is None: # Async is running. + sub_col = col.row(align=True) + sub_col.scale_y = 1 + split = sub_col.split(align=True) split.enabled = False split.scale_y = 2 - split.operator(addon_updater_check_now.bl_idname, - text="Checking...") - split = subcol.split(align=True) + split.operator(AddonUpdaterCheckNow.bl_idname, text="Checking...") + split = sub_col.split(align=True) split.scale_y = 2 - split.operator(addon_updater_end_background.bl_idname, - text="", icon="X") - - elif updater.include_branches is True \ - and len(updater.tags) == len(updater.include_branch_list) \ - and updater.manual_only is False: - - # no releases found, but still show the appropriate branch - subcol = col.row(align=True) - subcol.scale_y = 1 - split = subcol.split(align=True) + split.operator(AddonUpdaterEndBackground.bl_idname, text="", icon="X") + + elif updater.include_branches and \ + len(updater.tags) == len(updater.include_branch_list) and not \ + updater.manual_only: + # No releases found, but still show the appropriate branch. + sub_col = col.row(align=True) + sub_col.scale_y = 1 + split = sub_col.split(align=True) split.scale_y = 2 - split.operator(addon_updater_update_now.bl_idname, - text="Update directly to "+str(updater.include_branch_list[0])) - split = subcol.split(align=True) + now_txt = "Update directly to " + str(updater.include_branch_list[0]) + split.operator(AddonUpdaterUpdateNow.bl_idname, text=now_txt) + split = sub_col.split(align=True) split.scale_y = 2 - split.operator(addon_updater_check_now.bl_idname, + split.operator(AddonUpdaterCheckNow.bl_idname, text="", icon="FILE_REFRESH") - elif (updater.update_ready is True) and (updater.manual_only is False): - subcol = col.row(align=True) - subcol.scale_y = 1 - split = subcol.split(align=True) + elif updater.update_ready and not updater.manual_only: + sub_col = col.row(align=True) + sub_col.scale_y = 1 + split = sub_col.split(align=True) split.scale_y = 2 - split.operator(addon_updater_update_now.bl_idname, - text="Update now to "+str(updater.update_version)) - split = subcol.split(align=True) + split.operator(AddonUpdaterUpdateNow.bl_idname, + text="Update now to " + str(updater.update_version)) + split = sub_col.split(align=True) split.scale_y = 2 - split.operator(addon_updater_check_now.bl_idname, + split.operator(AddonUpdaterCheckNow.bl_idname, text="", icon="FILE_REFRESH") - elif (updater.update_ready is True) and (updater.manual_only is True): + elif updater.update_ready and updater.manual_only: col.scale_y = 2 - col.operator("wm.url_open", - text="Download "+str(updater.update_version)).url = updater.website - else: # i.e. that updater.update_ready is False - subcol = col.row(align=True) - subcol.scale_y = 1 - split = subcol.split(align=True) + dl_txt = "Download " + str(updater.update_version) + col.operator("wm.url_open", text=dl_txt).url = updater.website + else: # i.e. that updater.update_ready == False. + sub_col = col.row(align=True) + sub_col.scale_y = 1 + split = sub_col.split(align=True) split.enabled = False split.scale_y = 2 - split.operator(addon_updater_check_now.bl_idname, + split.operator(AddonUpdaterCheckNow.bl_idname, text="Addon is up to date") - split = subcol.split(align=True) + split = sub_col.split(align=True) split.scale_y = 2 - split.operator(addon_updater_check_now.bl_idname, + split.operator(AddonUpdaterCheckNow.bl_idname, text="", icon="FILE_REFRESH") row = element.row() @@ -1140,86 +1223,95 @@ def update_settings_ui_condensed(self, context, element=None): row = element.row() row.scale_y = 0.7 - lastcheck = updater.json["last_check"] + last_check = updater.json["last_check"] if updater.error is not None and updater.error_msg is not None: row.label(text=updater.error_msg) - elif lastcheck != "" and lastcheck is not None: - lastcheck = lastcheck[0: lastcheck.index(".")] - row.label(text="Last check: " + lastcheck) + elif last_check != "" and last_check is not None: + last_check = last_check[0: last_check.index(".")] + row.label(text="Last check: " + last_check) else: row.label(text="Last check: Never") def skip_tag_function(self, tag): - """A global function for tag skipping - - A way to filter which tags are displayed, - e.g. to limit downgrading too far - input is a tag text, e.g. "v1.2.3" - output is True for skipping this tag number, - False if the tag is allowed (default for all) - Note: here, "self" is the acting updater shared class instance + """A global function for tag skipping. + + A way to filter which tags are displayed, e.g. to limit downgrading too + long ago. + + Args: + self: The instance of the singleton addon update. + tag: the text content of a tag from the repo, e.g. "v1.2.3". + + Returns: + bool: True to skip this tag name (ie don't allow for downloading this + version), or False if the tag is allowed. """ - # in case of error importing updater - if self.invalidupdater is True: + # In case of error importing updater. + if self.invalid_updater: return False # ---- write any custom code here, return true to disallow version ---- # # # # Filter out e.g. if 'beta' is in name of release # if 'beta' in tag.lower(): - # return True + # return True # ---- write any custom code above, return true to disallow version --- # - if self.include_branches is True: + if self.include_branches: for branch in self.include_branch_list: - if tag["name"].lower() == branch: return False + if tag["name"].lower() == branch: + return False - # function converting string to tuple, ignoring e.g. leading 'v' + # Function converting string to tuple, ignoring e.g. leading 'v'. + # Be aware that this strips out other text that you might otherwise + # want to be kept and accounted for when checking tags (e.g. v1.1a vs 1.1b) tupled = self.version_tuple_from_text(tag["name"]) if not isinstance(tupled, tuple): return True - # select the min tag version - change tuple accordingly + # Select the min tag version - change tuple accordingly. if self.version_min_update is not None: if tupled < self.version_min_update: - return True # skip if current version below this + return True # Skip if current version below this. - # select the max tag version + # Select the max tag version. if self.version_max_update is not None: if tupled >= self.version_max_update: - return True # skip if current version at or above this + return True # Skip if current version at or above this. - # in all other cases, allow showing the tag for updating/reverting + # In all other cases, allow showing the tag for updating/reverting. + # To simply and always show all tags, this return False could be moved + # to the start of the function definition so all tags are allowed. return False def select_link_function(self, tag): - """Only customize if trying to leverage "attachments" in *GitHub* releases + """Only customize if trying to leverage "attachments" in *GitHub* releases. - A way to select from one or multiple attached donwloadable files from the - server, instead of downloading the default release/tag source code + A way to select from one or multiple attached downloadable files from the + server, instead of downloading the default release/tag source code. """ # -- Default, universal case (and is the only option for GitLab/Bitbucket) link = tag["zipball_url"] # -- Example: select the first (or only) asset instead source code -- - #if "assets" in tag and "browser_download_url" in tag["assets"][0]: - # link = tag["assets"][0]["browser_download_url"] + # if "assets" in tag and "browser_download_url" in tag["assets"][0]: + # link = tag["assets"][0]["browser_download_url"] # -- Example: select asset based on OS, where multiple builds exist -- # # not tested/no error checking, modify to fit your own needs! # # assume each release has three attached builds: - # # release_windows.zip, release_OSX.zip, release_linux.zip + # # release_windows.zip, release_OSX.zip, release_linux.zip # # This also would logically not be used with "branches" enabled # if platform.system() == "Darwin": # ie OSX - # link = [asset for asset in tag["assets"] if 'OSX' in asset][0] + # link = [asset for asset in tag["assets"] if 'OSX' in asset][0] # elif platform.system() == "Windows": - # link = [asset for asset in tag["assets"] if 'windows' in asset][0] + # link = [asset for asset in tag["assets"] if 'windows' in asset][0] # elif platform.system() == "Linux": - # link = [asset for asset in tag["assets"] if 'linux' in asset][0] + # link = [asset for asset in tag["assets"] if 'linux' in asset][0] return link @@ -1227,118 +1319,123 @@ def select_link_function(self, tag): # ----------------------------------------------------------------------------- # Register, should be run in the register module itself # ----------------------------------------------------------------------------- - classes = ( - addon_updater_install_popup, - addon_updater_check_now, - addon_updater_update_now, - addon_updater_update_target, - addon_updater_install_manually, - addon_updater_updated_successful, - addon_updater_restore_backup, - addon_updater_ignore, - addon_updater_end_background + AddonUpdaterInstallPopup, + AddonUpdaterCheckNow, + AddonUpdaterUpdateNow, + AddonUpdaterUpdateTarget, + AddonUpdaterInstallManually, + AddonUpdaterUpdatedSuccessful, + AddonUpdaterRestoreBackup, + AddonUpdaterIgnore, + AddonUpdaterEndBackground ) def register(bl_info): """Registering the operators in this module""" - # safer failure in case of issue loading module + # Safer failure in case of issue loading module. if updater.error: print("Exiting updater registration, " + updater.error) return - updater.clear_state() # clear internal vars, avoids reloading oddities + updater.clear_state() # Clear internal vars, avoids reloading oddities. - # confirm your updater "engine" (Github is default if not specified) + # Confirm your updater "engine" (Github is default if not specified). updater.engine = "Github" # updater.engine = "GitLab" # updater.engine = "Bitbucket" - # If using private repository, indicate the token here + # If using private repository, indicate the token here. # Must be set after assigning the engine. # **WARNING** Depending on the engine, this token can act like a password!! # Only provide a token if the project is *non-public*, see readme for - # other considerations and suggestions from a security standpoint - updater.private_token = None # "tokenstring" + # other considerations and suggestions from a security standpoint. + updater.private_token = None # "tokenstring" - # choose your own username, must match website (not needed for GitLab) - updater.user = "animate1978" + # Choose your own username, must match website (not needed for GitLab). + updater.user = "cgcookie" - # choose your own repository, must match git name - updater.repo = "MB-Lab" + # Choose your own repository, must match git name for GitHUb and Bitbucket, + # for GitLab use project ID (numbers only). + updater.repo = "blender-addon-updater" - #updater.addon = # define at top of module, MUST be done first + # updater.addon = # define at top of module, MUST be done first - # Website for manual addon download, optional but recommended to set - updater.website = "https://mblab.dev" + # Website for manual addon download, optional but recommended to set. + updater.website = "https://github.com/CGCookie/blender-addon-updater/" - # Addon subfolder path + # Addon subfolder path. # "sample/path/to/addon" # default is "" or None, meaning root updater.subfolder_path = "" - # used to check/compare versions + # Used to check/compare versions. updater.current_version = bl_info["version"] - # Optional, to hard-set update frequency, use this here - however, - # this demo has this set via UI properties. - # updater.set_check_interval( - # enable=False,months=0,days=0,hours=0,minutes=2) + # Optional, to hard-set update frequency, use this here - however, this + # demo has this set via UI properties. + # updater.set_check_interval(enabled=False, months=0, days=0, hours=0, minutes=2) # Optional, consider turning off for production or allow as an option # This will print out additional debugging info to the console - updater.verbose = True # make False for production default + updater.verbose = True # make False for production default # Optional, customize where the addon updater processing subfolder is, # essentially a staging folder used by the updater on its own # Needs to be within the same folder as the addon itself # Need to supply a full, absolute path to folder # updater.updater_path = # set path of updater folder, by default: - # /addons/{__package__}/{__package__}_updater - - # auto create a backup of the addon when installing other versions - updater.backup_current = True # True by default + # /addons/{__package__}/{__package__}_updater - # Sample ignore patterns for when creating backup of current during update - # updater.backup_ignore_patterns = ["__pycache__"] - # Alternate example patterns - updater.backup_ignore_patterns = [".git", "__pycache__", "*.bat", ".gitignore", "*.exe"] + # Auto create a backup of the addon when installing other versions. + updater.backup_current = True # True by default - # Patterns for files to actively overwrite if found in new update - # file and are also found in the currently installed addon. Note that + # Sample ignore patterns for when creating backup of current during update. + updater.backup_ignore_patterns = ["__pycache__"] + # Alternate example patterns: + # updater.backup_ignore_patterns = [".git", "__pycache__", "*.bat", ".gitignore", "*.exe"] - # by default (ie if set to []), updates are installed in the same way as blender: + # Patterns for files to actively overwrite if found in new update file and + # are also found in the currently installed addon. Note that by default + # (ie if set to []), updates are installed in the same way as blender: # .py files are replaced, but other file types (e.g. json, txt, blend) # will NOT be overwritten if already present in current install. Thus - # if you want to automatically update resources/non py files, add them - # as a part of the pattern list below so they will always be overwritten by an + # if you want to automatically update resources/non py files, add them as a + # part of the pattern list below so they will always be overwritten by an # update. If a pattern file is not found in new update, no action is taken - # This does NOT detele anything, only defines what is allowed to be overwritten - updater.overwrite_patterns = ["*.py", "*.pyc", "*.png", "*.jpg", "*.blend", "*.json", "README.md", "LICENSE.txt"] + # NOTE: This does NOT delete anything proactively, rather only defines what + # is allowed to be overwritten during an update execution. + updater.overwrite_patterns = ["*.png", "*.jpg", "README.md", "LICENSE.txt"] # updater.overwrite_patterns = [] # other examples: - # ["*"] means ALL files/folders will be overwritten by update, was the behavior pre updater v1.0.4 - # [] or ["*.py","*.pyc"] matches default blender behavior, ie same effect if user installs update manually without deleting the existing addon first - # e.g. if existing install and update both have a resource.blend file, the existing installed one will remain - # ["some.py"] means if some.py is found in addon update, it will overwrite any existing some.py in current addon install, if any - # ["*.json"] means all json files found in addon update will overwrite those of same name in current install - # ["*.png","README.md","LICENSE.txt"] means the readme, license, and all pngs will be overwritten by update - - # Patterns for files to actively remove prior to running update + # ["*"] means ALL files/folders will be overwritten by update, was the + # behavior pre updater v1.0.4. + # [] or ["*.py","*.pyc"] matches default blender behavior, ie same effect + # if user installs update manually without deleting the existing addon + # first e.g. if existing install and update both have a resource.blend + # file, the existing installed one will remain. + # ["some.py"] means if some.py is found in addon update, it will overwrite + # any existing some.py in current addon install, if any. + # ["*.json"] means all json files found in addon update will overwrite + # those of same name in current install. + # ["*.png","README.md","LICENSE.txt"] means the readme, license, and all + # pngs will be overwritten by update. + + # Patterns for files to actively remove prior to running update. # Useful if wanting to remove old code due to changes in filenames # that otherwise would accumulate. Note: this runs after taking # a backup (if enabled) but before placing in new update. If the same # file name removed exists in the update, then it acts as if pattern # is placed in the overwrite_patterns property. Note this is effectively - # ignored if clean=True in the run_update method + # ignored if clean=True in the run_update method. updater.remove_pre_update_patterns = ["*.py", "*.pyc"] # Note setting ["*"] here is equivalent to always running updates with # clean = True in the run_update method, ie the equivalent of a fresh, # new install. This would also delete any resources or user-made/modified - # files setting ["__pycache__"] ensures the pycache folder is always removed - # The configuration of ["*.py","*.pyc"] is a safe option as this + # files setting ["__pycache__"] ensures the pycache folder always removed. + # The configuration of ["*.py", "*.pyc"] is a safe option as this # will ensure no old python files/caches remain in event different addon - # versions have different filenames or structures + # versions have different filenames or structures. # Allow branches like 'master' as an option to update to, regardless # of release or version. @@ -1348,21 +1445,22 @@ def register(bl_info): # the "install {branch}/older version" operator. updater.include_branches = True - # (GitHub only) This options allows the user to use releases over tags for data, - # which enables pulling down release logs/notes, as well as specify installs from - # release-attached zips (instead of just the auto-packaged code generated with - # a release/tag). Setting has no impact on BitBucket or GitLab repos + # (GitHub only) This options allows using "releases" instead of "tags", + # which enables pulling down release logs/notes, as well as installs update + # from release-attached zips (instead of the auto-packaged code generated + # with a release/tag). Setting has no impact on BitBucket or GitLab repos. updater.use_releases = False - # note: Releases always have a tag, but a tag may not always be a release - # Therefore, setting True above will filter out any non-annoted tags - # note 2: Using this option will also display the release name instead of - # just the tag name, bear this in mind given the skip_tag_function filtering above - - # if using "include_branches", - # updater.include_branch_list defaults to ['master'] branch if set to none - # example targeting another multiple branches allowed to pull from - # updater.include_branch_list = ['master', 'dev'] # example with two branches - updater.include_branch_list = None # None is the equivalent to setting ['master'] + # Note: Releases always have a tag, but a tag may not always be a release. + # Therefore, setting True above will filter out any non-annotated tags. + # Note 2: Using this option will also display (and filter by) the release + # name instead of the tag name, bear this in mind given the + # skip_tag_function filtering above. + + # Populate if using "include_branches" option above. + # Note: updater.include_branch_list defaults to ['master'] branch if set to + # none. Example targeting another multiple branches allowed to pull from: + # updater.include_branch_list = ['master', 'dev'] + updater.include_branch_list = None # None is the equivalent = ['master'] # Only allow manual install, thus prompting the user to open # the addon's web page to download, specifically: updater.website @@ -1371,14 +1469,14 @@ def register(bl_info): updater.manual_only = False # Used for development only, "pretend" to install an update to test - # reloading conditions - updater.fake_install = False # Set to true to test callback/reloading + # reloading conditions. + updater.fake_install = False # Set to true to test callback/reloading. # Show popups, ie if auto-check for update is enabled or a previous # check for update in user preferences found a new version, show a popup # (at most once per blender session, and it provides an option to ignore - # for future sessions); default behavior is set to True - updater.showpopups = True + # for future sessions); default behavior is set to True. + updater.show_popups = True # note: if set to false, there will still be an "update ready" box drawn # using the `update_notice_box_ui` panel function. @@ -1387,49 +1485,54 @@ def register(bl_info): # Set the min and max versions allowed to install. # Optional, default None # min install (>=) will install this and higher - updater.version_min_update = (1, 7, 3) - # updater.version_min_update = None # if not wanting to define a min + updater.version_min_update = (0, 0, 0) + # updater.version_min_update = None # None or default for no minimum. - # max install (<) will install strictly anything lower - # updater.version_max_update = (9, 9, 9) - updater.version_max_update = None # set to None if not wanting to set max + # Max install (<) will install strictly anything lower than this version + # number, useful to limit the max version a given user can install (e.g. + # if support for a future version of blender is going away, and you don't + # want users to be prompted to install a non-functioning addon) + # updater.version_max_update = (9,9,9) + updater.version_max_update = None # None or default for no max. # Function defined above, customize as appropriate per repository - updater.skip_tag = skip_tag_function # min and max used in this function + updater.skip_tag = skip_tag_function # min and max used in this function - # Function defined above, customize as appropriate per repository; not required + # Function defined above, optionally customize as needed per repository. updater.select_link = select_link_function - # The register line items for all operators/panels + # Recommended false to encourage blender restarts on update completion + # Setting this option to True is NOT as stable as false (could cause + # blender crashes). + updater.auto_reload_post_update = False + + # The register line items for all operators/panels. # If using bpy.utils.register_module(__name__) to register elsewhere - # in the addon, delete these lines (also from unregister) + # in the addon, delete these lines (also from unregister). for cls in classes: - # apply annotations to remove Blender 2.8 warnings, no effect on 2.7 - print("cls BA: " + str(make_annotations(cls))) + # Apply annotations to remove Blender 2.8+ warnings, no effect on 2.7 make_annotations(cls) - print("cls: " + str(make_annotations(cls))) - # comment out this line if using bpy.utils.register_module(__name__) + # Comment out this line if using bpy.utils.register_module(__name__) bpy.utils.register_class(cls) - # special situation: we just updated the addon, show a popup - # to tell the user it worked - # should be enclosed in try/catch in case other issues arise - showReloadPopup() + # Special situation: we just updated the addon, show a popup to tell the + # user it worked. Could enclosed in try/catch in case other issues arise. + show_reload_popup() def unregister(): for cls in reversed(classes): - # comment out this line if using bpy.utils.unregister_module(__name__) - bpy.utils.unregister_class(cls) #BUG - https://github.com/animate1978/MB-Lab/issues/101 + # Comment out this line if using bpy.utils.unregister_module(__name__). + bpy.utils.unregister_class(cls) - # clear global vars since they may persist if not restarting blender - updater.clear_state() # clear internal vars, avoids reloading oddities + # Clear global vars since they may persist if not restarting blender. + updater.clear_state() # Clear internal vars, avoids reloading oddities. - global ran_autocheck_install_popup - ran_autocheck_install_popup = False + global ran_auto_check_install_popup + ran_auto_check_install_popup = False - global ran_update_sucess_popup - ran_update_sucess_popup = False + global ran_update_success_popup + ran_update_success_popup = False global ran_background_check ran_background_check = False diff --git a/algorithms.py b/algorithms.py index 0b75003f..e73855ef 100644 --- a/algorithms.py +++ b/algorithms.py @@ -331,6 +331,30 @@ def correct_morph(base_form, current_form, morph_deltas, bboxes): # Functions # ------------------------------------------------------------------------ +def check_mesh(obj): + config_data = file_ops.get_configuration() + model_config = config_data.get(obj.get("manuellab_id")) + if not model_config: + logger.debug("check_obj %s model %s is not found", obj.name, obj.get("manuellab_id")) + return False + + templates = {} + for template in config_data["templates_list"]: + tpl_conf = config_data.get(template) + if tpl_conf: + tpl_name = tpl_conf.get("template_model") + if tpl_name: + templates[tpl_name] = tpl_conf + + tpl_config = templates.get(model_config.get("template_model")) + if not tpl_config: + logger.debug("check_obj %s template %s is not found", obj.name, model_config.get("template_model")) + return False + result = len(obj.data.vertices) == tpl_config.get("vertices") and len(obj.data.polygons) == tpl_config.get("faces") + if not result: + logger.debug("check_obj %s vertex/face count mismatch: %d %d %d %d", obj.name, len(obj.data.vertices), tpl_config.get("vertices"), len(obj.data.polygons), tpl_config.get("faces")) + return result + def looking_for_humanoid_obj(): """ Looking for a mesh that is OK for the lab @@ -345,8 +369,8 @@ def looking_for_humanoid_obj(): name = "" for obj in bpy.data.objects: if obj.type == "MESH": - if "manuellab_vers" in get_object_keys(obj): - if utils.check_version(obj["manuellab_vers"]): + if "manuellab_vers" in obj and "manuellab_id" in obj: + if utils.check_version(obj["manuellab_vers"]) and check_mesh(obj): human_obj = obj name = human_obj.name break @@ -1090,19 +1114,19 @@ def swap_material(old_mat_name, new_mat_name, char_name): obj = bpy.data.objects[char_name] if obj == None: return None - + #Try and get materials if either does not exist return None - + try: mat_old = bpy.data.materials[old_mat_name] mat_new = bpy.data.materials[new_mat_name] - + except: logger.debug("Material not found") return None - - + + #Assign new material to old material slot materialslen = len(obj.data.materials) for i in range(0,materialslen): @@ -1160,7 +1184,7 @@ def create_enum_property_items(values=[], key_length=3, tip_length=4): values[i], str(values[i])[0:tip_length])) return return_list - + def split_name(name, splitting_char=' -_²&=¨^$£%µ,?;!§+*/:[]\"\'{}', indexes=[]): if len(splitting_char) < 1: return name @@ -1187,4 +1211,3 @@ def split(name, splitting_char=' -_²&=¨^$£%µ,?;!§+*/:[]\"\'{}'): if len(t) > 0: return_list.append(t) return split(return_list, splitting_char[1:]) - \ No newline at end of file diff --git a/creation_tools_ops.py b/creation_tools_ops.py index 10dbe57f..cf3d8bbe 100644 --- a/creation_tools_ops.py +++ b/creation_tools_ops.py @@ -178,8 +178,8 @@ def add_content(key, key_in, content): "texture_tongue_albedo": "", "texture_teeth_albedo": "", "texture_nails_albedo": "", "texture_eyelash_albedo": "", "texture_frecklemask": "", "texture_blush": "", - "texture_sebum": "", "texture_lipmap": "", "texture_subdermal": "", - "texture_thickness": "", "texture_iris_color": "", + "texture_sebum": "", "texture_lipmap": "", + "texture_roughness": "", "texture_iris_color": "", "texture_iris_bump": "", "texture_sclera_color": "", "texture_translucent_mask": "", "texture_sclera_mask": "", "morphs_extra_file": "", "shared_morphs_file": "", diff --git a/data/characters_config.json b/data/characters_config.json index c5b1a294..1445ca29 100644 --- a/data/characters_config.json +++ b/data/characters_config.json @@ -77,7 +77,7 @@ "texture_blush": "human_female_blush.png", "texture_sebum": "human_female_sebum.png", "texture_lipmap": "human_female_lipmap.png", - "texture_thickness": "human_female_thickness.png", + "texture_roughness": "human_female_roughness.png", "texture_iris_color" : "iris_color.png", "texture_iris_bump" : "iris_bump.png", "texture_sclera_color" : "sclera_color.png", @@ -113,7 +113,7 @@ "texture_blush": "human_female_blush.png", "texture_sebum": "human_female_sebum.png", "texture_lipmap": "human_female_lipmap.png", - "texture_thickness": "human_female_thickness.png", + "texture_roughness": "human_female_roughness.png", "texture_iris_color" : "iris_color.png", "texture_iris_bump" : "iris_bump.png", "texture_sclera_color" : "sclera_color.png", @@ -149,7 +149,7 @@ "texture_blush": "human_female_blush.png", "texture_sebum": "human_female_sebum.png", "texture_lipmap": "human_female_lipmap.png", - "texture_thickness": "human_female_thickness.png", + "texture_roughness": "human_female_roughness.png", "texture_iris_color" : "iris_color.png", "texture_iris_bump" : "iris_bump.png", "texture_sclera_color" : "sclera_color.png", @@ -185,7 +185,7 @@ "texture_blush": "human_female_blush.png", "texture_sebum": "human_female_sebum.png", "texture_lipmap": "human_female_lipmap.png", - "texture_thickness": "human_female_thickness.png", + "texture_roughness": "human_female_roughness.png", "texture_iris_color" : "iris_color.png", "texture_iris_bump" : "iris_bump.png", "texture_sclera_color" : "sclera_color.png", @@ -221,7 +221,7 @@ "texture_blush": "human_male_blush.png", "texture_sebum": "human_male_sebum.png", "texture_lipmap": "human_male_lipmap.png", - "texture_thickness": "human_male_thickness.png", + "texture_roughness": "human_male_roughness.png", "texture_iris_color" : "iris_color.png", "texture_iris_bump" : "iris_bump.png", "texture_sclera_color" : "sclera_color.png", @@ -257,7 +257,7 @@ "texture_blush": "human_male_blush.png", "texture_sebum": "human_male_sebum.png", "texture_lipmap": "human_male_lipmap.png", - "texture_thickness": "human_male_thickness.png", + "texture_roughness": "human_male_roughness.png", "texture_iris_color" : "iris_color.png", "texture_iris_bump" : "iris_bump.png", "texture_sclera_color" : "sclera_color.png", @@ -293,7 +293,7 @@ "texture_blush": "human_male_blush.png", "texture_sebum": "human_male_sebum.png", "texture_lipmap": "human_male_lipmap.png", - "texture_thickness": "human_male_thickness.png", + "texture_roughness": "human_male_roughness.png", "texture_iris_color" : "iris_color.png", "texture_iris_bump" : "iris_bump.png", "texture_sclera_color" : "sclera_color.png", @@ -329,7 +329,7 @@ "texture_blush": "human_male_blush.png", "texture_sebum": "human_male_sebum.png", "texture_lipmap": "human_male_lipmap.png", - "texture_thickness": "human_male_thickness.png", + "texture_roughness": "human_male_roughness.png", "texture_iris_color" : "iris_color.png", "texture_iris_bump" : "iris_bump.png", "texture_sclera_color" : "sclera_color.png", @@ -365,7 +365,7 @@ "texture_blush": "", "texture_sebum": "", "texture_lipmap": "", - "texture_thickness": "", + "texture_roughness": "", "texture_iris_color" : "", "texture_iris_bump" : "", "texture_sclera_color" : "", @@ -403,7 +403,7 @@ "texture_blush": "", "texture_sebum": "", "texture_lipmap": "", - "texture_thickness": "", + "texture_roughness": "", "texture_iris_color" : "", "texture_iris_bump" : "", "texture_sclera_color" : "", @@ -441,7 +441,7 @@ "texture_blush": "human_female_blush.png", "texture_sebum": "human_female_sebum.png", "texture_lipmap": "human_female_lipmap.png", - "texture_thickness": "human_female_thickness.png", + "texture_roughness": "human_female_roughness.png", "texture_iris_color" : "iris_color.png", "texture_iris_bump" : "iris_bump.png", "texture_sclera_color" : "sclera_color.png", @@ -477,7 +477,7 @@ "texture_blush": "", "texture_sebum": "", "texture_lipmap": "", - "texture_thickness": "", + "texture_roughness": "", "texture_iris_color" : "", "texture_iris_bump" : "", "texture_sclera_color" : "", @@ -515,7 +515,7 @@ "texture_blush": "", "texture_sebum": "", "texture_lipmap": "", - "texture_thickness": "", + "texture_roughness": "", "texture_iris_color" : "", "texture_iris_bump" : "", "texture_sclera_color" : "", @@ -553,7 +553,7 @@ "texture_blush": "human_male_blush.png", "texture_sebum": "human_male_sebum.png", "texture_lipmap": "human_male_lipmap.png", - "texture_thickness": "human_male_thickness.png", + "texture_roughness": "human_male_roughness.png", "texture_iris_color" : "iris_color.png", "texture_iris_bump" : "iris_bump.png", "texture_sclera_color" : "sclera_color.png", @@ -589,7 +589,7 @@ "texture_blush": "human_female_blush.png", "texture_sebum": "human_female_sebum.png", "texture_lipmap": "human_female_lipmap.png", - "texture_thickness": "human_female_thickness.png", + "texture_roughness": "human_female_roughness.png", "texture_iris_color" : "iris_color.png", "texture_iris_bump" : "iris_bump.png", "texture_sclera_color" : "sclera_color.png", @@ -625,7 +625,7 @@ "texture_blush": "human_male_blush.png", "texture_sebum": "human_male_sebum.png", "texture_lipmap": "human_male_lipmap.png", - "texture_thickness": "human_male_thickness.png", + "texture_roughness": "human_male_roughness.png", "texture_iris_color" : "iris_color.png", "texture_iris_bump" : "iris_bump.png", "texture_sclera_color" : "sclera_color.png", @@ -661,7 +661,7 @@ "texture_blush": "human_male_blush.png", "texture_sebum": "human_male_sebum.png", "texture_lipmap": "human_male_lipmap.png", - "texture_thickness": "human_male_thickness.png", + "texture_roughness": "human_male_roughness.png", "texture_iris_color" : "iris_color.png", "texture_iris_bump" : "iris_bump.png", "texture_sclera_color" : "sclera_color.png", diff --git a/data/humanoid_library.blend b/data/humanoid_library.blend index b141dbb9..8c697fa3 100644 Binary files a/data/humanoid_library.blend and b/data/humanoid_library.blend differ diff --git a/data/textures/human_female_roughness.png b/data/textures/human_female_roughness.png new file mode 100644 index 00000000..337a5da7 Binary files /dev/null and b/data/textures/human_female_roughness.png differ diff --git a/data/textures/human_male_roughness.png b/data/textures/human_male_roughness.png new file mode 100644 index 00000000..35dbd001 Binary files /dev/null and b/data/textures/human_male_roughness.png differ diff --git a/expressionscreator.py b/expressionscreator.py index 234e6f74..9f33ed0b 100644 --- a/expressionscreator.py +++ b/expressionscreator.py @@ -35,9 +35,9 @@ logger = logging.getLogger(__name__) class ExpressionsCreator(): - + def __init__(self): - + self.standard_expressions_list = ["abdomExpansion_min", "abdomExpansion_max", "browOutVertL_min", "browOutVertL_max", "browOutVertR_min", "browOutVertR_max", "browsMidVert_min", "browsMidVert_max", @@ -181,7 +181,7 @@ def __init__(self): self.expression_ID_list = [("HU", "Humans", "Standard in MB-Lab"), ("AN", "Anime", "Standard in MB-Lab"), ("OT", "OTHER", "For another model")] - + self.forbidden_char_list = '-_²&=¨^$£%µ,?;!§+*/' self.expression_name = ["", "", 0] @@ -200,14 +200,14 @@ def __init__(self): # and create them only when model is changed. # Used only BEFORE finalization of the model, # in the Combined Expression Editor. - + self.humanoid = None # Instance of class Humanoid - + #--------------Play with variables def set_lab_version(self, lab_version): self.lab_vers = list(lab_version) - + def get_standard_expressions_list(self): return self.standard_expressions_list @@ -255,7 +255,7 @@ def get_expression_ID(self): def get_expression_ID_list(self): return self.expression_ID_list - + def get_next_number(self): self.expression_name[2] += 1 return str(self.expression_name[2]).zfill(3) @@ -272,6 +272,9 @@ def set_expressions_modifiers(self, huma): if len(self.expressions_modifiers) > 0: return self.expressions_modifiers category = self.humanoid.get_category("Expressions") + if not category: + logger.error("Expressions is aren't found in humanoid") + return self.expressions_modifiers = category.get_modifier_tiny_name(self.body_parts_expr_list) def get_expressions_modifiers(self): @@ -302,7 +305,7 @@ def get_expressions_sub_categories(self): sorted_list = sorted(list(self.expressions_modifiers.keys())) self.expressions_sub_categories = algorithms.create_enum_property_items(sorted_list, tip_length=100) return self.expressions_sub_categories - + def is_comb_expression_exists(self, root_model, name): if len(root_model) < 1 or len(name) < 1: return False @@ -315,7 +318,7 @@ def is_comb_expression_exists(self, root_model, name): except: return False return False - + #--------------EnumProperty for expressions in UI #--------------AFTER finalization of the character @@ -332,7 +335,7 @@ def get_expressions_items(self): def get_expressions_item(self, key): return algorithms.get_enum_property_item(key, self.editor_expressions_items) - + #--------------Loading data def get_all_expression_files(self, data_path, data_type_path, body_type): #Get all files in morphs directory, with standard ones. @@ -349,7 +352,7 @@ def get_all_expression_files(self, data_path, data_type_path, body_type): found_files += [os.path.join(dir, item)] return found_files - #--------------Saving all changed base expression in a filedef + #--------------Saving all changed base expression in a filedef def save_face_expression(self, filepath): # Save all expression morphs as a new face expression # in its dedicated file. @@ -365,36 +368,36 @@ def save_face_expression(self, filepath): with open(filepath, "w") as j_file: json.dump(char_data, j_file, indent=2) j_file.close() - + # data_source can be a filepath but also the data themselves. def load_face_expression(self, data_source, reset_unassigned=True): - + if self.humanoid == None: return - + obj = self.humanoid.get_object() log_msg_type = "Expression data" - + if isinstance(data_source, str): log_msg_type = file_ops.simple_path(data_source) charac_data = file_ops.load_json_data(data_source, "Expression data") else: charac_data = data_source - + logger.info("Loading expression from {0}".format(log_msg_type)) - + if "manuellab_vers" in charac_data: if not utils.check_version(charac_data["manuellab_vers"]): logger.warning("{0} created with vers. {1}. Current vers is {2}".format(log_msg_type, charac_data["manuellab_vers"], self.lab_vers)) else: logger.info("No lab version specified in {0}".format(log_msg_type)) - + if "structural" in charac_data: char_data = charac_data["structural"] else: logger.warning("No structural data in {0}".format(log_msg_type)) char_data = {} - + # data are loaded, now update the character. if char_data is not None: for name in self.humanoid.character_data.keys(): diff --git a/file_ops.py b/file_ops.py index b2376828..f840bb93 100644 --- a/file_ops.py +++ b/file_ops.py @@ -68,12 +68,13 @@ def get_root_directories(): root_directories = [] for dir in rd: if dir not in fd: + logger.debug("found root dir %s", dir) root_directories.append( (dir, "MB_Lab" if dir == "data" else dir, dir)) return root_directories - + def set_data_path(path): global data_directory global configuration_done @@ -100,15 +101,20 @@ def get_configuration(): # what's why the variable below exists. if configuration_done != None: return configuration_done - data_path = get_data_path() - # Here something to change : - # Allow to load every file that ends with _config.json - if data_path: - configuration_done = {} + + addon_directory = os.path.dirname(os.path.realpath(__file__)) + configuration_done = {} + for tup in get_root_directories(): + data_path = os.path.join(addon_directory, tup[0]) + if not os.path.isdir(data_path): + logger.error("get_configuration: %s is not a directury!", data_path) + continue tmp = {} + logger.debug("Loading config dir %s", data_path) for list_dir in os.listdir(data_path): configuration_path = os.path.join(data_path, list_dir) if os.path.isfile(configuration_path) and configuration_path.endswith("_config.json"): + logger.debug("Loading config json %s", configuration_path) tmp = load_json_data(configuration_path, "Characters definition") for prop in tmp: if prop == 'data_directory': @@ -119,7 +125,10 @@ def get_configuration(): configuration_done[prop] += tmp[prop] else: configuration_done[prop] = tmp[prop] + + if configuration_done: return configuration_done + logger.critical("Configuration database not found. Please check your Blender addons directory.") return None @@ -275,7 +284,7 @@ def append_object_from_library(lib_filepath, obj_names, suffix=None): except OSError: logger.critical("lib %s not found", lib_filepath) return - + for obj in data_to.objects: link_to_collection(obj) obj_parent = utils.get_object_parent(obj) diff --git a/humanoid.py b/humanoid.py index 141fbd4c..3eb130d1 100644 --- a/humanoid.py +++ b/humanoid.py @@ -523,7 +523,7 @@ def save_body_dermal_texture(self, filepath): self.mat_engine.save_texture(filepath, "body_derm") def save_all_textures(self, filepath): - targets = ["body_derm", "body_displ", "teeth_albedo", "eyes_albedo", "tongue_albedo", "freckle_mask", "blush", "sebum", "lipmap", "iris_color", "iris_bump", "sclera_color", "translucent_mask", "sclera_mask", "body_bump"] + targets = ["body_derm", "body_displ", "teeth_albedo", "eyes_albedo", "tongue_albedo", "freckle_mask", "blush", "sebum", "roughness", "lipmap", "iris_color", "iris_bump", "sclera_color", "translucent_mask", "sclera_mask", "body_bump"] for target in targets: dir_path = os.path.dirname(filepath) filename = os.path.basename(filepath) diff --git a/jointscreator.py b/jointscreator.py index d207e5dc..102a95f2 100644 --- a/jointscreator.py +++ b/jointscreator.py @@ -224,6 +224,18 @@ def save_joints_base_file(): # -------------------------------------------------- current_joint_central_point = None +def create_current_joint_central_point(): + global current_joint_central_point + mesh = bpy.data.meshes.new('Basic_Sphere') + current_joint_central_point = bpy.data.objects.new("Joint center", mesh) + bm = bmesh.new() + bmesh.ops.create_uvsphere(bm, u_segments=4, v_segments=4, diameter=0.004) + bm.to_mesh(mesh) + bm.free() + file_ops.link_to_collection(current_joint_central_point) + current_joint_central_point.hide_select = True + + def show_central_point(hist): global current_joints_base_file global current_joint_central_point @@ -240,14 +252,7 @@ def show_central_point(hist): # Now we have all vertices, we compute the average center central_point = algorithms.average_center(vertices) if current_joint_central_point == None: - mesh = bpy.data.meshes.new('Basic_Sphere') - current_joint_central_point = bpy.data.objects.new("Joint center", mesh) - bm = bmesh.new() - bmesh.ops.create_uvsphere(bm, u_segments=4, v_segments=4, diameter=0.004) - bm.to_mesh(mesh) - bm.free() - file_ops.link_to_collection(current_joint_central_point) - current_joint_central_point.hide_select = True + create_current_joint_central_point() current_joint_central_point.location = (central_point.x, central_point.y, central_point.z) # -------------------------------------------------- @@ -346,6 +351,8 @@ def show_offset_point(hist): if current_offset_point == None: create_offset_point() co = file[hist.name] + if current_joint_central_point == None: # It happens. + create_current_joint_central_point() vect = current_joint_central_point.location current_offset_point.location = ( vect.x + co[0], @@ -407,6 +414,8 @@ def set_offset_point(): for key, item in current_joints_offset_file.items(): file = item # Now we calculate de relative location of the offset + if current_joint_central_point == None: # Security. + create_current_joint_central_point() center_point = current_joint_central_point.location offset_point = current_offset_point.location file[hist.name] = [ diff --git a/materialengine.py b/materialengine.py index 9c96b72b..cf2e71b8 100644 --- a/materialengine.py +++ b/materialengine.py @@ -63,6 +63,7 @@ def __init__(self, obj_name, character_config): "freckle_mask": character_config["texture_frecklemask"], "blush": character_config["texture_blush"], "sebum": character_config["texture_sebum"], + "roughness": character_config["texture_roughness"], "lipmap": character_config["texture_lipmap"], "iris_color": character_config["texture_iris_color"], "iris_bump": character_config["texture_iris_bump"], @@ -130,6 +131,9 @@ def texture_blush_exist(self): def texture_sebum_exist(self): return os.path.isfile(self.image_file_paths["sebum"]) @property + def texture_roughness_exist(self): + return os.path.isfile(self.image_file_paths["roughness"]) + @property def texture_lipmap_exist(self): return os.path.isfile(self.image_file_paths["lipmap"]) @property @@ -155,24 +159,22 @@ def calculate_disp_pixels(blender_image, age_factor, tone_factor, mass_factor): logger.info('start: calculate_disp_pixels %s', blender_image.name) tone_f = tone_factor if tone_factor > 0.0 else 0.0 - ajustments = np.array([0.0, 0.5, 0.5, 0.5], dtype='float32') - factors = np.fmax(np.array([1, age_factor, tone_f, (1.0 - tone_f) * mass_factor], dtype='float32'), 0.0) - np_image = np.array(blender_image.pixels, dtype='float32').reshape(-1, 4) + adjustments = np.array([0.0, 0.5, 0.5, 0.5], dtype=np.float32) + factors = np.fmax(np.array([1, age_factor, tone_f, (1.0 - tone_f) * mass_factor], dtype=np.float32), 0.0) + np_image = np.empty(len(blender_image.pixels), dtype=np.float32) + blender_image.pixels.foreach_get(np_image) + np_image = np_image.reshape(-1, 4) # add_result = r + age_f * (g - 0.5) + tone_f * (b - 0.5) + mass_f * (a - 0.5) - add_result = np.sum((np_image - ajustments) * factors, axis=1) - result_image = np.insert(np.repeat(np.fmin(add_result, 1.0), 3).reshape(-1, 3), 3, 1.0, axis=1) + np_image -= adjustments + np_image *= factors + add_result = np.sum(np_image, axis=1) + np.fmin(add_result, 1.0, add_result) + np_image[:,0] = add_result + np_image[:,1] = add_result + np_image[:,2] = add_result + np_image[:,3] = 1 logger.info('finish: calculate_disp_pixels %s', blender_image.name) - return result_image.flatten() - - @staticmethod - def multiply_images(image1, image2, result_name, blending_factor=0.5): - logger.info('multiply_images %s', result_name) - if images_scale(image1, image2): - np_img1, np_img2 = np.array(image1.pixels, dtype='float32'), np.array(image2.pixels, dtype='float32') - - result_img = new_image(result_name, image2.size) - result_img.pixels = np_img1 * np_img2 * blending_factor + (np_img1 * (1.0 - blending_factor)) - logger.info('finish: multiply_images %s', result_name) + return np_image.reshape(-1) # Link Textures to Nodes @@ -242,6 +244,8 @@ def update_shaders(self, material_parameters=[], update_textures_nodes=True): self.assign_image_to_node(material.name, node.name, self.image_file_names["blush"]) if "_skn_sebum" in node.name: self.assign_image_to_node(material.name, node.name, self.image_file_names["sebum"]) + if "_skn_roughness" in node.name: + self.assign_image_to_node(material.name, node.name, self.image_file_names["roughness"]) if "_skn_lipmap" in node.name: self.assign_image_to_node(material.name, node.name, self.image_file_names["lipmap"]) if "_iris_color" in node.name: @@ -309,7 +313,8 @@ def calculate_displacement_texture(self, age_factor, tone_factor, mass_factor): return if images_scale(disp_data_image, disp_img): - disp_img.pixels = self.calculate_disp_pixels(disp_data_image, age_factor, tone_factor, mass_factor) + new_pixels = self.calculate_disp_pixels(disp_data_image, age_factor, tone_factor, mass_factor) + disp_img.pixels.foreach_set(new_pixels) disp_tex.image = disp_img logger.info("Displacement calculated in %s seconds", time.time()-time1) else: diff --git a/measurescreator.py b/measurescreator.py index d0ff519c..66c06e94 100644 --- a/measurescreator.py +++ b/measurescreator.py @@ -393,7 +393,7 @@ def get_two_points(direction=0): two_points_index += 1 if two_points_index >= len(measures_parts_points): two_points_index = 0 - if two_points_index >= measures_parts_points: # It happens + if two_points_index >= len(measures_parts_points): # It happens two_points_index = len(measures_parts_points)-1 measure_name = measures_parts_points[two_points_index] # Now we seek and keep the values in the MeshHandling. @@ -428,7 +428,7 @@ def get_girth(direction=0): girth_index += 1 if girth_index >= len(measures_parts_girths): girth_index = 0 - if girth_index >= measures_parts_girths: # It happens + if girth_index >= len(measures_parts_girths): # It happens girth_index = len(measures_parts_girths)-1 measure_name = measures_parts_girths[girth_index] # Now we seek and keep the values in the MeshHandling. diff --git a/morphengine.py b/morphengine.py index 2aa4702f..265aa3fe 100644 --- a/morphengine.py +++ b/morphengine.py @@ -302,6 +302,8 @@ def compare_data_proportions(self): def correct_morphs(self, names): + if not self.bbox_data: + return morph_values_cache = {} for morph_name in self.morph_data.keys(): for name in names: diff --git a/preferences.py b/preferences.py index 7be6e88c..783f62cc 100644 --- a/preferences.py +++ b/preferences.py @@ -38,27 +38,27 @@ class MBPreferences(bpy.types.AddonPreferences): description="If enabled, auto-check for updates using an interval", default=False, ) - updater_intrval_months = bpy.props.IntProperty( + updater_interval_months = bpy.props.IntProperty( name='Months', description="Number of months between checking for updates", default=0, min=0 ) - updater_intrval_days = bpy.props.IntProperty( + updater_interval_days = bpy.props.IntProperty( name='Days', description="Number of days between checking for updates", default=7, min=0, max=31 ) - updater_intrval_hours = bpy.props.IntProperty( + updater_interval_hours = bpy.props.IntProperty( name='Hours', description="Number of hours between checking for updates", default=0, min=0, max=23 ) - updater_intrval_minutes = bpy.props.IntProperty( + updater_interval_minutes = bpy.props.IntProperty( name='Minutes', description="Number of minutes between checking for updates", default=0,