├── .gitignore ├── LICENSE.txt ├── README.md ├── __init__.py ├── logo.png ├── pack_for_export.py └── placeholder.png /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | releases/* 3 | __pycache__/ 4 | **.pyc 5 | sketchfab/.cache 6 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sketchfab Blender Addon 2 | 3 | **Directly import and export models from and to Sketchfab in Blender** 4 | 5 | * [Installation](#installation) 6 | * [Login](#login) 7 | * [Import from Sketchfab](#import-a-model-from-sketchfab) 8 | * [Export to Sketchfab](#export-a-model-to-sketchfab) 9 | * [Known issues](#known-issues) 10 | * [Report an issue](#report-an-issue) 11 | 12 | *Based on [Blender glTF 2.0 Importer and Exporter](https://github.com/KhronosGroup/glTF-Blender-IO) from [Khronos Group](https://github.com/KhronosGroup)* 13 | 14 |
15 | 16 | ## Installation 17 | 18 | To install the addon, just download the **sketchfab-x-y-z.zip** file attached to the [latest release](https://github.com/sketchfab/blender-plugin/releases/latest), and install it as a regular blender addon (User Preferences -> Addons -> Install from file). 19 | 20 | After installing the addon, two optional settings are available: 21 | 22 | * Download history: path to a .csv file used to keep track of your downloads and model licenses 23 | * Download directory: use this directory for temporary downloads (thumbnails and models). By default, OS specific temporary paths are used, but you can set this to a different directory if you encounter errors linked to write access. 24 | 25 |

26 | 27 |
28 | 29 | ## Login 30 | 31 | After installation, the addon is available in the 3D view in the tab 'Sketchfab' in the Properties panel (shortcut **N**) for Blender 2.80+. 32 | 33 | Login (mandatory to import or export models) can be achieved through using the email and password associated to your Sketchfab account, or by using your API token, available in the settings of your [Sketchfab account](https://sketchfab.com/settings/password): 34 | 35 |

36 | 37 | Your Sketchfab username should then be displayed upon successful login, and you will gain access to the full import and export capabilities of the addon. 38 | 39 | Please note that your login credentials are stored in a temporary file on your local machine (to automatically log you in when starting Blender). You can clear it by simply logging out of your Sketchfab account through the **Log Out** button. 40 | 41 | ### Organization members 42 | 43 | If you are a member of a [Sketchfab organization](https://sketchfab.com/3d-asset-management), you will be able to select the organization you belong to in the "Sketchfab for Teams" dropdown. Doing so will allow you to browse, import and export models from and to specific projects within your organization. 44 | 45 |
46 | 47 | ## Import a model from Sketchfab 48 | 49 | Once logged in, you should be able to easily import any downloadable model from Sketchfab. 50 | 51 |

52 | 53 | To do so, run a search query and adapt the search options in the **Search filters** menu. The dropdown located above the search bar lets you specify the type of models you are browsing through: 54 | 55 | * All site : downloadable models available under [Creative Commons licenses](https://help.sketchfab.com/hc/en-us/articles/201368589-Downloading-Models#licenses) on sketchfab.com 56 | * Own models: [PRO users](https://sketchfab.com/plans) can directly download models they have uploaded to their account 57 | * Store purchases: models you have purchased on the [Sketchfab Store](https://sketchfab.com/store) 58 | * Organization members can specify a specific project to browse 59 | 60 | Clicking the **Search Results** thumbnail allows to navigate through the search results, and selecting a thumbnail gives you details before import: 61 | 62 |

63 | 64 | If this fits your usecase better, you can also select the "Import from url" option to import a downloadable model through its full url, formatted as "http://sketchfab.com/3d-models/model-name-XXXX" or "https://sketchfab.com/orgs/OrgName/3d-models/model-name-XXXX" for organizations' models: 65 | 66 |

67 | 68 |
69 | 70 | ## Export a model to Sketchfab 71 | 72 | You can choose to either export the currently selected model(s) or all visible models, and set some model properties, such as its title, description and tags. 73 | 74 | You can also choose to keep the exported model as a draft (unchecking the checkbox will directly publish the model), but only **PRO** users can set their models as Private, and optionnaly protect them with a password. 75 | 76 | Finally, an option is given to [reupload a model](https://help.sketchfab.com/hc/en-us/articles/203064088-Reuploading-a-Model) by specifying the model's full url, formatted as "http://sketchfab.com/3d-models/model-name-XXXX" (or "https://sketchfab.com/orgs/OrgName/3d-models/model-name-XXXX" for organizations' models). Make sure to double check the model link you are reuploading to before proceeding. 77 | 78 |

79 | 80 | ### A note on material support 81 | 82 | Not all Blender materials and shaders will get correctly exported to Sketchfab. As a rule of thumb, avoid complex node graphs and don't use "transformative" nodes (Gradient, ColorRamp, Multiply, MixShader...) to improve the chances of your material being correctly parsed on Sketchfab. 83 | 84 | The best material support comes with the **Principled BSDF** node, having either parameters or image textures plugged into the following channels: 85 | 86 | * Base Color 87 | * Roughness 88 | * Metallic 89 | * Normal map 90 | * Alpha 91 | * Emission 92 | 93 | Note that Opacity and Backface Culling parameters should be set in the **Options** tab of the material's Properties panel in order to be directly activated in Sketchfab's 3D settings. 94 | 95 | Here is an example of a compatible node graph with backface culling and alpha mode correctly set (Blender 2.80 - Eevee renderer): 96 | 97 |

98 | 99 | 100 | ## Known Issues 101 | 102 | If none of the following description matches your problem, please feel free to [report an issue](#report-an-issue). 103 | 104 | ### Animation (import and export) 105 | 106 | Although simple skeletal or keyframed animations should work fine, more complex animations could cause unexpected behaviour. 107 | 108 | There is no "quick fix" for those kinds of behaviours, which are actively being worked on on our side. 109 | 110 | ### Import 111 | 112 | Here is a list of known issues on import, as well as some possible fixes. 113 | 114 | Please note that the materials are being converted from Sketchfab to Eevee in Blender 2.80+. If a material looks wrong, using the **Node editor** could therefore help you fixing possible issues. 115 | 116 | #### Mesh not parented to armature 117 | 118 | Until Blender 3.0, rigged meshes did not get parented correctly to their respective armatures, resulting in non-rigged models. This behaviour is fixed by using the plugin with a version of Blender after 3.0. 119 | 120 | #### Empty scene after import 121 | 122 | Scale can vary a lot between different models, and models origins are not always correctly centered. As imported models are be selected after import, you can try to scale them in order to make them visible (most often, the model will need to be scaled down). 123 | 124 | If it's not enough, try to select a mesh in the Outliner view and use numpad '.' (**View to selected** operator) to center the view on it. Modifying the range of the clip ("Clip start" and "Clip end") in the "View" tab of the Tools panel can also help for models with high scale. 125 | 126 | #### Unexpected colors (vertex colors) 127 | 128 | If your model displays strange color artifacts which don't seem to be caused by textures, you can try checking the model's vertex colors information (**Properties** area -> **Object data** tab -> **Vertex Colors** layer), and delete the data if present. 129 | 130 | Vertex colors are indeed always exported in glTF files (to allow edition), and always loaded in Blender. It is possible that this data is corrupted or useless - but disabled on Sketchfab - explaining why the online render looked fine. 131 | 132 | ### Export 133 | 134 | #### Transparency 135 | 136 | Some transparency settings might not be processed correctly, and just using a **Transparent BSDF** shader or linking a texture to the **Alpha** input of a **Principled BSDF** node might not be sufficient: try to set the opacity settings in the **Properties Panel** of the Node editor, under the **Options** tab by setting the **Blend Mode** to **Alpha Clip** or **Alpha Blend**. 137 | 138 | #### High Resolution textures 139 | 140 | In some very specific cases, the processing of your model can crash due to "heavy" textures. 141 | 142 | If your model does not process correctly in Sketchfab and that you are using multiple high resolution textures (for instance materials with 16k textures or multiple 8k textures), you can either try to reduce the original images size or upload your model without texture and add them later in Sketchfab's 3D settings. 143 | 144 | #### Texture Colorspace 145 | 146 | As of now, textures colorspace set in Blender are not automatically converted to Sketchfab, and although normal maps, roughness, metalness and occlusion textures should be processed correctly, setting a diffuse texture's colorspace in Blender as "Non-Color Data" or a metalness map as "Color" (sRGB in 2.80) will be ignored. 147 | 148 | 149 | ## Report an issue 150 | 151 | If you feel like you've encountered a bug not listed in the [known issues](#known-issues), or that the addon lacks an important feature, you can contact us through [Sketchfab's Help Center](https://help.sketchfab.com/hc/en-us/requests/new?type=exporters&subject=Blender+Plugin) (or directly from the addon through the **Report an issue** button). 152 | 153 | To help us track a possible error, please try to append the logs of Blender's console in your message: 154 | 155 | * On Windows, it is available through the menu **Window** -> **Toggle system console** 156 | * On OSX or Linux systems, you can access this data by [starting Blender from the command line](https://docs.blender.org/manual/en/dev/render/workflows/command_line.html). Outputs will then be printed in the shell from which you launched Blender. 157 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2022 Sketchfab 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | import os 18 | import urllib 19 | import requests 20 | import threading 21 | import time 22 | from collections import OrderedDict 23 | import subprocess 24 | import tempfile 25 | import json 26 | import shutil 27 | from uuid import UUID 28 | 29 | import bpy 30 | import bpy.utils.previews 31 | from bpy.props import (StringProperty, 32 | EnumProperty, 33 | BoolProperty, 34 | IntProperty, 35 | PointerProperty) 36 | 37 | bl_info = { 38 | 'name': 'Sketchfab Plugin', 39 | 'description': 'Browse and download free Sketchfab downloadable models', 40 | 'author': 'Sketchfab', 41 | 'license': 'APACHE2', 42 | 'deps': '', 43 | 'version': (1, 6, 1), 44 | "blender": (2, 80, 0), 45 | 'location': 'View3D > Tools > Sketchfab', 46 | 'warning': '', 47 | 'wiki_url': 'https://github.com/sketchfab/blender-plugin/releases', 48 | 'tracker_url': 'https://github.com/sketchfab/blender-plugin/issues', 49 | 'link': 'https://github.com/sketchfab/blender-plugin', 50 | 'support': 'COMMUNITY', 51 | 'category': 'Import-Export' 52 | } 53 | bl_info['blender'] = getattr(bpy.app, "version") 54 | 55 | 56 | PLUGIN_VERSION = str(bl_info['version']).strip('() ').replace(',', '.') 57 | preview_collection = {} 58 | thumbnailsProgress = set([]) 59 | ongoingSearches = set([]) 60 | is_plugin_enabled = False 61 | 62 | 63 | class Config: 64 | 65 | ADDON_NAME = 'io_sketchfab' 66 | GITHUB_REPOSITORY_URL = 'https://github.com/sketchfab/blender-plugin' 67 | GITHUB_REPOSITORY_API_URL = 'https://api.github.com/repos/sketchfab/blender-plugin' 68 | SKETCHFAB_REPORT_URL = 'https://help.sketchfab.com/hc/en-us/requests/new?type=exporters&subject=Blender+Plugin' 69 | 70 | SKETCHFAB_URL = 'https://sketchfab.com' 71 | CLIENTID = 'hGC7unF4BHyEB0s7Orz5E1mBd3LluEG0ILBiZvF9' 72 | SKETCHFAB_OAUTH = SKETCHFAB_URL + '/oauth2/token/' 73 | SKETCHFAB_API = 'https://api.sketchfab.com' 74 | SKETCHFAB_SEARCH = SKETCHFAB_API + '/v3/search' 75 | SKETCHFAB_MODEL = SKETCHFAB_API + '/v3/models' 76 | SKETCHFAB_ORGS = SKETCHFAB_API + '/v3/orgs' 77 | SKETCHFAB_SIGNUP = 'https://sketchfab.com/signup' 78 | 79 | BASE_SEARCH = SKETCHFAB_SEARCH + '?type=models&downloadable=true' 80 | DEFAULT_FLAGS = '&staffpicked=true&sort_by=-staffpickedAt' 81 | DEFAULT_SEARCH = SKETCHFAB_SEARCH + \ 82 | '?type=models&downloadable=true' + DEFAULT_FLAGS 83 | 84 | SKETCHFAB_ME = '{}/v3/me'.format(SKETCHFAB_API) 85 | BASE_SEARCH_OWN_MODELS = SKETCHFAB_ME + '/search?type=models&downloadable=true' 86 | PURCHASED_MODELS = SKETCHFAB_ME + "/models/purchases?type=models" 87 | 88 | SKETCHFAB_PLUGIN_VERSION = '{}/releases'.format(GITHUB_REPOSITORY_API_URL) 89 | 90 | # Those will be set during plugin initialization, or upon setting a new cache directory 91 | SKETCHFAB_TEMP_DIR = "" 92 | SKETCHFAB_THUMB_DIR = "" 93 | SKETCHFAB_MODEL_DIR = "" 94 | 95 | SKETCHFAB_CATEGORIES = (('ALL', 'All categories', 'All categories'), 96 | ('animals-pets', 'Animals & Pets', 'Animals and Pets'), 97 | ('architecture', 'Architecture', 'Architecture'), 98 | ('art-abstract', 'Art & Abstract', 'Art & Abstract'), 99 | ('cars-vehicles', 'Cars & vehicles', 'Cars & vehicles'), 100 | ('characters-creatures', 'Characters & Creatures', 'Characters & Creatures'), 101 | ('cultural-heritage-history', 'Cultural Heritage & History', 'Cultural Heritage & History'), 102 | ('electronics-gadgets', 'Electronics & Gadgets', 'Electronics & Gadgets'), 103 | ('fashion-style', 'Fashion & Style', 'Fashion & Style'), 104 | ('food-drink', 'Food & Drink', 'Food & Drink'), 105 | ('furniture-home', 'Furniture & Home', 'Furniture & Home'), 106 | ('music', 'Music', 'Music'), 107 | ('nature-plants', 'Nature & Plants', 'Nature & Plants'), 108 | ('news-politics', 'News & Politics', 'News & Politics'), 109 | ('people', 'People', 'People'), 110 | ('places-travel', 'Places & Travel', 'Places & Travel'), 111 | ('science-technology', 'Science & Technology', 'Science & Technology'), 112 | ('sports-fitness', 'Sports & Fitness', 'Sports & Fitness'), 113 | ('weapons-military', 'Weapons & Military', 'Weapons & Military')) 114 | 115 | SKETCHFAB_FACECOUNT = (('ANY', "All", ""), 116 | ('10K', "Up to 10k", ""), 117 | ('50K', "10k to 50k", ""), 118 | ('100K', "50k to 100k", ""), 119 | ('250K', "100k to 250k", ""), 120 | ('250KP', "250k +", "")) 121 | 122 | SKETCHFAB_SORT_BY = (('RELEVANCE', "Relevance", ""), 123 | ('LIKES', "Likes", ""), 124 | ('VIEWS', "Views", ""), 125 | ('RECENT', "Recent", "")) 126 | 127 | SKETCHFAB_SEARCH_DOMAIN = (('DEFAULT', "All site", "", 0), 128 | ('OWN', "Own Models (PRO)", "", 1), 129 | ('STORE', "Store purchases", "", 2)) 130 | 131 | MAX_THUMBNAIL_HEIGHT = 256 132 | 133 | SKETCHFAB_UPLOAD_LIMITS = { 134 | "basic" : 100 * 1024 * 1024, 135 | "pro": 200 * 1024 * 1024, 136 | "prem": 500 * 1024 * 1024, 137 | "ent": 500 * 1024 * 1024 138 | } 139 | 140 | class Utils: 141 | def humanify_size(size): 142 | suffix = 'B' 143 | readable = size 144 | 145 | # Megabyte 146 | if size > 1048576: 147 | suffix = 'MB' 148 | readable = size / 1048576.0 149 | # Kilobyte 150 | elif size > 1024: 151 | suffix = 'KB' 152 | readable = size / 1024.0 153 | 154 | readable = round(readable, 2) 155 | return '{}{}'.format(readable, suffix) 156 | 157 | def humanify_number(number): 158 | suffix = '' 159 | readable = number 160 | 161 | if number > 1000000: 162 | suffix = 'M' 163 | readable = number / 1000000.0 164 | 165 | elif number > 1000: 166 | suffix = 'K' 167 | readable = number / 1000.0 168 | 169 | readable = round(readable, 2) 170 | return '{}{}'.format(readable, suffix) 171 | 172 | def build_download_url(uid, use_org_profile=False, active_org=None): 173 | if use_org_profile: 174 | return '{}/{}/models/{}/download'.format(Config.SKETCHFAB_ORGS, active_org["uid"], uid) 175 | else: 176 | return '{}/{}/download'.format(Config.SKETCHFAB_MODEL, uid) 177 | 178 | def thumbnail_file_exists(uid): 179 | return os.path.exists(os.path.join(Config.SKETCHFAB_THUMB_DIR, '{}.jpeg'.format(uid))) 180 | 181 | def clean_thumbnail_directory(): 182 | if not os.path.exists(Config.SKETCHFAB_THUMB_DIR): 183 | return 184 | 185 | from os import listdir 186 | for file in listdir(Config.SKETCHFAB_THUMB_DIR): 187 | os.remove(os.path.join(Config.SKETCHFAB_THUMB_DIR, file)) 188 | 189 | def clean_downloaded_model_dir(uid): 190 | shutil.rmtree(os.path.join(Config.SKETCHFAB_MODEL_DIR, uid)) 191 | 192 | def get_thumbnail_url(thumbnails_json): 193 | min_height = 1e6 194 | min_thumbnail = None 195 | best_height = 0 196 | best_thumbnail = None 197 | for image in thumbnails_json['images']: 198 | h = image['height'] 199 | if h <= Config.MAX_THUMBNAIL_HEIGHT and h > best_height: 200 | best_height = h 201 | best_thumbnail = image['url'] 202 | elif h < min_height: 203 | min_height = h 204 | min_thumbnail = image['url'] 205 | # Ensure we have a thumbnail if available thumbnails are all above MAX_THUMBNAIL_HEIGHT 206 | if best_thumbnail is None and min_thumbnail is not None: 207 | return min_thumbnail 208 | return best_thumbnail 209 | 210 | def setup_plugin(): 211 | if not os.path.exists(Config.SKETCHFAB_THUMB_DIR): 212 | os.makedirs(Config.SKETCHFAB_THUMB_DIR) 213 | 214 | def get_uid_from_thumbnail_url(thumbnail_url): 215 | return thumbnail_url.split('/')[4] 216 | 217 | def get_uid_from_model_url(model_url, use_org_profile=False): 218 | try: 219 | return model_url.split('/')[7] if use_org_profile else model_url.split('/')[5] 220 | except: 221 | ShowMessage("ERROR", "Url parsing error", "Error getting uid from url: {}".format(model_url)) 222 | return None 223 | 224 | def get_uid_from_download_url(model_url): 225 | return model_url.split('/')[6] 226 | 227 | def clean_node_hierarchy(objects, root_name): 228 | """ 229 | Removes the useless nodes in a hierarchy 230 | TODO: Keep the transform (might impact Yup/Zup) 231 | """ 232 | # Find the parent object 233 | root = None 234 | for object in objects: 235 | if object.parent is None: 236 | root = object 237 | if root is None: 238 | return None 239 | 240 | # Go down its hierarchy until one child has multiple children, or a single mesh 241 | # Keep the name while deleting objects in the hierarchy 242 | diverges = False 243 | while diverges==False: 244 | children = root.children 245 | if children is not None: 246 | 247 | if len(children)>1: 248 | diverges = True 249 | root.name = root_name 250 | 251 | if len(children)==1: 252 | if children[0].type != "EMPTY": 253 | diverges = True 254 | root.name = root_name 255 | if children[0].type == "MESH": # should always be the case 256 | matrixcopy = children[0].matrix_world.copy() 257 | children[0].parent = None 258 | children[0].matrix_world = matrixcopy 259 | bpy.data.objects.remove(root) 260 | children[0].name = root_name 261 | root = children[0] 262 | 263 | elif children[0].type == "EMPTY": 264 | diverges = False 265 | matrixcopy = children[0].matrix_world.copy() 266 | children[0].parent = None 267 | children[0].matrix_world = matrixcopy 268 | bpy.data.objects.remove(root) 269 | root = children[0] 270 | else: 271 | break 272 | 273 | # Select the root Empty node 274 | root.select_set(True) 275 | 276 | def is_valid_uuid(uuid_to_test, version=4): 277 | try: 278 | uuid_obj = UUID(hex=uuid_to_test, version=version) 279 | return True 280 | except ValueError: 281 | return False 282 | 283 | class Cache: 284 | SKETCHFAB_CACHE_FILE = os.path.join( 285 | bpy.utils.user_resource("SCRIPTS", path="sketchfab_cache", create=True), 286 | ".cache" 287 | ) # Use a user path to avoid permission-related errors 288 | 289 | def read(): 290 | if not os.path.exists(Cache.SKETCHFAB_CACHE_FILE): 291 | return {} 292 | 293 | with open(Cache.SKETCHFAB_CACHE_FILE, 'rb') as f: 294 | data = f.read().decode('utf-8') 295 | return json.loads(data) 296 | 297 | def get_key(key): 298 | cache_data = Cache.read() 299 | if key in cache_data: 300 | return cache_data[key] 301 | 302 | def save_key(key, value): 303 | cache_data = Cache.read() 304 | cache_data[key] = value 305 | with open(Cache.SKETCHFAB_CACHE_FILE, 'wb+') as f: 306 | f.write(json.dumps(cache_data).encode('utf-8')) 307 | 308 | def delete_key(key): 309 | cache_data = Cache.read() 310 | if key in cache_data: 311 | del cache_data[key] 312 | 313 | with open(Cache.SKETCHFAB_CACHE_FILE, 'wb+') as f: 314 | f.write(json.dumps(cache_data).encode('utf-8')) 315 | 316 | 317 | # helpers 318 | def get_sketchfab_login_props(): 319 | return bpy.context.window_manager.sketchfab_api 320 | 321 | 322 | def get_sketchfab_props(): 323 | return bpy.context.window_manager.sketchfab_browser 324 | 325 | 326 | def get_sketchfab_props_proxy(): 327 | return bpy.context.window_manager.sketchfab_browser_proxy 328 | 329 | def get_sketchfab_model(uid): 330 | skfb = get_sketchfab_props() 331 | if "current" in skfb.search_results and uid in skfb.search_results["current"]: 332 | return skfb.search_results['current'][uid] 333 | else: 334 | return None 335 | 336 | def run_default_search(): 337 | searchthr = GetRequestThread(Config.DEFAULT_SEARCH, parse_results) 338 | searchthr.start() 339 | 340 | 341 | def get_plugin_enabled(): 342 | global is_plugin_enabled 343 | return is_plugin_enabled 344 | 345 | 346 | def refresh_search(self, context): 347 | pprops = get_sketchfab_props_proxy() 348 | if pprops.is_refreshing: 349 | return 350 | 351 | props = get_sketchfab_props() 352 | 353 | if pprops.search_domain != props.search_domain: 354 | props.search_domain = pprops.search_domain 355 | if pprops.sort_by != props.sort_by: 356 | props.sort_by = pprops.sort_by 357 | 358 | if 'current' in props.search_results: 359 | del props.search_results['current'] 360 | 361 | props.query = pprops.query 362 | props.animated = pprops.animated 363 | props.pbr = pprops.pbr 364 | props.staffpick = pprops.staffpick 365 | props.categories = pprops.categories 366 | props.face_count = pprops.face_count 367 | bpy.ops.wm.sketchfab_search('EXEC_DEFAULT') 368 | 369 | 370 | def set_login_status(status_type, status): 371 | login_props = get_sketchfab_login_props() 372 | login_props.status = status 373 | login_props.status_type = status_type 374 | 375 | 376 | def set_import_status(status): 377 | props = get_sketchfab_props() 378 | props.import_status = status 379 | 380 | 381 | class SketchfabApi: 382 | def __init__(self): 383 | self.access_token = '' 384 | self.api_token = '' 385 | self.headers = {} 386 | self.username = '' 387 | self.display_name = '' 388 | self.plan_type = '' 389 | self.next_results_url = None 390 | self.prev_results_url = None 391 | self.user_orgs = [] 392 | self.user_has_orgs = False 393 | self.active_org = None 394 | self.use_org_profile = False 395 | 396 | def build_headers(self): 397 | if self.access_token: 398 | self.headers = {'Authorization': 'Bearer ' + self.access_token} 399 | elif self.api_token: 400 | self.headers = {'Authorization': 'Token ' + self.api_token} 401 | else: 402 | print("Empty authorization header") 403 | self.headers = {} 404 | 405 | def login(self, email, password, api_token): 406 | bpy.ops.wm.login_modal('INVOKE_DEFAULT') 407 | 408 | def is_user_logged(self): 409 | if (self.access_token or self.api_token) and self.headers: 410 | return True 411 | 412 | return False 413 | 414 | def is_user_pro(self): 415 | return len(self.plan_type) and self.plan_type not in ['basic', 'plus'] 416 | 417 | def logout(self): 418 | self.access_token = '' 419 | self.api_token = '' 420 | self.headers = {} 421 | Cache.delete_key('username') 422 | Cache.delete_key('access_token') 423 | Cache.delete_key('api_token') 424 | Cache.delete_key('key') 425 | 426 | props = get_sketchfab_props() 427 | #props.search_domain = "DEFAULT" 428 | if 'current' in props.search_results: 429 | del props.search_results['current'] 430 | pprops = get_sketchfab_props_proxy() 431 | #pprops.search_domain = "DEFAULT" 432 | 433 | self.user_orgs = [] 434 | self.user_has_orgs = False 435 | self.active_org = None 436 | self.use_org_profile = False 437 | props.use_org_profile = False 438 | pprops.use_org_profile = False 439 | 440 | bpy.ops.wm.sketchfab_search('EXEC_DEFAULT') 441 | 442 | def request_user_info(self): 443 | requests.get(Config.SKETCHFAB_ME, headers=self.headers, hooks={'response': self.parse_user_info}) 444 | 445 | def get_user_info(self): 446 | if self.display_name and self.plan_type: 447 | return '{} ({})'.format(self.display_name, self.plan_type) 448 | else: 449 | return ('', '') 450 | 451 | def parse_user_info(self, r, *args, **kargs): 452 | if r.status_code == 200: 453 | user_data = r.json() 454 | self.username = user_data['username'] 455 | self.display_name = user_data['displayName'] 456 | self.plan_type = user_data['account'] 457 | requests.get(Config.SKETCHFAB_ME + "/orgs", headers=self.headers, hooks={'response': self.on_user_orgs_check}) 458 | else: 459 | print('\nInvalid access or API token\nYou can get your API token here:\nhttps://sketchfab.com/settings/password\n') 460 | set_login_status('ERROR', 'Failed to authenticate') 461 | ShowMessage("ERROR", "Failed to authenticate", "Invalid access or API token") 462 | self.access_token = '' 463 | self.api_token = '' 464 | self.headers = {} 465 | 466 | def request_user_orgs(self): 467 | if not self.active_org: 468 | requests.get(Config.SKETCHFAB_ME + "/orgs", headers=self.headers, hooks={'response': self.parse_orgs_info}) 469 | pass 470 | 471 | def on_user_orgs_check(self, r, *args, **kargs): 472 | self.user_has_orgs = bool((r.status_code == 200) and len(r.json().get("results", []))) 473 | 474 | def parse_orgs_info(self, r, *args, **kargs): 475 | """ 476 | Get and store information about user's orgs, and its orgs projects 477 | """ 478 | if r.status_code == 200: 479 | orgs_data = r.json() 480 | 481 | # Get a list of the user's orgs 482 | for org in orgs_data["results"]: 483 | self.user_orgs.append({ 484 | "uid": org["uid"], 485 | "displayName": org["displayName"], 486 | "username": org["username"], 487 | "url": org["publicProfileUrl"], 488 | "projects": [], 489 | }) 490 | self.user_orgs.sort(key = lambda x : x["displayName"]) 491 | 492 | # Iterate on the orgs to get lists of their projects 493 | for org in self.user_orgs: 494 | 495 | # Create the callback inline to keep a reference to the org uid 496 | def parse_projects_info(r, *args, **kargs): 497 | """ 498 | Get and store information about an org projects 499 | """ 500 | if r.status_code == 200: 501 | projects_data = r.json() 502 | projects = projects_data["results"] 503 | # Add the projects to the orgs dict object 504 | for proj in projects: 505 | org_uid = proj["org"]["uid"] 506 | org = next((x for x in self.user_orgs if x["uid"] == org_uid)) 507 | org["projects"].append({ 508 | "uid": proj["uid"], 509 | "name": proj["name"], 510 | "slug": proj["slug"], 511 | "modelCount": proj["modelCount"], 512 | "memberCount": proj["memberCount"], 513 | }) 514 | org["projects"].sort(key = lambda x : x["name"]) 515 | 516 | # Iterate on all projects (not just the 24 first) 517 | if projects_data["next"] is not None: 518 | requests.get( 519 | projects_data["next"], 520 | headers=self.headers, 521 | hooks={'response': parse_projects_info} 522 | ) 523 | 524 | else: 525 | print('Can not get projects info') 526 | 527 | requests.get("%s/%s/projects" % (Config.SKETCHFAB_ORGS, org["uid"]), 528 | headers=self.headers, 529 | hooks={'response': parse_projects_info}) 530 | 531 | # Set the first org as active 532 | if len(self.user_orgs): 533 | self.active_org = self.user_orgs[0] 534 | self.user_has_orgs = True 535 | 536 | # Iterate on all orgs (not just the 24 first) 537 | if orgs_data["next"] is not None: 538 | requests.get(orgs_data["next"], headers=self.headers, hooks={'response': self.parse_orgs_info}) 539 | 540 | def request_thumbnail(self, thumbnails_json, model_uid): 541 | # Avoid requesting twice the same data 542 | if model_uid not in thumbnailsProgress: 543 | thumbnailsProgress.add(model_uid) 544 | url = Utils.get_thumbnail_url(thumbnails_json) 545 | thread = ThumbnailCollector(url) 546 | thread.start() 547 | 548 | def request_model_info(self, uid, callback=None): 549 | callback = self.handle_model_info if callback is None else callback 550 | url = Config.SKETCHFAB_MODEL + '/' + uid 551 | if self.use_org_profile and self.active_org.get("uid"): 552 | url = Config.SKETCHFAB_ORGS + "/" + self.active_org["uid"] + "/models/" + uid 553 | 554 | model_infothr = GetRequestThread(url, callback, self.headers) 555 | model_infothr.start() 556 | 557 | def handle_model_info(self, r, *args, **kwargs): 558 | skfb = get_sketchfab_props() 559 | uid = Utils.get_uid_from_model_url(r.url, self.use_org_profile) 560 | 561 | # Dirty fix to avoid processing obsolete result data 562 | if 'current' not in skfb.search_results or uid is None or uid not in skfb.search_results['current']: 563 | return 564 | 565 | model = skfb.search_results['current'][uid] 566 | json_data = r.json() 567 | model.license = json_data.get('license', {}) 568 | if model.license is not None: 569 | model.license = model.license.get('fullName', 'Personal (you own this model)') 570 | anim_count = int(json_data.get('animationCount', 0)) 571 | model.animated = 'Yes ({} animation(s))'.format(anim_count) if anim_count > 0 else 'No' 572 | skfb.search_results['current'][uid] = model 573 | 574 | def search(self, query, search_cb): 575 | skfb = get_sketchfab_props() 576 | url = Config.BASE_SEARCH 577 | if skfb.search_domain == "OWN": 578 | url = Config.BASE_SEARCH_OWN_MODELS 579 | elif skfb.search_domain == "STORE": 580 | url = Config.PURCHASED_MODELS 581 | elif skfb.search_domain == "ACTIVE_ORG": 582 | url = Config.SKETCHFAB_ORGS + "/%s/models?isArchivesReady=true" % self.active_org["uid"] 583 | elif len(skfb.search_domain) == 32: 584 | url = Config.SKETCHFAB_ORGS + "/%s/models?isArchivesReady=true&projects=%s" % (self.active_org["uid"], skfb.search_domain) 585 | 586 | search_query = '{}{}'.format(url, query) 587 | if search_query not in ongoingSearches: 588 | ongoingSearches.add(search_query) 589 | searchthr = GetRequestThread(search_query, search_cb, self.headers) 590 | searchthr.start() 591 | 592 | def search_cursor(self, url, search_cb): 593 | requests.get(url, headers=self.headers, hooks={'response': search_cb}) 594 | 595 | def write_model_info(self, title, author, authorUrl, license, uid): 596 | try: 597 | downloadHistory = bpy.context.preferences.addons[__name__.split('.')[0]].preferences.downloadHistory 598 | if downloadHistory != "": 599 | downloadHistory = os.path.abspath(downloadHistory) 600 | createFile = False 601 | if not os.path.exists(downloadHistory): 602 | createFile = True 603 | with open(downloadHistory, 'a+') as f: 604 | if createFile: 605 | f.write("Model name, Author name, Author url, License, Model link,\n") 606 | f.write("{}, {}, https://sketchfab.com/{}, {}, https://sketchfab.com/models/{},\n".format( 607 | title.replace(",", " "), 608 | author.replace(",", " "), 609 | authorUrl.replace(",", " "), 610 | license.replace(",", " "), 611 | uid 612 | )) 613 | except: 614 | print("Error encountered while saving data to history file") 615 | 616 | def parse_model_info_request(self, r, *args, **kargs): 617 | try: 618 | if r.status_code == 200: 619 | result = r.json() 620 | title = result['name'] 621 | author = result['user']['displayName'] 622 | username = result['user']['username'] 623 | license = result["license"]["label"] 624 | uid = result['uid'] 625 | self.write_model_info(title, author, username, license, uid) 626 | else: 627 | print("Error encountered while getting model info ({})\n{}\n{}".format(r.status_code, r.url, str(r.json()))) 628 | except: 629 | print("Error encountered while parsing model info request: {}".format(r.url)) 630 | 631 | def download_model(self, uid): 632 | skfb_model = get_sketchfab_model(uid) 633 | if skfb_model is not None: # The model comes from the search results 634 | if skfb_model.download_url and (time.time() - skfb_model.time_url_requested < skfb_model.url_expires): 635 | self.get_archive(skfb_model.download_url) 636 | else: 637 | skfb_model.download_url = None 638 | skfb_model.url_expires = None 639 | skfb_model.time_url_requested = None 640 | self.write_model_info(skfb_model.title, skfb_model.author, skfb_model.username, skfb_model.license, uid) 641 | requests.get(Utils.build_download_url(uid, self.use_org_profile, self.active_org), headers=self.headers, hooks={'response': self.handle_download}) 642 | else: # Model comes from a direct link 643 | skfb = get_sketchfab_props() 644 | download_url = "" 645 | 646 | # If the model is in an org, find if the user has access to it 647 | if "/orgs/" in skfb.manualImportPath: 648 | try: 649 | orgName = skfb.manualImportPath.split("/orgs/")[1].split("/")[0] 650 | if skfb.skfb_api.user_has_orgs and not skfb.skfb_api.active_org: 651 | skfb.skfb_api.request_user_orgs() 652 | 653 | user_orgs = skfb.skfb_api.user_orgs 654 | orgUid = "" 655 | for org in user_orgs: 656 | if org["username"] == orgName: 657 | orgUid = org["uid"] 658 | break 659 | if orgUid: 660 | download_url = '{}/{}/models/{}/download'.format(Config.SKETCHFAB_ORGS, orgUid, uid) 661 | else: 662 | ShowMessage("ERROR", "User not in Organization", "User does not appear to belong to org %s" % (orgName)) 663 | return 664 | except: 665 | ShowMessage("ERROR", "Invalid url", "Cannot parse org name from url %s" % skfb.manualImportPath) 666 | return 667 | # Otherwise, request a direct download and get model info 668 | else: 669 | download_url = Utils.build_download_url(uid) 670 | requests.get('{}/{}'.format(Config.SKETCHFAB_MODEL, uid), headers=skfb.skfb_api.headers, hooks={'response': self.parse_model_info_request}) 671 | 672 | requests.get(download_url, headers=self.headers, hooks={'response': self.handle_download}) 673 | 674 | def handle_download(self, r, *args, **kwargs): 675 | if r.status_code != 200 or 'gltf' not in r.json(): 676 | ShowMessage("ERROR", "This model is not downloadable", "Make sure your account has enough rights to download the model") 677 | return 678 | 679 | skfb = get_sketchfab_props() 680 | uid = Utils.get_uid_from_model_url(r.url, self.use_org_profile) 681 | if uid is None: 682 | return 683 | 684 | gltf = r.json()['gltf'] 685 | skfb_model = get_sketchfab_model(uid) 686 | 687 | # If the model name is not known at this step, we could try to do an additional API call to get it 688 | # This can happen when the user chose to import a model from its url 689 | # However this adds an additional call and a bit of complexity for org models (need additional parsing), 690 | # so for a simple hotfix models imported this way will be called "Sketchfab model" 691 | self.get_archive(gltf['url'], skfb_model.title if skfb_model else "Sketchfab Model") 692 | 693 | def get_archive(self, url, title): 694 | if url is None: 695 | print('Url is None') 696 | return 697 | 698 | r = requests.get(url, stream=True) 699 | uid = Utils.get_uid_from_download_url(url) 700 | temp_dir = os.path.join(Config.SKETCHFAB_MODEL_DIR, uid) 701 | if not os.path.exists(temp_dir): 702 | os.makedirs(temp_dir) 703 | 704 | archive_path = os.path.join(temp_dir, '{}.zip'.format(uid)) 705 | if not os.path.exists(archive_path): 706 | wm = bpy.context.window_manager 707 | wm.progress_begin(0, 100) 708 | set_log("Downloading model..") 709 | with open(archive_path, "wb") as f: 710 | total_length = r.headers.get('content-length') 711 | if total_length is None: # no content length header 712 | f.write(r.content) 713 | else: 714 | dl = 0 715 | total_length = int(total_length) 716 | for data in r.iter_content(chunk_size=4096): 717 | dl += len(data) 718 | f.write(data) 719 | done = int(100 * dl / total_length) 720 | wm.progress_update(done) 721 | set_log("Downloading model..{}%".format(done)) 722 | 723 | wm.progress_end() 724 | else: 725 | print('Model already downloaded') 726 | 727 | gltf_path, gltf_zip = unzip_archive(archive_path) 728 | if gltf_path: 729 | try: 730 | import_model(gltf_path, uid, title) 731 | except Exception as e: 732 | import traceback 733 | print(traceback.format_exc()) 734 | else: 735 | ShowMessage("ERROR", "Download error", "Failed to download model (url might be invalid)") 736 | model = get_sketchfab_model(uid) 737 | set_import_status("Import model ({})".format(model.download_size if model.download_size else 'fetching data')) 738 | return 739 | 740 | class SketchfabLoginProps(bpy.types.PropertyGroup): 741 | def update_tr(self, context): 742 | self.status = '' 743 | if self.email != self.last_username or self.password != self.last_password: 744 | self.last_username = self.email 745 | self.last_password = self.password 746 | if not self.password: 747 | set_login_status('ERROR', 'Password is empty') 748 | bpy.ops.wm.sketchfab_login('EXEC_DEFAULT') 749 | 750 | 751 | email : StringProperty( 752 | name="email", 753 | description="User email", 754 | default="" 755 | ) 756 | 757 | api_token : StringProperty( 758 | name="API Token", 759 | description="User API Token", 760 | default="" 761 | ) 762 | 763 | use_mail : BoolProperty( 764 | name="Use mail / password", 765 | description="Use mail/password login or API Token", 766 | default=True, 767 | ) 768 | 769 | password : StringProperty( 770 | name="password", 771 | description="User password", 772 | subtype='PASSWORD', 773 | default="", 774 | update=update_tr 775 | ) 776 | 777 | access_token : StringProperty( 778 | name="access_token", 779 | description="oauth access token", 780 | subtype='PASSWORD', 781 | default="" 782 | ) 783 | 784 | status : StringProperty(name='', default='') 785 | status_type : EnumProperty( 786 | name="Login status type", 787 | items=(('ERROR', "Error", ""), 788 | ('INFO', "Information", ""), 789 | ('FILE_REFRESH', "Progress", "")), 790 | description="Determines which icon to use", 791 | default='FILE_REFRESH' 792 | ) 793 | 794 | last_username : StringProperty(default="default") 795 | last_password : StringProperty(default="default") 796 | 797 | skfb_api = SketchfabApi() 798 | 799 | def get_user_orgs(self, context): 800 | api = get_sketchfab_props().skfb_api 801 | if not api.user_has_orgs: 802 | api.request_user_orgs() 803 | return [(org["uid"], org["displayName"], "") for org in api.user_orgs] 804 | 805 | def get_org_projects(self, context): 806 | api = get_sketchfab_props().skfb_api 807 | return [(proj["uid"], proj["name"], proj["name"]) for proj in api.active_org["projects"]] 808 | 809 | def get_available_search_domains(self, context): 810 | api = get_sketchfab_props().skfb_api 811 | 812 | search_domains = [domain for domain in Config.SKETCHFAB_SEARCH_DOMAIN] 813 | 814 | if api.user_has_orgs and api.use_org_profile: 815 | search_domains = [ 816 | ("ACTIVE_ORG", "Active Organization", api.active_org["displayName"], 0) 817 | ] 818 | for p in get_org_projects(self, context): 819 | search_domains.append(p) 820 | 821 | return tuple(search_domains) 822 | 823 | def refresh_orgs(self, context): 824 | 825 | pprops = get_sketchfab_props_proxy() 826 | if pprops.is_refreshing: 827 | return 828 | 829 | props = get_sketchfab_props() 830 | api = props.skfb_api 831 | 832 | api.use_org_profile = pprops.use_org_profile 833 | 834 | if api.user_has_orgs and not api.active_org : 835 | bpy.context.window.cursor_set("WAIT") 836 | api.request_user_orgs() 837 | bpy.context.window.cursor_set("DEFAULT") 838 | 839 | orgs = [org for org in api.user_orgs if org["uid"] == pprops.active_org] 840 | api.active_org = orgs[0] if len(orgs) else None 841 | 842 | if pprops.use_org_profile != props.use_org_profile: 843 | props.use_org_profile = pprops.use_org_profile 844 | if pprops.active_org != props.active_org: 845 | props.active_org = pprops.active_org 846 | 847 | if props.use_org_profile: 848 | props.search_domain = "ACTIVE_ORG" 849 | pprops.search_domain = "ACTIVE_ORG" 850 | else: 851 | props.search_domain = "DEFAULT" 852 | pprops.search_domain = "DEFAULT" 853 | 854 | refresh_search(self, context) 855 | 856 | def get_sorting_options(self, context): 857 | api = get_sketchfab_props().skfb_api 858 | if api.user_has_orgs and api.use_org_profile: 859 | return ( 860 | ('RELEVANCE', "Relevance", ""), 861 | ('RECENT', "Recent", "") 862 | ) 863 | else: 864 | return Config.SKETCHFAB_SORT_BY 865 | 866 | class SketchfabBrowserPropsProxy(bpy.types.PropertyGroup): 867 | # Search 868 | query : StringProperty( 869 | name="", 870 | update=refresh_search, 871 | description="Query to search", 872 | default="", 873 | options={'SKIP_SAVE'} 874 | ) 875 | 876 | pbr : BoolProperty( 877 | name="PBR", 878 | description="Search for PBR model only", 879 | default=False, 880 | update=refresh_search, 881 | ) 882 | 883 | categories : EnumProperty( 884 | name="Categories", 885 | items=Config.SKETCHFAB_CATEGORIES, 886 | description="Show only models of category", 887 | default='ALL', 888 | update=refresh_search 889 | ) 890 | face_count : EnumProperty( 891 | name="Face Count", 892 | items=Config.SKETCHFAB_FACECOUNT, 893 | description="Determines which meshes are exported", 894 | default='ANY', 895 | update=refresh_search 896 | ) 897 | 898 | sort_by : EnumProperty( 899 | name="Sort by", 900 | items=get_sorting_options, 901 | description="Sort ", 902 | update=refresh_search, 903 | ) 904 | 905 | animated : BoolProperty( 906 | name="Animated", 907 | description="Show only models with animation", 908 | default=False, 909 | update=refresh_search 910 | ) 911 | 912 | staffpick : BoolProperty( 913 | name="Staffpick", 914 | description="Show only staffpick models", 915 | default=False, 916 | update=refresh_search 917 | ) 918 | 919 | search_domain : EnumProperty( 920 | name="", 921 | items=get_available_search_domains, 922 | description="Search domain ", 923 | update=refresh_search, 924 | default=None 925 | ) 926 | 927 | use_org_profile : BoolProperty( 928 | name="Use organisation profile", 929 | description="Download/Upload as a member of an organization.\nSearch queries and uploads will be performed to\nthe organisation and project selected below", 930 | default=False, 931 | update=refresh_orgs 932 | ) 933 | 934 | active_org : EnumProperty( 935 | name="Org", 936 | items=get_user_orgs, 937 | description="Active org", 938 | update=refresh_orgs 939 | ) 940 | 941 | is_refreshing : BoolProperty( 942 | name="Refresh", 943 | description="Refresh", 944 | default=False, 945 | ) 946 | expanded_filters : bpy.props.BoolProperty(default=False) 947 | 948 | class SketchfabBrowserProps(bpy.types.PropertyGroup): 949 | # Search 950 | query : StringProperty( 951 | name="Search", 952 | description="Query to search", 953 | default="" 954 | ) 955 | 956 | pbr : BoolProperty( 957 | name="PBR", 958 | description="Search for PBR model only", 959 | default=False 960 | ) 961 | 962 | categories : EnumProperty( 963 | name="Categories", 964 | items=Config.SKETCHFAB_CATEGORIES, 965 | description="Show only models of category", 966 | default='ALL', 967 | ) 968 | 969 | face_count : EnumProperty( 970 | name="Face Count", 971 | items=Config.SKETCHFAB_FACECOUNT, 972 | description="Determines which meshes are exported", 973 | default='ANY', 974 | ) 975 | 976 | sort_by : EnumProperty( 977 | name="Sort by", 978 | items=get_sorting_options, 979 | description="Sort ", 980 | ) 981 | 982 | animated : BoolProperty( 983 | name="Animated", 984 | description="Show only models with animation", 985 | default=False, 986 | ) 987 | 988 | staffpick : BoolProperty( 989 | name="Staffpick", 990 | description="Show only staffpick models", 991 | default=False, 992 | ) 993 | 994 | search_domain : EnumProperty( 995 | name="Search domain", 996 | items=get_available_search_domains, 997 | description="Search domain ", 998 | ) 999 | 1000 | use_org_profile : BoolProperty( 1001 | name="Use organisation profile", 1002 | description="Import/Export as a member of an organization\nLOL", 1003 | default=False, 1004 | ) 1005 | 1006 | active_org : EnumProperty( 1007 | name="Org", 1008 | items=get_user_orgs, 1009 | description="Active org", 1010 | ) 1011 | 1012 | status : StringProperty(name='status', default='idle') 1013 | 1014 | use_preview : BoolProperty( 1015 | name="Use Preview", 1016 | description="Show results using preview widget instead of regular buttons with thumbnails as icons", 1017 | default=True 1018 | ) 1019 | 1020 | search_results = {} 1021 | current_key : StringProperty(name='current', default='current') 1022 | has_searched_next : BoolProperty(name='next', default=False) 1023 | has_searched_prev : BoolProperty(name='prev', default=False) 1024 | 1025 | skfb_api = SketchfabLoginProps.skfb_api 1026 | custom_icons = bpy.utils.previews.new() 1027 | has_loaded_thumbnails : BoolProperty(default=False) 1028 | 1029 | is_latest_version : IntProperty(default=-1) 1030 | 1031 | import_status : StringProperty(name='import', default='') 1032 | 1033 | manualImportBoolean : BoolProperty( 1034 | name="Import from url", 1035 | description="Import a downloadable model from a url", 1036 | default=False, 1037 | ) 1038 | manualImportPath : StringProperty( 1039 | name="Url", 1040 | description="Paste full model url:\n* https://sketchfab.com/models/mymodel-XXXX\n* https://sketchfab.com/orgs/XXXX/3d-models/mymodel-YYYY", 1041 | default="", 1042 | maxlen=1024, 1043 | options={'TEXTEDIT_UPDATE'}) 1044 | 1045 | 1046 | def list_current_results(self, context): 1047 | skfb = get_sketchfab_props() 1048 | 1049 | # No results: 1050 | if 'current' not in skfb.search_results: 1051 | return preview_collection['default'] 1052 | 1053 | if skfb.has_loaded_thumbnails and 'thumbnails' in preview_collection: 1054 | return preview_collection['thumbnails'] 1055 | 1056 | res = [] 1057 | missing_thumbnail = False 1058 | if 'current' in skfb.search_results and len(skfb.search_results['current']): 1059 | skfb_results = skfb.search_results['current'] 1060 | for i, result in enumerate(skfb_results): 1061 | if result in skfb_results: 1062 | model = skfb_results[result] 1063 | if model.uid in skfb.custom_icons: 1064 | res.append((model.uid, model.title, "", skfb.custom_icons[model.uid].icon_id, i)) 1065 | else: 1066 | res.append((model.uid, model.title, "", preview_collection['skfb']['0'].icon_id, i)) 1067 | missing_thumbnail = True 1068 | else: 1069 | print('Result issue') 1070 | 1071 | # Default element to avoid having an empty preview collection 1072 | if not res: 1073 | res.append(('NORESULTS', 'empty', "", preview_collection['skfb']['0'].icon_id, 0)) 1074 | 1075 | preview_collection['thumbnails'] = tuple(res) 1076 | skfb.has_loaded_thumbnails = not missing_thumbnail 1077 | return preview_collection['thumbnails'] 1078 | 1079 | 1080 | 1081 | 1082 | 1083 | def draw_model_info(layout, model, context): 1084 | ui_model_props = layout.box().column(align=True) 1085 | 1086 | row = ui_model_props.row() 1087 | row.label(text="{}".format(model.title), icon='OBJECT_DATA') 1088 | row.operator("wm.sketchfab_view", text="", icon='LINKED').model_uid = model.uid 1089 | 1090 | ui_model_props.label(text='{}'.format(model.author), icon='ARMATURE_DATA') 1091 | 1092 | if model.license: 1093 | ui_model_props.label(text='{}'.format(model.license), icon='TEXT') 1094 | else: 1095 | ui_model_props.label(text='Fetching..') 1096 | 1097 | if model.vertex_count and model.face_count: 1098 | ui_model_stats = ui_model_props.row() 1099 | ui_model_stats.label(text='Verts: {} | Faces: {}'.format(Utils.humanify_number(model.vertex_count), Utils.humanify_number(model.face_count)), icon='MESH_DATA') 1100 | 1101 | if(model.animated): 1102 | ui_model_props.label(text='Animated: ' + model.animated, icon='ANIM_DATA') 1103 | 1104 | layout.separator() 1105 | 1106 | def draw_import_button(layout, model, context): 1107 | 1108 | import_ops = layout.row() 1109 | skfb = get_sketchfab_props() 1110 | 1111 | import_ops.enabled = skfb.skfb_api.is_user_logged() and bpy.context.mode == 'OBJECT' and Utils.is_valid_uuid(model.uid) 1112 | if not skfb.skfb_api.is_user_logged(): 1113 | downloadlabel = 'Log in to download models' 1114 | elif bpy.context.mode != 'OBJECT': 1115 | downloadlabel = "Import is available only in object mode" 1116 | else: 1117 | downloadlabel = "Import model" 1118 | if model.download_size: 1119 | downloadlabel += " ({})".format(model.download_size) 1120 | if skfb.import_status: 1121 | downloadlabel = skfb.import_status 1122 | 1123 | download_icon = 'IMPORT' if import_ops.enabled else 'INFO' 1124 | import_ops.scale_y = 2.0 1125 | import_ops.operator("wm.sketchfab_download", icon=download_icon, text=downloadlabel, translate=False, emboss=True).model_uid = model.uid 1126 | 1127 | def set_log(log): 1128 | get_sketchfab_props().status = log 1129 | 1130 | def unzip_archive(archive_path): 1131 | if os.path.exists(archive_path): 1132 | set_import_status('Unzipping model') 1133 | import zipfile 1134 | try: 1135 | zip_ref = zipfile.ZipFile(archive_path, 'r') 1136 | extract_dir = os.path.dirname(archive_path) 1137 | zip_ref.extractall(extract_dir) 1138 | zip_ref.close() 1139 | except zipfile.BadZipFile: 1140 | print('Error when dezipping file') 1141 | os.remove(archive_path) 1142 | print('Invaild zip. Try again') 1143 | set_import_status('') 1144 | return None, None 1145 | 1146 | gltf_file = os.path.join(extract_dir, 'scene.gltf') 1147 | return gltf_file, archive_path 1148 | 1149 | else: 1150 | print('ERROR: archive doesn\'t exist') 1151 | 1152 | 1153 | def run_async(func): 1154 | from threading import Thread 1155 | from functools import wraps 1156 | 1157 | @wraps(func) 1158 | def async_func(*args, **kwargs): 1159 | func_hl = Thread(target=func, args=args, kwargs=kwargs) 1160 | func_hl.start() 1161 | return func_hl 1162 | 1163 | return async_func 1164 | 1165 | 1166 | def import_model(gltf_path, uid, title): 1167 | bpy.ops.wm.import_modal('INVOKE_DEFAULT', gltf_path=gltf_path, uid=uid, title=title) 1168 | 1169 | 1170 | def build_search_request(query, pbr, animated, staffpick, face_count, category, sort_by): 1171 | final_query = '&q={}'.format(query) if query else '' 1172 | 1173 | if animated: 1174 | final_query = final_query + '&animated=true' 1175 | 1176 | if staffpick: 1177 | final_query = final_query + '&staffpicked=true' 1178 | 1179 | if sort_by == 'LIKES': 1180 | final_query = final_query + '&sort_by=-likeCount' 1181 | elif sort_by == 'RECENT': 1182 | final_query = final_query + '&sort_by=-publishedAt' 1183 | elif sort_by == 'VIEWS': 1184 | final_query = final_query + '&sort_by=-viewCount' 1185 | 1186 | if face_count == '10K': 1187 | final_query = final_query + '&max_face_count=10000' 1188 | elif face_count == '50K': 1189 | final_query = final_query + '&min_face_count=10000&max_face_count=50000' 1190 | elif face_count == '100K': 1191 | final_query = final_query + '&min_face_count=50000&max_face_count=100000' 1192 | elif face_count == '250K': 1193 | final_query = final_query + "&min_face_count=100000&max_face_count=250000" 1194 | elif face_count == '250KP': 1195 | final_query = final_query + "&min_face_count=250000" 1196 | 1197 | if category != 'ALL': 1198 | final_query = final_query + '&categories={}'.format(category) 1199 | 1200 | if pbr: 1201 | final_query = final_query + '&pbr_type=metalness' 1202 | 1203 | return final_query 1204 | 1205 | 1206 | def parse_results(r, *args, **kwargs): 1207 | 1208 | ongoingSearches.discard(r.url) 1209 | 1210 | skfb = get_sketchfab_props() 1211 | json_data = r.json() 1212 | 1213 | if 'current' in skfb.search_results: 1214 | skfb.search_results['current'].clear() 1215 | del skfb.search_results['current'] 1216 | 1217 | skfb.search_results['current'] = OrderedDict() 1218 | 1219 | for result in list(json_data.get('results', [])): 1220 | 1221 | # Dirty fix to avoid parsing obsolete data 1222 | if 'current' not in skfb.search_results: 1223 | return 1224 | 1225 | uid = result['uid'] 1226 | skfb.search_results['current'][result['uid']] = SketchfabModel(result) 1227 | 1228 | if not os.path.exists(os.path.join(Config.SKETCHFAB_THUMB_DIR, uid) + '.jpeg'): 1229 | skfb.skfb_api.request_thumbnail(result['thumbnails'], uid) 1230 | elif uid not in skfb.custom_icons: 1231 | skfb.custom_icons.load(uid, os.path.join(Config.SKETCHFAB_THUMB_DIR, "{}.jpeg".format(uid)), 'IMAGE') 1232 | 1233 | # Make a request to get the download_size for org and own models 1234 | """ 1235 | model = skfb.search_results['current'][result['uid']] 1236 | if model.download_size is None: 1237 | api = skfb.skfb_api 1238 | def set_download_size(r, *args, **kwargs): 1239 | json_data = r.json() 1240 | print(json_data) 1241 | if 'gltf' in json_data and 'size' in json_data['gltf']: 1242 | model.download_size = Utils.humanify_size(json_data['gltf']['size']) 1243 | requests.get(Utils.build_download_url(uid, api.use_org_profile, api.active_org), headers=api.headers, hooks={'response': set_download_size}) 1244 | """ 1245 | 1246 | if json_data['next']: 1247 | skfb.skfb_api.next_results_url = json_data['next'] 1248 | else: 1249 | skfb.skfb_api.next_results_url = None 1250 | 1251 | if json_data['previous']: 1252 | skfb.skfb_api.prev_results_url = json_data['previous'] 1253 | else: 1254 | skfb.skfb_api.prev_results_url = None 1255 | 1256 | 1257 | class ThumbnailCollector(threading.Thread): 1258 | def __init__(self, url): 1259 | self.url = url 1260 | threading.Thread.__init__(self) 1261 | 1262 | def set_url(self, url): 1263 | self.url = url 1264 | 1265 | def run(self): 1266 | if not self.url: 1267 | return 1268 | requests.get(self.url, stream=True, hooks={'response': self.handle_thumbnail}) 1269 | 1270 | def handle_thumbnail(self, r, *args, **kwargs): 1271 | uid = r.url.split('/')[4] 1272 | if not os.path.exists(Config.SKETCHFAB_THUMB_DIR): 1273 | try: 1274 | os.makedirs(Config.SKETCHFAB_THUMB_DIR) 1275 | except: 1276 | pass 1277 | thumbnail_path = os.path.join(Config.SKETCHFAB_THUMB_DIR, uid) + '.jpeg' 1278 | 1279 | with open(thumbnail_path, "wb") as f: 1280 | total_length = r.headers.get('content-length') 1281 | 1282 | if total_length is None and r.content: 1283 | f.write(r.content) 1284 | else: 1285 | dl = 0 1286 | total_length = int(total_length) 1287 | for data in r.iter_content(chunk_size=4096): 1288 | dl += len(data) 1289 | f.write(data) 1290 | 1291 | thumbnailsProgress.discard(uid) 1292 | 1293 | props = get_sketchfab_props() 1294 | if uid not in props.custom_icons: 1295 | props.custom_icons.load(uid, os.path.join(Config.SKETCHFAB_THUMB_DIR, "{}.jpeg".format(uid)), 'IMAGE') 1296 | 1297 | 1298 | class LoginModal(bpy.types.Operator): 1299 | """Login into your account""" 1300 | bl_idname = "wm.login_modal" 1301 | bl_label = "" 1302 | bl_options = {'INTERNAL'} 1303 | 1304 | is_logging : BoolProperty(default=False) 1305 | error : BoolProperty(default=False) 1306 | error_message : StringProperty(default='') 1307 | 1308 | def execute(self, context): 1309 | return {'FINISHED'} 1310 | 1311 | def handle_mail_login(self, r, *args, **kwargs): 1312 | browser_props = get_sketchfab_props() 1313 | if r.status_code == 200 and 'access_token' in r.json(): 1314 | browser_props.skfb_api.access_token = r.json()['access_token'] 1315 | login_props = get_sketchfab_login_props() 1316 | Cache.save_key('username', login_props.email) 1317 | Cache.save_key('access_token', browser_props.skfb_api.access_token) 1318 | 1319 | browser_props.skfb_api.build_headers() 1320 | set_login_status('INFO', '') 1321 | browser_props.skfb_api.request_user_info() 1322 | 1323 | else: 1324 | if 'error_description' in r.json(): 1325 | set_login_status('ERROR', 'Failed to authenticate: bad login/password') 1326 | else: 1327 | set_login_status('ERROR', 'Failed to authenticate: bad login/password') 1328 | print('Cannot login.\n {}'.format(r.json())) 1329 | 1330 | self.is_logging = False 1331 | 1332 | def handle_token_login(self, api_token): 1333 | browser_props = get_sketchfab_props() 1334 | browser_props.skfb_api.api_token = api_token 1335 | login_props = get_sketchfab_login_props() 1336 | Cache.save_key('api_token', login_props.api_token) 1337 | 1338 | browser_props.skfb_api.build_headers() 1339 | set_login_status('INFO', '') 1340 | browser_props.skfb_api.request_user_info() 1341 | self.is_logging = False 1342 | 1343 | def modal(self, context, event): 1344 | if self.error: 1345 | self.error = False 1346 | set_login_status('ERROR', '{}'.format(self.error_message)) 1347 | return {"FINISHED"} 1348 | 1349 | if self.is_logging: 1350 | set_login_status('FILE_REFRESH', 'Loging in to your Sketchfab account...') 1351 | return {'RUNNING_MODAL'} 1352 | else: 1353 | return {'FINISHED'} 1354 | 1355 | def invoke(self, context, event): 1356 | self.is_logging = True 1357 | try: 1358 | context.window_manager.modal_handler_add(self) 1359 | login_props = get_sketchfab_login_props() 1360 | if(login_props.use_mail): 1361 | data = { 1362 | 'grant_type': 'password', 1363 | 'client_id': Config.CLIENTID, 1364 | 'username': login_props.email, 1365 | 'password': login_props.password, 1366 | } 1367 | requests.post(Config.SKETCHFAB_OAUTH, data=data, hooks={'response': self.handle_mail_login}) 1368 | else: 1369 | self.handle_token_login(login_props.api_token) 1370 | except Exception as e: 1371 | self.error = True 1372 | self.error_message = str(e) 1373 | 1374 | return {'RUNNING_MODAL'} 1375 | 1376 | 1377 | class ImportModalOperator(bpy.types.Operator): 1378 | """Imports the selected model into Blender""" 1379 | bl_idname = "wm.import_modal" 1380 | bl_label = "Import glTF model into Sketchfab" 1381 | bl_options = {'INTERNAL'} 1382 | 1383 | gltf_path : StringProperty() 1384 | uid : StringProperty() 1385 | title: StringProperty() 1386 | 1387 | def execute(self, context): 1388 | print('IMPORT') 1389 | return {'FINISHED'} 1390 | 1391 | def modal(self, context, event): 1392 | if bpy.context.scene.render.engine not in ["CYCLES", "BLENDER_EEVEE"]: 1393 | bpy.context.scene.render.engine = "BLENDER_EEVEE" 1394 | try: 1395 | old_objects = [o.name for o in bpy.data.objects] # Get the current objects inorder to find the new node hierarchy 1396 | bpy.ops.import_scene.gltf(filepath=self.gltf_path) 1397 | set_import_status('') 1398 | Utils.clean_downloaded_model_dir(self.uid) 1399 | Utils.clean_node_hierarchy([o for o in bpy.data.objects if o.name not in old_objects], self.title) 1400 | return {'FINISHED'} 1401 | except Exception: 1402 | import traceback 1403 | print(traceback.format_exc()) 1404 | set_import_status('') 1405 | return {'FINISHED'} 1406 | 1407 | def invoke(self, context, event): 1408 | context.window_manager.modal_handler_add(self) 1409 | set_import_status('Importing...') 1410 | return {'RUNNING_MODAL'} 1411 | 1412 | 1413 | class GetRequestThread(threading.Thread): 1414 | def __init__(self, url, callback, headers={}): 1415 | self.url = url 1416 | self.callback = callback 1417 | self.headers = headers 1418 | threading.Thread.__init__(self) 1419 | 1420 | def run(self): 1421 | requests.get(self.url, headers=self.headers, hooks={'response': self.callback}) 1422 | 1423 | 1424 | class View3DPanel: 1425 | bl_space_type = 'VIEW_3D' 1426 | bl_region_type = 'TOOLS' if bpy.app.version < (2, 80, 0) else 'UI' 1427 | bl_category = 'Sketchfab' 1428 | bl_context = 'objectmode' 1429 | 1430 | class SketchfabPanel(View3DPanel, bpy.types.Panel): 1431 | bl_options = {'DEFAULT_CLOSED'} 1432 | bl_idname = "VIEW3D_PT_sketchfab_about" 1433 | bl_label = "About" 1434 | 1435 | @classmethod 1436 | def poll(cls, context): 1437 | return (context.scene is not None) 1438 | 1439 | def draw(self, context): 1440 | 1441 | skfb = get_sketchfab_props() 1442 | 1443 | if skfb.is_latest_version == 1: 1444 | self.bl_label = "Sketchfab plugin v{} (up-to-date)".format(PLUGIN_VERSION) 1445 | elif skfb.is_latest_version == 0: 1446 | self.bl_label = "Sketchfab plugin v{} (outdated)".format(PLUGIN_VERSION) 1447 | self.layout.operator('wm.skfb_new_version', text='New version available', icon='ERROR') 1448 | elif skfb.is_latest_version == -2: 1449 | self.bl_label = "Sketchfab plugin v{}".format(PLUGIN_VERSION) 1450 | 1451 | # External links 1452 | #doc_ui = self.layout.row() 1453 | self.layout.operator('wm.skfb_help', text='Documentation', icon='QUESTION') 1454 | self.layout.operator('wm.skfb_report_issue', text='Report an issue', icon='ERROR') 1455 | self.layout.label(text="Download folder:") 1456 | self.layout.label(text=" " + Config.SKETCHFAB_TEMP_DIR) 1457 | 1458 | class LoginPanel(View3DPanel, bpy.types.Panel): 1459 | bl_idname = "VIEW3D_PT_sketchfab_login" 1460 | bl_label = "Activation / Log in" 1461 | #bl_parent_id = "VIEW3D_PT_sketchfab" 1462 | 1463 | is_logged = BoolProperty() 1464 | 1465 | def draw(self, context): 1466 | global is_plugin_enabled 1467 | if not is_plugin_enabled: 1468 | self.layout.operator('wm.skfb_enable', text='Activate add-on', icon="LINKED").enable = True 1469 | else: 1470 | # LOGIN 1471 | skfb_login = get_sketchfab_login_props() 1472 | layout = self.layout.box().column(align=True) 1473 | layout.enabled = get_plugin_enabled() 1474 | if skfb_login.skfb_api.is_user_logged(): 1475 | login_row = layout.row() 1476 | login_row.label(text='Logged in as {}'.format(skfb_login.skfb_api.get_user_info())) 1477 | login_row.operator('wm.sketchfab_login', text='Logout', icon='DISCLOSURE_TRI_RIGHT').authenticate = False 1478 | if skfb_login.status: 1479 | layout.prop(skfb_login, 'status', icon=skfb_login.status_type) 1480 | else: 1481 | layout.label(text="Login to your Sketchfab account", icon='INFO') 1482 | layout.prop(skfb_login, "use_mail") 1483 | if skfb_login.use_mail: 1484 | layout.prop(skfb_login, "email") 1485 | layout.prop(skfb_login, "password") 1486 | else: 1487 | layout.prop(skfb_login, "api_token") 1488 | ops_row = layout.row() 1489 | ops_row.operator('wm.sketchfab_signup', text='Create an account', icon='PLUS') 1490 | login_icon = "LINKED" if bpy.app.version < (2,80,0) else "USER" 1491 | ops_row.operator('wm.sketchfab_login', text='Log in', icon=login_icon).authenticate = True 1492 | if skfb_login.status: 1493 | layout.prop(skfb_login, 'status', icon=skfb_login.status_type) 1494 | 1495 | class TeamsPanel(View3DPanel, bpy.types.Panel): 1496 | bl_idname = "VIEW3D_PT_sketchfab_teams" 1497 | bl_label = "Sketchfab for Teams" 1498 | bl_options = {'DEFAULT_CLOSED'} 1499 | 1500 | def draw(self, context): 1501 | 1502 | skfb = get_sketchfab_props() 1503 | api = skfb.skfb_api 1504 | 1505 | self.layout.enabled = get_plugin_enabled() and api.is_user_logged() 1506 | 1507 | if not api.user_has_orgs: 1508 | self.layout.label(text="You are not part of an organization", icon='INFO') 1509 | self.layout.operator("wm.url_open", text='Learn about Sketchfab for Teams').url = "https://sketchfab.com/features/teams" 1510 | else: 1511 | props = get_sketchfab_props_proxy() 1512 | 1513 | use_org_profile_row = self.layout.row() 1514 | use_org_profile_row.prop(props, "use_org_profile") 1515 | 1516 | org_row = self.layout.row() 1517 | org_row.prop(props, "active_org") 1518 | org_row.enabled = skfb.skfb_api.use_org_profile 1519 | 1520 | pprops = get_sketchfab_props() 1521 | 1522 | class Model: 1523 | def __init__(self, _uid): 1524 | self.uid = _uid 1525 | self.download_size = 0 1526 | 1527 | class SketchfabBrowse(View3DPanel, bpy.types.Panel): 1528 | bl_idname = "VIEW3D_PT_sketchfab_browse" 1529 | bl_label = "Import" 1530 | 1531 | uid = '' 1532 | label = "Search results" 1533 | 1534 | def draw_search(self, layout, context): 1535 | prop = get_sketchfab_props() 1536 | props = get_sketchfab_props_proxy() 1537 | skfb_api = prop.skfb_api 1538 | 1539 | # Add an option to import from url or uid 1540 | col = layout.box().column(align=True) 1541 | row = col.row() 1542 | row.prop(prop, "manualImportBoolean") 1543 | 1544 | if prop.manualImportBoolean: 1545 | row = col.row() 1546 | row.prop(prop, "manualImportPath") 1547 | 1548 | else: 1549 | col = layout.box().column(align=True) 1550 | ro = col.row() 1551 | ro.label(text="Search") 1552 | domain_col = ro.column() 1553 | domain_col.scale_x = 1.5 1554 | domain_col.enabled = skfb_api.is_user_logged() 1555 | domain_col.prop(props, "search_domain") 1556 | 1557 | ro = col.row() 1558 | ro.scale_y = 1.25 1559 | ro.prop(props, "query") 1560 | ro.operator("wm.sketchfab_search", text="", icon='VIEWZOOM') 1561 | 1562 | # User selected own models but is not pro 1563 | if props.search_domain == "OWN" and skfb_api.is_user_logged() and not skfb_api.is_user_pro(): 1564 | col.label(text='A PRO account is required', icon='QUESTION') 1565 | col.label(text='to access your personal library') 1566 | 1567 | # Display a collapsible box for filters 1568 | col = layout.box().column(align=True) 1569 | col.enabled = (props.search_domain != "STORE") 1570 | row = col.row() 1571 | row.prop(props, "expanded_filters", icon="TRIA_DOWN" if props.expanded_filters else "TRIA_RIGHT", icon_only=True, emboss=False) 1572 | row.label(text="Search filters") 1573 | if props.expanded_filters: 1574 | if props.search_domain in ["DEFAULT", "OWN"]: 1575 | col.separator() 1576 | col.prop(props, "categories") 1577 | col.prop(props, "sort_by") 1578 | col.prop(props, "face_count") 1579 | row = col.row() 1580 | row.prop(props, "pbr") 1581 | row.prop(props, "staffpick") 1582 | row.prop(props, "animated") 1583 | else: 1584 | col.separator() 1585 | col.prop(props, "sort_by") 1586 | col.prop(props, "face_count") 1587 | 1588 | pprops = get_sketchfab_props() 1589 | 1590 | def draw_results(self, layout, context): 1591 | 1592 | props = get_sketchfab_props() 1593 | 1594 | col = layout.box().column(align=True) 1595 | 1596 | if not props.manualImportBoolean: 1597 | 1598 | #results = layout.column(align=True) 1599 | col.label(text=self.label) 1600 | 1601 | model = None 1602 | 1603 | result_pages_ops = col.row() 1604 | if props.skfb_api.prev_results_url: 1605 | result_pages_ops.operator("wm.sketchfab_search_prev", text="Previous page", icon='FRAME_PREV') 1606 | 1607 | if props.skfb_api.next_results_url: 1608 | result_pages_ops.operator("wm.sketchfab_search_next", text="Next page", icon='FRAME_NEXT') 1609 | 1610 | #result_label = 'Click below to see more results' 1611 | #col.label(text=result_label, icon='INFO') 1612 | try: 1613 | col.template_icon_view(bpy.context.window_manager, 'result_previews', show_labels=True, scale=8) 1614 | except Exception: 1615 | print('ResultsPanel: Failed to display results') 1616 | pass 1617 | 1618 | if 'current' not in props.search_results or not len(props.search_results['current']): 1619 | self.label = 'No results' 1620 | return 1621 | else: 1622 | self.label = "Search results" 1623 | 1624 | if "current" in props.search_results: 1625 | 1626 | if bpy.context.window_manager.result_previews not in props.search_results['current']: 1627 | return 1628 | 1629 | model = props.search_results['current'][bpy.context.window_manager.result_previews] 1630 | 1631 | if not model: 1632 | return 1633 | 1634 | if self.uid != model.uid: 1635 | self.uid = model.uid 1636 | 1637 | if not model.info_requested: 1638 | props.skfb_api.request_model_info(model.uid) 1639 | model.info_requested = True 1640 | 1641 | draw_model_info(col, model, context) 1642 | draw_import_button(col, model, context) 1643 | else: 1644 | uid = "" 1645 | if "sketchfab.com" in props.manualImportPath: 1646 | uid = props.manualImportPath[-32:] 1647 | m = Model(uid) 1648 | draw_import_button(col, m, context) 1649 | 1650 | def draw(self, context): 1651 | self.layout.enabled = get_plugin_enabled() 1652 | self.draw_search(self.layout, context) 1653 | self.draw_results(self.layout, context) 1654 | 1655 | def invoke(self, context, event): 1656 | wm = context.window_manager 1657 | return wm.invoke_props_dialog(self, width=900, height=850) 1658 | 1659 | class SketchfabExportPanel(View3DPanel, bpy.types.Panel): 1660 | #bl_idname = "wm.sketchfab_export" if bpy.app.version == (2, 79, 0) else "VIEW3D_PT_sketchfab_export" 1661 | bl_options = {'DEFAULT_CLOSED'} 1662 | bl_label = "Export" 1663 | bl_idname = "VIEW3D_PT_sketchfab_export" 1664 | 1665 | def draw(self, context): 1666 | 1667 | api = get_sketchfab_props().skfb_api 1668 | self.layout.enabled = get_plugin_enabled() and api.is_user_logged() 1669 | 1670 | wm = context.window_manager 1671 | props = wm.sketchfab_export 1672 | 1673 | layout = self.layout 1674 | 1675 | # Selection only 1676 | layout.prop(props, "selection") 1677 | 1678 | # Model properties 1679 | col = layout.box().column(align=True) 1680 | if not props.reuploadBoolean: 1681 | col.prop(props, "title") 1682 | col.prop(props, "description") 1683 | col.prop(props, "tags") 1684 | col.prop(props, "draft") 1685 | col.prop(props, "private") 1686 | if props.private: 1687 | col.prop(props, "password") 1688 | col.prop(props, "reuploadBoolean") 1689 | if props.reuploadBoolean: 1690 | col.prop(props, "reuploadPath") 1691 | 1692 | # Project selection if member of an org 1693 | if api.active_org and api.use_org_profile: 1694 | row = layout.row() 1695 | row.prop(props, "active_project") 1696 | 1697 | # Upload button 1698 | row = layout.row() 1699 | row.scale_y = 2.0 1700 | upload_label = "Reupload" if props.reuploadBoolean else "Upload" 1701 | upload_icon = "EXPORT" 1702 | upload_enabled = api.is_user_logged() and bpy.context.mode == 'OBJECT' 1703 | if not upload_enabled: 1704 | if not api.is_user_logged(): 1705 | upload_label = "Log in to upload models" 1706 | elif bpy.context.mode != 'OBJECT': 1707 | upload_label = "Export is only available in object mode" 1708 | if sf_state.uploading: 1709 | upload_label = "Uploading %s" % sf_state.size_label 1710 | upload_icon = "SORTTIME" 1711 | row.operator("wm.sketchfab_export", icon=upload_icon, text=upload_label) 1712 | 1713 | model_url = sf_state.model_url 1714 | if model_url: 1715 | layout.operator("wm.url_open", text="View Online Model", icon='URL').url = model_url 1716 | 1717 | class SketchfabLogger(bpy.types.Operator): 1718 | """Log in / out your Sketchab.com account""" 1719 | bl_idname = 'wm.sketchfab_login' 1720 | bl_label = 'Sketchfab Login' 1721 | bl_options = {'INTERNAL'} 1722 | 1723 | authenticate : BoolProperty(default=True) 1724 | 1725 | def execute(self, context): 1726 | set_login_status('FILE_REFRESH', 'Login to your Sketchfab account...') 1727 | wm = context.window_manager 1728 | if self.authenticate: 1729 | wm.sketchfab_browser.skfb_api.login(wm.sketchfab_api.email, wm.sketchfab_api.password, wm.sketchfab_api.api_token) 1730 | else: 1731 | wm.sketchfab_browser.skfb_api.logout() 1732 | wm.sketchfab_api.password = '' 1733 | wm.sketchfab_api.last_password = "default" 1734 | set_login_status('FILE_REFRESH', '') 1735 | return {'FINISHED'} 1736 | 1737 | 1738 | class SketchfabModel: 1739 | def __init__(self, json_data): 1740 | self.title = str(json_data['name']) 1741 | self.author = json_data['user']['displayName'] 1742 | self.username = json_data['user']['username'] 1743 | self.uid = json_data['uid'] 1744 | self.vertex_count = json_data['vertexCount'] 1745 | self.face_count = json_data['faceCount'] 1746 | 1747 | if 'archives' in json_data and 'gltf' in json_data['archives']: 1748 | if 'size' in json_data['archives']['gltf'] and json_data['archives']['gltf']['size']: 1749 | self.download_size = Utils.humanify_size(json_data['archives']['gltf']['size']) 1750 | else: 1751 | self.download_size = None 1752 | 1753 | self.thumbnail_url = os.path.join(Config.SKETCHFAB_THUMB_DIR, '{}.jpeg'.format(self.uid)) 1754 | 1755 | # Model info request 1756 | self.info_requested = False 1757 | self.license = None 1758 | self.animated = False 1759 | 1760 | # Download url data 1761 | self.download_url = None 1762 | self.time_url_requested = None 1763 | self.url_expires = None 1764 | 1765 | def ShowMessage(icon = "INFO", title = "Info", message = "Information"): 1766 | def draw(self, context): 1767 | self.layout.label(text=message) 1768 | print("\n{}: {}".format(icon, message)) 1769 | bpy.context.window_manager.popup_menu(draw, title = title, icon = icon) 1770 | 1771 | 1772 | class SketchfabDownloadModel(bpy.types.Operator): 1773 | """Import the selected model""" 1774 | bl_idname = "wm.sketchfab_download" 1775 | bl_label = "Downloading" 1776 | bl_options = {'INTERNAL'} 1777 | 1778 | model_uid : bpy.props.StringProperty(name="uid") 1779 | 1780 | def execute(self, context): 1781 | skfb_api = context.window_manager.sketchfab_browser.skfb_api 1782 | skfb_api.download_model(self.model_uid) 1783 | return {'FINISHED'} 1784 | 1785 | 1786 | class ViewOnSketchfab(bpy.types.Operator): 1787 | """Upload your model to Sketchfab""" 1788 | bl_idname = "wm.sketchfab_view" 1789 | bl_label = "View the model on Sketchfab" 1790 | bl_options = {'INTERNAL'} 1791 | 1792 | model_uid : bpy.props.StringProperty(name="uid") 1793 | 1794 | def execute(self, context): 1795 | import webbrowser 1796 | webbrowser.open('{}/models/{}'.format(Config.SKETCHFAB_URL, self.model_uid)) 1797 | return {'FINISHED'} 1798 | 1799 | 1800 | def clear_search(): 1801 | skfb = get_sketchfab_props() 1802 | skfb.has_loaded_thumbnails = False 1803 | skfb.search_results.clear() 1804 | skfb.custom_icons.clear() 1805 | bpy.data.window_managers['WinMan']['result_previews'] = 0 1806 | 1807 | 1808 | class SketchfabSearch(bpy.types.Operator): 1809 | """Send a search query to Sketchfab 1810 | Searches on the selected domain (all site, own models for PRO+ users, organization...) 1811 | and takes into accounts various search filters""" 1812 | bl_idname = "wm.sketchfab_search" 1813 | bl_label = "Search Sketchfab" 1814 | bl_options = {'INTERNAL'} 1815 | 1816 | def execute(self, context): 1817 | # prepare request for search 1818 | clear_search() 1819 | skfb = get_sketchfab_props() 1820 | skfb.skfb_api.prev_results_url = None 1821 | skfb.skfb_api.next_results_url = None 1822 | final_query = build_search_request(skfb.query, skfb.pbr, skfb.animated, skfb.staffpick, skfb.face_count, skfb.categories, skfb.sort_by) 1823 | skfb.skfb_api.search(final_query, parse_results) 1824 | return {'FINISHED'} 1825 | 1826 | 1827 | class SketchfabSearchNextResults(bpy.types.Operator): 1828 | """Loads the next batch of 24 models from the search results""" 1829 | bl_idname = "wm.sketchfab_search_next" 1830 | bl_label = "Search Sketchfab" 1831 | bl_options = {'INTERNAL'} 1832 | 1833 | def execute(self, context): 1834 | # prepare request for search 1835 | clear_search() 1836 | skfb_api = get_sketchfab_props().skfb_api 1837 | skfb_api.search_cursor(skfb_api.next_results_url, parse_results) 1838 | return {'FINISHED'} 1839 | 1840 | 1841 | class SketchfabSearchPreviousResults(bpy.types.Operator): 1842 | """Loads the previous batch of 24 models from the search results""" 1843 | bl_idname = "wm.sketchfab_search_prev" 1844 | bl_label = "Search Sketchfab" 1845 | bl_options = {'INTERNAL'} 1846 | 1847 | def execute(self, context): 1848 | # prepare request for search 1849 | clear_search() 1850 | skfb_api = get_sketchfab_props().skfb_api 1851 | skfb_api.search_cursor(skfb_api.prev_results_url, parse_results) 1852 | return {'FINISHED'} 1853 | 1854 | class SketchfabCreateAccount(bpy.types.Operator): 1855 | """Create an account on sketchfab.com""" 1856 | bl_idname = "wm.sketchfab_signup" 1857 | bl_label = "Sketchfab" 1858 | bl_options = {'INTERNAL'} 1859 | 1860 | def execute(self, context): 1861 | import webbrowser 1862 | webbrowser.open(Config.SKETCHFAB_SIGNUP) 1863 | return {'FINISHED'} 1864 | 1865 | 1866 | class SketchfabNewVersion(bpy.types.Operator): 1867 | """Opens addon latest available release on github""" 1868 | bl_idname = "wm.skfb_new_version" 1869 | bl_label = "Sketchfab" 1870 | bl_options = {'INTERNAL'} 1871 | 1872 | def execute(self, context): 1873 | import webbrowser 1874 | webbrowser.open('{}/releases/latest'.format(Config.GITHUB_REPOSITORY_URL)) 1875 | return {'FINISHED'} 1876 | 1877 | 1878 | class SketchfabReportIssue(bpy.types.Operator): 1879 | """Open an issue on github tracker""" 1880 | bl_idname = "wm.skfb_report_issue" 1881 | bl_label = "Sketchfab" 1882 | bl_options = {'INTERNAL'} 1883 | 1884 | def execute(self, context): 1885 | import webbrowser 1886 | webbrowser.open(Config.SKETCHFAB_REPORT_URL) 1887 | return {'FINISHED'} 1888 | 1889 | 1890 | class SketchfabHelp(bpy.types.Operator): 1891 | """Opens the addon README on github""" 1892 | bl_idname = "wm.skfb_help" 1893 | bl_label = "Sketchfab" 1894 | bl_options = {'INTERNAL'} 1895 | 1896 | def execute(self, context): 1897 | import webbrowser 1898 | webbrowser.open('{}/releases/latest'.format(Config.GITHUB_REPOSITORY_URL)) 1899 | return {'FINISHED'} 1900 | 1901 | 1902 | def activate_plugin(): 1903 | props = get_sketchfab_props() 1904 | login = get_sketchfab_login_props() 1905 | 1906 | # Fill login/access_token 1907 | cache_data = Cache.read() 1908 | if 'username' in cache_data: 1909 | login.email = cache_data['username'] 1910 | 1911 | if 'access_token' in cache_data: 1912 | props.skfb_api.access_token = cache_data['access_token'] 1913 | props.skfb_api.build_headers() 1914 | props.skfb_api.request_user_info() 1915 | props.skfb_api.use_mail = True 1916 | elif 'api_token' in cache_data: 1917 | props.skfb_api.api_token = cache_data['api_token'] 1918 | props.skfb_api.build_headers() 1919 | props.skfb_api.request_user_info() 1920 | props.skfb_api.use_mail = False 1921 | 1922 | global is_plugin_enabled 1923 | is_plugin_enabled = True 1924 | 1925 | try: 1926 | requests.get(Config.SKETCHFAB_PLUGIN_VERSION, hooks={'response': check_plugin_version}) 1927 | except Exception as e: 1928 | print('Error when checking for version: {}'.format(e)) 1929 | 1930 | run_default_search() 1931 | 1932 | 1933 | class SketchfabEnable(bpy.types.Operator): 1934 | """Activate the addon (checks login, cache folders...)""" 1935 | bl_idname = "wm.skfb_enable" 1936 | bl_label = "Sketchfab" 1937 | bl_options = {'INTERNAL'} 1938 | 1939 | enable : BoolProperty(default=True) 1940 | def execute(self, context): 1941 | if self.enable: 1942 | activate_plugin() 1943 | 1944 | return {'FINISHED'} 1945 | 1946 | 1947 | class SketchfabExportProps(bpy.types.PropertyGroup): 1948 | description : StringProperty( 1949 | name="Description", 1950 | description="Description of the model (optional)", 1951 | default="", 1952 | maxlen=1024) 1953 | filepath : StringProperty( 1954 | name="Filepath", 1955 | description="internal use", 1956 | default="", 1957 | ) 1958 | selection : BoolProperty( 1959 | name="Selection only", 1960 | description="Determines which meshes are exported", 1961 | default=False, 1962 | ) 1963 | private : BoolProperty( 1964 | name="Private", 1965 | description="Upload as private (requires a pro account)", 1966 | default=False, 1967 | ) 1968 | draft : BoolProperty( 1969 | name="Draft", 1970 | description="Do not publish the model", 1971 | default=True, 1972 | ) 1973 | password : StringProperty( 1974 | name="Password", 1975 | description="Password-protect your model (requires a pro account)", 1976 | default="", 1977 | ) 1978 | tags : StringProperty( 1979 | name="Tags", 1980 | description="List of tags (42 max), separated by spaces (optional)", 1981 | default="", 1982 | ) 1983 | title : StringProperty( 1984 | name="Title", 1985 | description="Title of the model (determined automatically if left empty)", 1986 | default="", 1987 | maxlen=48 1988 | ) 1989 | reuploadBoolean : BoolProperty( 1990 | name="Reupload", 1991 | description="Reupload the model over an existing one", 1992 | default=False, 1993 | ) 1994 | reuploadPath : StringProperty( 1995 | name="Url", 1996 | description="Paste full model url to reupload to", 1997 | default="", 1998 | maxlen=1024) 1999 | active_project : EnumProperty( 2000 | name="Project", 2001 | items=get_org_projects, 2002 | description="Active project", 2003 | update=refresh_orgs 2004 | ) 2005 | 2006 | 2007 | class _SketchfabState: 2008 | """Singleton to store state""" 2009 | __slots__ = ( 2010 | "uploading", 2011 | "size_label", 2012 | "model_url", 2013 | "report_message", 2014 | "report_type", 2015 | ) 2016 | 2017 | def __init__(self): 2018 | self.uploading = False 2019 | self.size_label = "" 2020 | self.model_url = "" 2021 | self.report_message = "" 2022 | self.report_type = '' 2023 | 2024 | sf_state = _SketchfabState() 2025 | del _SketchfabState 2026 | 2027 | # remove file copy 2028 | def terminate(filepath): 2029 | print(filepath) 2030 | os.remove(filepath) 2031 | os.rmdir(os.path.dirname(filepath)) 2032 | 2033 | def upload_report(report_message, report_type): 2034 | sf_state.report_message = report_message 2035 | sf_state.report_type = report_type 2036 | 2037 | # upload the blend-file to sketchfab 2038 | def upload(filepath, filename): 2039 | 2040 | props = get_sketchfab_props() 2041 | api = props.skfb_api 2042 | 2043 | wm = bpy.context.window_manager 2044 | props = wm.sketchfab_export 2045 | 2046 | title = props.title 2047 | if not title: 2048 | title = os.path.splitext(os.path.basename(bpy.data.filepath))[0] 2049 | 2050 | # Limit the number of tags to 42 2051 | props.tags = " ".join(props.tags.split(" ")[:42]) 2052 | 2053 | _data = { 2054 | "name": title, 2055 | "description": props.description, 2056 | "tags": props.tags, 2057 | "private": props.private, 2058 | "isPublished": not props.draft, 2059 | "password": props.password, 2060 | "source": "blender-exporter", 2061 | } 2062 | 2063 | _files = { 2064 | "modelFile": open(filepath, 'rb'), 2065 | } 2066 | 2067 | _headers = api.headers 2068 | 2069 | uploadUrl = "" 2070 | modelUid = "" 2071 | requestFunction = requests.post 2072 | 2073 | # Are we reuploading ? 2074 | if props.reuploadBoolean: 2075 | 2076 | requestFunction = requests.put 2077 | 2078 | if "sketchfab.com/" not in props.reuploadPath: 2079 | return upload_report("reupload url is malformed %s" % props.reuploadPath, 'ERROR') 2080 | 2081 | # Get the model uid 2082 | try: 2083 | modelUid = props.reuploadPath[-32:] 2084 | if not Utils.is_valid_uuid(modelUid): 2085 | return upload_report("reupload url does not end with a valid uid (32 characters string): %s" % props.reuploadPath, 'ERROR') 2086 | except: 2087 | return upload_report("reupload url is malformed %s" % props.reuploadPath, 'ERROR') 2088 | 2089 | # If the model is in an org, find if the user has access to it 2090 | if "/orgs/" in props.reuploadPath: 2091 | if True:#try: 2092 | orgName = props.reuploadPath.split("/orgs/")[1].split("/")[0] 2093 | user_orgs = api.user_orgs 2094 | orgUid = "" 2095 | for org in user_orgs: 2096 | if org["username"] == orgName: 2097 | orgUid = org["uid"] 2098 | break 2099 | if orgUid: 2100 | uploadUrl = '{}/{}/models/{}'.format(Config.SKETCHFAB_ORGS, orgUid, modelUid) 2101 | else: 2102 | return upload_report("User does not appear to belong to org %s" % (orgName), 'ERROR') 2103 | else:#aexcept: 2104 | return upload_report("Cannot parse the org name from the url %s" % props.reuploadPath, 'ERROR') 2105 | # Otherwise, request a direct reupload 2106 | else: 2107 | uploadUrl = '{}/{}'.format(Config.SKETCHFAB_MODEL, modelUid) 2108 | 2109 | _data = { 2110 | "uid" : modelUid, 2111 | "source": "blender-exporter" 2112 | } 2113 | 2114 | else: 2115 | 2116 | # Org or not 2117 | if api.user_has_orgs and api.use_org_profile: 2118 | uploadUrl = "%s/%s/models" % (Config.SKETCHFAB_ORGS, api.active_org["uid"]) 2119 | _data["orgProject"] = props.active_project 2120 | else: 2121 | uploadUrl = Config.SKETCHFAB_MODEL 2122 | 2123 | # Upload and parse the result 2124 | try: 2125 | print("Uploading to %s" % uploadUrl) 2126 | r = requestFunction( 2127 | uploadUrl, 2128 | data = _data, 2129 | files = _files, 2130 | headers = _headers 2131 | ) 2132 | except requests.exceptions.RequestException as e: 2133 | return upload_report("Upload failed. Error: %s" % str(e), 'WARNING') 2134 | 2135 | if r.status_code not in [requests.codes.ok, requests.codes.created, requests.codes.no_content]: 2136 | return upload_report("Upload failed. Error code: %s\nMessage:\n%s" % (str(r.status_code), str(r)), 'WARNING') 2137 | else: 2138 | try: 2139 | result = r.json() 2140 | sf_state.model_url = Config.SKETCHFAB_URL + "/models/" + result["uid"] 2141 | except: 2142 | sf_state.model_url = Config.SKETCHFAB_URL + "/models/" + modelUid 2143 | return upload_report("Upload complete. Available on your sketchfab.com dashboard.", 'INFO') 2144 | 2145 | 2146 | class ExportSketchfab(bpy.types.Operator): 2147 | """Upload your model to Sketchfab""" 2148 | bl_idname = "wm.sketchfab_export" 2149 | bl_label = "Upload" 2150 | 2151 | _timer = None 2152 | _thread = None 2153 | 2154 | def modal(self, context, event): 2155 | if event.type == 'TIMER': 2156 | if not self._thread.is_alive(): 2157 | wm = context.window_manager 2158 | props = wm.sketchfab_export 2159 | 2160 | terminate(props.filepath) 2161 | 2162 | if context.area: 2163 | context.area.tag_redraw() 2164 | 2165 | # forward message from upload thread 2166 | if not sf_state.report_type: 2167 | sf_state.report_type = 'ERROR' 2168 | self.report({sf_state.report_type}, sf_state.report_message) 2169 | 2170 | wm.event_timer_remove(self._timer) 2171 | self._thread.join() 2172 | sf_state.uploading = False 2173 | return {'FINISHED'} 2174 | 2175 | return {'PASS_THROUGH'} 2176 | 2177 | def execute(self, context): 2178 | 2179 | if sf_state.uploading: 2180 | self.report({'WARNING'}, "Please wait till current upload is finished") 2181 | return {'CANCELLED'} 2182 | 2183 | wm = context.window_manager 2184 | props = wm.sketchfab_export 2185 | sf_state.model_url = "" 2186 | 2187 | # Prepare to save the file 2188 | binary_path = bpy.app.binary_path 2189 | script_path = os.path.dirname(os.path.realpath(__file__)) 2190 | basename, ext = os.path.splitext(bpy.data.filepath) 2191 | if not basename: 2192 | basename = os.path.join(basename, "temp") 2193 | if not ext: 2194 | ext = ".blend" 2195 | tempdir = tempfile.mkdtemp() 2196 | filepath = os.path.join(tempdir, "export-sketchfab" + ext) 2197 | 2198 | SKETCHFAB_EXPORT_DATA_FILE = os.path.join(tempdir, "export-sketchfab.json") 2199 | 2200 | try: 2201 | # save a copy of actual scene but don't interfere with the users models 2202 | bpy.ops.wm.save_as_mainfile(filepath=filepath, compress=True, copy=True) 2203 | 2204 | with open(SKETCHFAB_EXPORT_DATA_FILE, 'w') as s: 2205 | json.dump({ 2206 | "selection": props.selection, 2207 | }, s) 2208 | 2209 | subprocess.check_call([ 2210 | binary_path, 2211 | "--background", 2212 | "-noaudio", 2213 | filepath, 2214 | "--python", os.path.join(script_path, "pack_for_export.py"), 2215 | "--", tempdir 2216 | ]) 2217 | 2218 | os.remove(filepath) 2219 | 2220 | # read subprocess call results 2221 | with open(SKETCHFAB_EXPORT_DATA_FILE, 'r') as s: 2222 | r = json.load(s) 2223 | size = r["size"] 2224 | props.filepath = r["filepath"] 2225 | filename = r["filename"] 2226 | 2227 | os.remove(SKETCHFAB_EXPORT_DATA_FILE) 2228 | 2229 | except Exception as e: 2230 | self.report({'WARNING'}, "Error occured while preparing your file: %s" % str(e)) 2231 | return {'FINISHED'} 2232 | 2233 | # Check the generated file size against the user plans, to know if the upload will succeed 2234 | upload_limit = Config.SKETCHFAB_UPLOAD_LIMITS[get_sketchfab_props().skfb_api.plan_type] 2235 | if get_sketchfab_props().skfb_api.use_org_profile: 2236 | upload_limit = Config.SKETCHFAB_UPLOAD_LIMITS["ent"] 2237 | if size > upload_limit: 2238 | human_size_limit = Utils.humanify_size(upload_limit) 2239 | human_exported_size = Utils.humanify_size(size) 2240 | self.report({'ERROR'}, "Upload size is above your plan upload limit: %s > %s" % (human_exported_size, human_size_limit)) 2241 | return {'FINISHED'} 2242 | 2243 | sf_state.uploading = True 2244 | sf_state.size_label = Utils.humanify_size(size) 2245 | self._thread = threading.Thread( 2246 | target=upload, 2247 | args=(props.filepath, filename), 2248 | ) 2249 | self._thread.start() 2250 | 2251 | wm.modal_handler_add(self) 2252 | self._timer = wm.event_timer_add(1.0, window=context.window) 2253 | 2254 | return {'RUNNING_MODAL'} 2255 | 2256 | def cancel(self, context): 2257 | wm = context.window_manager 2258 | wm.event_timer_remove(self._timer) 2259 | self._thread.join() 2260 | 2261 | def get_temporary_path(): 2262 | 2263 | # Get the preferences cache directory 2264 | cachePath = bpy.context.preferences.addons[__name__.split('.')[0]].preferences.cachePath 2265 | 2266 | # The cachePath was set in the preferences 2267 | if cachePath: 2268 | return cachePath 2269 | else: 2270 | # Rely on Blender temporary directory 2271 | if bpy.app.version == (2, 79, 0): 2272 | if bpy.context.user_preferences.filepaths.temporary_directory: 2273 | return bpy.context.user_preferences.filepaths.temporary_directory 2274 | else: 2275 | return tempfile.mkdtemp() 2276 | else: 2277 | if bpy.context.preferences.filepaths.temporary_directory: 2278 | return bpy.context.preferences.filepaths.temporary_directory 2279 | else: 2280 | return tempfile.mkdtemp() 2281 | 2282 | def updateCacheDirectory(self, context): 2283 | 2284 | # Get the cache path from the preferences, or a default temporary 2285 | path = os.path.abspath(get_temporary_path()) 2286 | 2287 | # Delete the old directory 2288 | # Won't delete anything upon plugin intialization, only when switching path in preferences 2289 | if Config.SKETCHFAB_TEMP_DIR and os.path.exists(Config.SKETCHFAB_TEMP_DIR) and os.path.isdir(Config.SKETCHFAB_TEMP_DIR): 2290 | shutil.rmtree(Config.SKETCHFAB_TEMP_DIR) 2291 | 2292 | # Create the paths and directories for temporary directories 2293 | Config.SKETCHFAB_TEMP_DIR = os.path.join(path, "sketchfab_downloads") 2294 | Config.SKETCHFAB_THUMB_DIR = os.path.join(Config.SKETCHFAB_TEMP_DIR, 'thumbnails') 2295 | Config.SKETCHFAB_MODEL_DIR = os.path.join(Config.SKETCHFAB_TEMP_DIR, 'imports') 2296 | if not os.path.exists(Config.SKETCHFAB_TEMP_DIR): os.makedirs(Config.SKETCHFAB_TEMP_DIR) 2297 | if not os.path.exists(Config.SKETCHFAB_THUMB_DIR): os.makedirs(Config.SKETCHFAB_THUMB_DIR) 2298 | if not os.path.exists(Config.SKETCHFAB_MODEL_DIR): os.makedirs(Config.SKETCHFAB_MODEL_DIR) 2299 | 2300 | class SketchfabAddonPreferences(bpy.types.AddonPreferences): 2301 | bl_idname = __name__ 2302 | cachePath: StringProperty( 2303 | name="Cache folder", 2304 | description=( 2305 | "Temporary directory for downloads from sketchfab.com\n" 2306 | "Set by the OS by default, make sure to have write access\n" 2307 | "to this directory if you set it manually" 2308 | ), 2309 | subtype='DIR_PATH', 2310 | update=updateCacheDirectory 2311 | ) 2312 | downloadHistory : StringProperty( 2313 | name="Download history file", 2314 | description=( 2315 | ".csv file containing your downloads from sketchfab.com\n" 2316 | "If valid, the name, license and url of every model you\n" 2317 | "download through the plugin will be saved in this file" 2318 | ), 2319 | subtype='FILE_PATH' 2320 | ) 2321 | def draw(self, context): 2322 | layout = self.layout 2323 | layout.prop(self, "cachePath", text="Download directory") 2324 | layout.prop(self, "downloadHistory", text="Download history (.csv)") 2325 | 2326 | classes = ( 2327 | SketchfabAddonPreferences, 2328 | 2329 | # Properties 2330 | SketchfabBrowserProps, 2331 | SketchfabLoginProps, 2332 | SketchfabBrowserPropsProxy, 2333 | SketchfabExportProps, 2334 | 2335 | # Panels 2336 | LoginPanel, 2337 | TeamsPanel, 2338 | SketchfabBrowse, 2339 | SketchfabExportPanel, 2340 | SketchfabPanel, 2341 | 2342 | # Operators 2343 | SketchfabEnable, 2344 | SketchfabCreateAccount, 2345 | LoginModal, 2346 | SketchfabNewVersion, 2347 | SketchfabHelp, 2348 | SketchfabReportIssue, 2349 | SketchfabSearch, 2350 | SketchfabSearchPreviousResults, 2351 | SketchfabSearchNextResults, 2352 | ImportModalOperator, 2353 | ViewOnSketchfab, 2354 | SketchfabDownloadModel, 2355 | SketchfabLogger, 2356 | ExportSketchfab, 2357 | ) 2358 | 2359 | def check_plugin_version(request, *args, **kwargs): 2360 | response = request.json() 2361 | skfb = get_sketchfab_props() 2362 | if response and len(response): 2363 | latest_release_version = response[0]['tag_name'].replace('.', '') 2364 | current_version = str(bl_info['version']).replace(',', '').replace('(', '').replace(')', '').replace(' ', '') 2365 | 2366 | if latest_release_version == current_version: 2367 | print('You are using the latest version({})'.format(response[0]['tag_name'])) 2368 | skfb.is_latest_version = 1 2369 | else: 2370 | print('A new version is available: {}'.format(response[0]['tag_name'])) 2371 | skfb.is_latest_version = 0 2372 | else: 2373 | print('Failed to retrieve plugin version') 2374 | skfb.is_latest_version = -2 2375 | 2376 | def register(): 2377 | sketchfab_icon = bpy.utils.previews.new() 2378 | icons_dir = os.path.dirname(__file__) 2379 | sketchfab_icon.load("skfb", os.path.join(icons_dir, "logo.png"), 'IMAGE') 2380 | sketchfab_icon.load("0", os.path.join(icons_dir, "placeholder.png"), 'IMAGE') 2381 | 2382 | res = [] 2383 | res.append(('NORESULTS', 'empty', "", sketchfab_icon['0'].icon_id, 0)) 2384 | preview_collection['default'] = tuple(res) 2385 | preview_collection['skfb'] = sketchfab_icon 2386 | bpy.types.WindowManager.result_previews = EnumProperty(items=list_current_results) 2387 | 2388 | for cls in classes: 2389 | bpy.utils.register_class(cls) 2390 | 2391 | bpy.types.WindowManager.sketchfab_browser = PointerProperty( 2392 | type=SketchfabBrowserProps) 2393 | 2394 | bpy.types.WindowManager.sketchfab_browser_proxy = PointerProperty( 2395 | type=SketchfabBrowserPropsProxy) 2396 | 2397 | bpy.types.WindowManager.sketchfab_api = PointerProperty( 2398 | type=SketchfabLoginProps, 2399 | ) 2400 | 2401 | bpy.types.WindowManager.sketchfab_export = PointerProperty( 2402 | type=SketchfabExportProps, 2403 | ) 2404 | 2405 | # If a cache path was set in preferences, use it 2406 | updateCacheDirectory(None, context=bpy.context) 2407 | 2408 | def unregister(): 2409 | for cls in classes: 2410 | bpy.utils.unregister_class(cls) 2411 | 2412 | del bpy.types.WindowManager.sketchfab_api 2413 | del bpy.types.WindowManager.sketchfab_browser 2414 | del bpy.types.WindowManager.sketchfab_browser_proxy 2415 | del bpy.types.WindowManager.sketchfab_export 2416 | 2417 | bpy.utils.previews.remove(preview_collection['skfb']) 2418 | del bpy.types.WindowManager.result_previews 2419 | Utils.clean_thumbnail_directory() 2420 | 2421 | 2422 | if __name__ == "__main__": 2423 | register() 2424 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketchfab/blender-plugin/7e0356a9de9fa9d83bbd58af863b8cc599b3d1e2/logo.png -------------------------------------------------------------------------------- /pack_for_export.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2021 Sketchfab 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | import os 18 | import bpy 19 | import json 20 | import sys 21 | 22 | sys.path.append(os.path.dirname(os.path.abspath(__file__))) 23 | 24 | SKETCHFAB_EXPORT_TEMP_DIR = sys.argv[7] 25 | SKETCHFAB_EXPORT_DATA_FILE = os.path.join(SKETCHFAB_EXPORT_TEMP_DIR, "export-sketchfab.json") 26 | 27 | # save a copy of the current blendfile 28 | def save_blend_copy(): 29 | import time 30 | 31 | filepath = SKETCHFAB_EXPORT_TEMP_DIR 32 | filename = time.strftime("Sketchfab_%Y_%m_%d_%H_%M_%S.blend", 33 | time.localtime(time.time())) 34 | filepath = os.path.join(filepath, filename) 35 | bpy.ops.wm.save_as_mainfile(filepath=filepath, 36 | compress=True, 37 | copy=True) 38 | size = os.path.getsize(filepath) 39 | return (filepath, filename, size) 40 | 41 | # change visibility statuses and pack images 42 | def prepare_assets(export_settings): 43 | hidden = set() 44 | images = set() 45 | 46 | # If we did not ask to export all models, do some cleanup 47 | if export_settings['selection']: 48 | 49 | for ob in bpy.data.objects: 50 | if ob.type == 'MESH': 51 | for mat_slot in ob.material_slots: 52 | if not mat_slot.material: 53 | continue 54 | 55 | if bpy.app.version < (2, 80, 0): 56 | for tex_slot in mat_slot.material.texture_slots: 57 | if not tex_slot: 58 | continue 59 | tex = tex_slot.texture 60 | if tex.type == 'IMAGE': 61 | image = tex.image 62 | if image is not None: 63 | images.add(image) 64 | 65 | if mat_slot.material.use_nodes: 66 | nodes = mat_slot.material.node_tree.nodes 67 | for n in nodes: 68 | if n.type == "TEX_IMAGE": 69 | if n.image is not None: 70 | images.add(n.image) 71 | 72 | if export_settings['selection'] and ob.type == 'MESH': 73 | # Add relevant objects to the list of objects to remove 74 | if not ob.visible_get(): # Not visible 75 | hidden.add(ob) 76 | elif not ob.select_get(): # Visible but not selected 77 | ob.hide_set(True) 78 | hidden.add(ob) 79 | 80 | for img in images: 81 | if not img.packed_file: 82 | try: 83 | img.pack() 84 | except: 85 | # can fail in rare cases 86 | import traceback 87 | traceback.print_exc() 88 | 89 | for ob in hidden: 90 | bpy.data.objects.remove(ob) 91 | 92 | # delete unused materials and associated textures (will remove unneeded packed images) 93 | for m in bpy.data.meshes: 94 | if m.users == 0: 95 | bpy.data.meshes.remove(m) 96 | for m in bpy.data.materials: 97 | if m.users == 0: 98 | bpy.data.materials.remove(m) 99 | for t in bpy.data.images: 100 | if t.users == 0: 101 | bpy.data.images.remove(t) 102 | 103 | def prepare_file(export_settings): 104 | prepare_assets(export_settings) 105 | return save_blend_copy() 106 | 107 | def read_settings(): 108 | with open(SKETCHFAB_EXPORT_DATA_FILE, 'r') as s: 109 | return json.load(s) 110 | 111 | def write_result(filepath, filename, size): 112 | with open(SKETCHFAB_EXPORT_DATA_FILE, 'w') as s: 113 | json.dump({ 114 | 'filepath': filepath, 115 | 'filename': filename, 116 | 'size': size, 117 | }, s) 118 | 119 | 120 | if __name__ == "__main__": 121 | try: 122 | export_settings = read_settings() 123 | filepath, filename, size = prepare_file(export_settings) 124 | write_result(filepath, filename, size) 125 | except: 126 | import traceback 127 | traceback.print_exc() 128 | sys.exit(1) 129 | -------------------------------------------------------------------------------- /placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketchfab/blender-plugin/7e0356a9de9fa9d83bbd58af863b8cc599b3d1e2/placeholder.png --------------------------------------------------------------------------------