├── .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
--------------------------------------------------------------------------------