diff --git a/README.md b/README.md index f85f5d7..bd41814 100644 --- a/README.md +++ b/README.md @@ -8,64 +8,80 @@ * [Export to Sketchfab](#export-a-model-to-sketchfab) * [Known issues](#known-issues) * [Report an issue](#report-an-issue) -* [Addon development](#addon-development) *Based on [Blender glTF 2.0 Importer and Exporter](https://github.com/KhronosGroup/glTF-Blender-IO) from [Khronos Group](https://github.com/KhronosGroup)* +
## Installation 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). -The addon should then be available in the 3D view: +After installing the addon, two optional settings are available: -* Blender 2.79: under the tab 'Sketchfab' in the Tools panel (shortcut **T**). -* Blender 2.80: under the tab 'Sketchfab' in the Properties panel (shortcut **N**). +* Download history: path to a .csv file used to keep track of your downloads and model licenses +* 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. -**⚠️ Note to Blender 2.79 OSX/Linux users:** The addon uses its own version of the SSL library. It is embedded within the plugin and should work correctly, but do not hesitate to [report an issue](#report-an-issue) if you encounter any issue related to SSL. +

+**Note to Blender 2.79 OSX/Linux users:** The addon uses an embedded version of the SSL library. Do not hesitate to [report an issue](#report-an-issue) if you encounter any issue related to SSL. + +
## Login -The login process (mandatory to import or export models) should be straightforward: type in the email adress associated with your Sketchfab account as well as your password in the login form: +After installation, the addon is available in the 3D view in the tab 'Sketchfab' in the Properties panel (shortcut **N**) for Blender 2.80 and after, and in the Tools panel (shortcut **T**) for Blender 2.79. + +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): -![Screenshot from 2019-09-03 10-25-22](https://user-images.githubusercontent.com/52042414/64157665-66849980-ce37-11e9-9806-c74bb1476987.jpg) +

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. 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. +### Organization members + +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. + +
## Import a model from Sketchfab Once logged in, you should be able to easily import any downloadable model from Sketchfab. -To do so, just run a search query and adapt the search options in the **Filters** menu. +

-Note that **PRO** users can use the **My Models** checkbox to import any published model from their own library (even the private ones). +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: -![results](https://user-images.githubusercontent.com/52042414/64158308-84063300-ce38-11e9-9d4d-1b17d0b0c828.jpg) +* All site : downloadable models available under [Creative Commons licenses](https://help.sketchfab.com/hc/en-us/articles/201368589-Downloading-Models#licenses) on sketchfab.com +* Own models: [PRO users](https://sketchfab.com/plans) can directly download models they have uploaded to their account +* Store purchases: models you have purchased on the [Sketchfab Store](https://sketchfab.com/store) +* Organization members can specify a specific project to browse -Clicking the **Search Results** thumbnail will allow you to navigate through the models available for download, and selecting one model will allow you to inspect it before import. +Clicking the **Search Results** thumbnail allows to navigate through the search results, and selecting a thumbnail gives you details before import: -![license](https://user-images.githubusercontent.com/52042414/64158307-84063300-ce38-11e9-89dd-04c37859bb6b.jpg) +

-Please note that all downloadable models are licensed under specific licenses: make sure to follow the different [Creative Commons licensing schemes](https://help.sketchfab.com/hc/en-us/articles/201368589-Downloading-Models#licenses). +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: +

-## Export a model to Sketchfab +
-Exporting should also be straightforward. +## Export a model to Sketchfab -You can choose to either export the currently selected model(s) or all visible models, and can also choose to set some model properties, such as its title, description and tags. +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. 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. -![export](https://user-images.githubusercontent.com/52042414/64161913-b61a9380-ce3e-11e9-89fa-7e15426cfff0.jpg) +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. + +

### A note on material support -Not all Blender materials and shaders will get correctly exported to Sketchfab. +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. The best material support comes with the **Principled BSDF** node, having either parameters or image textures plugged into the following channels: @@ -76,11 +92,11 @@ The best material support comes with the **Principled BSDF** node, having either * Alpha * Emission -Note that the export does not support UVs transformation (through the Mapping node), and 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. +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. Here is an example of a compatible node graph with backface culling and alpha mode correctly set (Blender 2.80 - Eevee renderer): -![graph](https://user-images.githubusercontent.com/52042414/64164529-b4070380-ce43-11e9-8602-995b083ac722.jpg) +

## Known Issues @@ -99,36 +115,15 @@ Here is a list of known issues on import, as well as some possible fixes. Please note that the materials are being converted from Sketchfab to Cycles in Blender 2.79, and Eevee in Blender 2.80. If a material looks wrong, using the **Node editor** could therefore help you fixing possible issues. -#### Empty scene after import - -Scale can vary a lot between different models, and models origins are not always intuitively centered. - -The imported models will always be selected after import, and you can try to scale them in order to make them visible (most often, the model will need to be scaled down). +#### Mesh not parented to armature -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. +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. -#### Transparency - -Some models are using refraction on Sketchfab (for glass, ice, water...), which is not supported by glTF and ends up being converted to regular transparency. - -In Blender **Node Editor**, refraction can be achieved by tweaking the **IOR** and **Transmission** inputs of the Principled BSDF node, or by mixing a **Refraction BSDF** shader with the original material. - -#### Weird seams or normals - -Tangent space import is not working yet so you might experience rendering issue on some models with normal maps. - -#### Single color model (wrong backface culling) - -Backface culling is not well supported on import yet. - -It is often used on Sketchfab to create models with outlines (as on [this model](https://sketchfab.com/models/71436ab009684265a2fda0e469f77752) for instance) by duplicating the object, scaling it up, flipping its normals and making the material single sided. +#### Empty scene after import -You can reproduce this behaviour in Blender: +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). -* Blender 2.79: - * For the 3D view (not rendered), check the **Backface culling** checkbox in the **Properties Panel** (shortcut **N**), under the **Shading** dropdown. - * For the rendered view (in Cycles), follow the instructions on [this StackOverflow answer](https://blender.stackexchange.com/a/2083). -* Blender 2.80: In the node editor **Properties Panel**, under the **Options** tab and **Settings** dropdown, make sure to have the **Backface Culling** option toggled on. +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. #### Unexpected colors (vertex colors) @@ -160,23 +155,4 @@ If you feel like you've encountered a bug not listed in the [known issues](#know To help us track a possible error, please try to append the logs of Blender's console in your message: * On Windows, it is available through the menu **Window** -> **Toggle system console** -* 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. - -## Addon development - -To prepare a development version of the addon, you'll first have to clone this repository and update the [Khronos glTF IO](https://github.com/KhronosGroup/glTF-Blender-IO) submodule: -```sh -git clone https://github.com/sketchfab/blender-plugin.git -cd blender-plugin/ -git submodule update --init --recursive -``` - -You'll then need (only once) to patch the code from the Khronos submodule with the command: -```sh -./build.sh --patch -``` - -The final releases can then be built by executing build.sh without arguments: -``` -./build.sh -``` \ No newline at end of file +* 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. \ No newline at end of file diff --git a/addons/io_sketchfab_plugin/__init__.py b/addons/io_sketchfab_plugin/__init__.py index fe9e282..77293a0 100644 --- a/addons/io_sketchfab_plugin/__init__.py +++ b/addons/io_sketchfab_plugin/__init__.py @@ -1,5 +1,5 @@ """ -Copyright 2021 Sketchfab +Copyright 2022 Sketchfab Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -23,6 +23,8 @@ import subprocess import tempfile import json +import shutil +from uuid import UUID import bpy import bpy.utils.previews @@ -33,10 +35,8 @@ PointerProperty) from .io import * -from .sketchfab import Config, Utils, Cache from .io.imp.gltf2_io_gltf import * from .blender.imp.gltf2_blender_gltf import * - from .blender.blender_version import Version @@ -77,7 +77,7 @@ 'author': 'Sketchfab', 'license': 'APACHE2', 'deps': '', - 'version': (1, 4, 1), + 'version': (1, 5, 0), "blender": (2, 80, 0), 'location': 'View3D > Tools > Sketchfab', 'warning': '', @@ -92,8 +92,273 @@ PLUGIN_VERSION = str(bl_info['version']).strip('() ').replace(',', '.') preview_collection = {} +thumbnailsProgress = set([]) +ongoingSearches = set([]) is_plugin_enabled = False + +class Config: + + ADDON_NAME = 'io_sketchfab' + GITHUB_REPOSITORY_URL = 'https://github.com/sketchfab/blender-plugin' + GITHUB_REPOSITORY_API_URL = 'https://api.github.com/repos/sketchfab/blender-plugin' + SKETCHFAB_REPORT_URL = 'https://help.sketchfab.com/hc/en-us/requests/new?type=exporters&subject=Blender+Plugin' + + SKETCHFAB_URL = 'https://sketchfab.com' + DUMMY_CLIENTID = 'hGC7unF4BHyEB0s7Orz5E1mBd3LluEG0ILBiZvF9' + SKETCHFAB_OAUTH = SKETCHFAB_URL + '/oauth2/token/?grant_type=password&client_id=' + DUMMY_CLIENTID + SKETCHFAB_API = 'https://api.sketchfab.com' + SKETCHFAB_SEARCH = SKETCHFAB_API + '/v3/search' + SKETCHFAB_MODEL = SKETCHFAB_API + '/v3/models' + SKETCHFAB_ORGS = SKETCHFAB_API + '/v3/orgs' + SKETCHFAB_SIGNUP = 'https://sketchfab.com/signup' + + BASE_SEARCH = SKETCHFAB_SEARCH + '?type=models&downloadable=true' + DEFAULT_FLAGS = '&staffpicked=true&sort_by=-staffpickedAt' + DEFAULT_SEARCH = SKETCHFAB_SEARCH + \ + '?type=models&downloadable=true' + DEFAULT_FLAGS + + SKETCHFAB_ME = '{}/v3/me'.format(SKETCHFAB_API) + BASE_SEARCH_OWN_MODELS = SKETCHFAB_ME + '/search?type=models&downloadable=true' + PURCHASED_MODELS = SKETCHFAB_ME + "/models/purchases?type=models" + + SKETCHFAB_PLUGIN_VERSION = '{}/releases'.format(GITHUB_REPOSITORY_API_URL) + + # Those will be set during plugin initialization, or upon setting a new cache directory + SKETCHFAB_TEMP_DIR = "" + SKETCHFAB_THUMB_DIR = "" + SKETCHFAB_MODEL_DIR = "" + + SKETCHFAB_CATEGORIES = (('ALL', 'All categories', 'All categories'), + ('animals-pets', 'Animals & Pets', 'Animals and Pets'), + ('architecture', 'Architecture', 'Architecture'), + ('art-abstract', 'Art & Abstract', 'Art & Abstract'), + ('cars-vehicles', 'Cars & vehicles', 'Cars & vehicles'), + ('characters-creatures', 'Characters & Creatures', 'Characters & Creatures'), + ('cultural-heritage-history', 'Cultural Heritage & History', 'Cultural Heritage & History'), + ('electronics-gadgets', 'Electronics & Gadgets', 'Electronics & Gadgets'), + ('fashion-style', 'Fashion & Style', 'Fashion & Style'), + ('food-drink', 'Food & Drink', 'Food & Drink'), + ('furniture-home', 'Furniture & Home', 'Furniture & Home'), + ('music', 'Music', 'Music'), + ('nature-plants', 'Nature & Plants', 'Nature & Plants'), + ('news-politics', 'News & Politics', 'News & Politics'), + ('people', 'People', 'People'), + ('places-travel', 'Places & Travel', 'Places & Travel'), + ('science-technology', 'Science & Technology', 'Science & Technology'), + ('sports-fitness', 'Sports & Fitness', 'Sports & Fitness'), + ('weapons-military', 'Weapons & Military', 'Weapons & Military')) + + SKETCHFAB_FACECOUNT = (('ANY', "All", ""), + ('10K', "Up to 10k", ""), + ('50K', "10k to 50k", ""), + ('100K', "50k to 100k", ""), + ('250K', "100k to 250k", ""), + ('250KP', "250k +", "")) + + SKETCHFAB_SORT_BY = (('RELEVANCE', "Relevance", ""), + ('LIKES', "Likes", ""), + ('VIEWS', "Views", ""), + ('RECENT', "Recent", "")) + + SKETCHFAB_SEARCH_DOMAIN = (('DEFAULT', "All site", "", 0), + ('OWN', "Own Models (PRO)", "", 1), + ('STORE', "Store purchases", "", 2)) + + MAX_THUMBNAIL_HEIGHT = 256 + + SKETCHFAB_UPLOAD_LIMITS = { + "basic" : 100 * 1024 * 1024, + "plus": 100 * 1024 * 1024, + "pro": 200 * 1024 * 1024, + "prem": 200 * 1024 * 1024, + "biz": 500 * 1024 * 1024, + "ent": 500 * 1024 * 1024 + } + +class Utils: + def humanify_size(size): + suffix = 'B' + readable = size + + # Megabyte + if size > 1048576: + suffix = 'MB' + readable = size / 1048576.0 + # Kilobyte + elif size > 1024: + suffix = 'KB' + readable = size / 1024.0 + + readable = round(readable, 2) + return '{}{}'.format(readable, suffix) + + def humanify_number(number): + suffix = '' + readable = number + + if number > 1000000: + suffix = 'M' + readable = number / 1000000.0 + + elif number > 1000: + suffix = 'K' + readable = number / 1000.0 + + readable = round(readable, 2) + return '{}{}'.format(readable, suffix) + + def build_download_url(uid, use_org_profile=False, active_org=None): + if use_org_profile: + return '{}/{}/models/{}/download'.format(Config.SKETCHFAB_ORGS, active_org["uid"], uid) + else: + return '{}/{}/download'.format(Config.SKETCHFAB_MODEL, uid) + + def thumbnail_file_exists(uid): + return os.path.exists(os.path.join(Config.SKETCHFAB_THUMB_DIR, '{}.jpeg'.format(uid))) + + def clean_thumbnail_directory(): + if not os.path.exists(Config.SKETCHFAB_THUMB_DIR): + return + + from os import listdir + for file in listdir(Config.SKETCHFAB_THUMB_DIR): + os.remove(os.path.join(Config.SKETCHFAB_THUMB_DIR, file)) + + def clean_downloaded_model_dir(uid): + shutil.rmtree(os.path.join(Config.SKETCHFAB_MODEL_DIR, uid)) + + def get_thumbnail_url(thumbnails_json): + min_height = 1e6 + min_thumbnail = None + best_height = 0 + best_thumbnail = None + for image in thumbnails_json['images']: + h = image['height'] + if h <= Config.MAX_THUMBNAIL_HEIGHT and h > best_height: + best_height = h + best_thumbnail = image['url'] + elif h < min_height: + min_height = h + min_thumbnail = image['url'] + # Ensure we have a thumbnail if available thumbnails are all above MAX_THUMBNAIL_HEIGHT + if best_thumbnail is None and min_thumbnail is not None: + return min_thumbnail + return best_thumbnail + + def make_model_name(gltf_data): + if 'title' in gltf_data.asset.extras: + return gltf_data.asset.extras['title'] + + return 'GLTFModel' + + def setup_plugin(): + if not os.path.exists(Config.SKETCHFAB_THUMB_DIR): + os.makedirs(Config.SKETCHFAB_THUMB_DIR) + + def get_uid_from_thumbnail_url(thumbnail_url): + return thumbnail_url.split('/')[4] + + def get_uid_from_model_url(model_url, use_org_profile=False): + try: + return model_url.split('/')[7] if use_org_profile else model_url.split('/')[5] + except: + ShowMessage("ERROR", "Url parsing error", "Error getting uid from url: {}".format(model_url)) + return None + + def get_uid_from_download_url(model_url): + return model_url.split('/')[6] + + def clean_node_hierarchy(objects, root_name): + """ + Removes the useless nodes in a hierarchy + TODO: Keep the transform (might impact Yup/Zup) + """ + # Find the parent object + root = None + for object in objects: + if object.parent is None: + root = object + if root is None: + return None + + # Go down its hierarchy until one child has multiple children, or a single mesh + # Keep the name while deleting objects in the hierarchy + diverges = False + while diverges==False: + children = root.children + if children is not None: + + if len(children)>1: + diverges = True + root.name = root_name + + if len(children)==1: + if children[0].type != "EMPTY": + diverges = True + root.name = root_name + if children[0].type == "MESH": # should always be the case + matrixcopy = children[0].matrix_world.copy() + children[0].parent = None + children[0].matrix_world = matrixcopy + bpy.data.objects.remove(root) + children[0].name = root_name + root = children[0] + + elif children[0].type == "EMPTY": + diverges = False + matrixcopy = children[0].matrix_world.copy() + children[0].parent = None + children[0].matrix_world = matrixcopy + bpy.data.objects.remove(root) + root = children[0] + else: + break + + # Select the root Empty node + root.select_set(True) + + def is_valid_uuid(uuid_to_test, version=4): + try: + uuid_obj = UUID(hex=uuid_to_test, version=version) + return True + except ValueError: + return False + +class Cache: + SKETCHFAB_CACHE_FILE = os.path.join( + bpy.utils.user_resource("SCRIPTS", path="sketchfab_cache", create=True), + ".cache" + ) # Use a user path to avoid permission-related errors + + def read(): + if not os.path.exists(Cache.SKETCHFAB_CACHE_FILE): + return {} + + with open(Cache.SKETCHFAB_CACHE_FILE, 'rb') as f: + data = f.read().decode('utf-8') + return json.loads(data) + + def get_key(key): + cache_data = Cache.read() + if key in cache_data: + return cache_data[key] + + def save_key(key, value): + cache_data = Cache.read() + cache_data[key] = value + with open(Cache.SKETCHFAB_CACHE_FILE, 'wb+') as f: + f.write(json.dumps(cache_data).encode('utf-8')) + + def delete_key(key): + cache_data = Cache.read() + if key in cache_data: + del cache_data[key] + + with open(Cache.SKETCHFAB_CACHE_FILE, 'wb+') as f: + f.write(json.dumps(cache_data).encode('utf-8')) + + # helpers def get_sketchfab_login_props(): return bpy.context.window_manager.sketchfab_api @@ -108,7 +373,10 @@ def get_sketchfab_props_proxy(): def get_sketchfab_model(uid): skfb = get_sketchfab_props() - return skfb.search_results['current'][uid] + if "current" in skfb.search_results and uid in skfb.search_results["current"]: + return skfb.search_results['current'][uid] + else: + return None def run_default_search(): searchthr = GetRequestThread(Config.DEFAULT_SEARCH, parse_results) @@ -158,6 +426,7 @@ def set_import_status(status): class SketchfabApi: def __init__(self): self.access_token = '' + self.api_token = '' self.headers = {} self.username = '' self.display_name = '' @@ -169,13 +438,19 @@ def __init__(self): self.use_org_profile = False def build_headers(self): - self.headers = {'Authorization': 'Bearer ' + self.access_token} + if self.access_token: + self.headers = {'Authorization': 'Bearer ' + self.access_token} + elif self.api_token: + self.headers = {'Authorization': 'Token ' + self.api_token} + else: + print("Empty authorization header") + self.headers = {} - def login(self, email, password): + def login(self, email, password, api_token): bpy.ops.wm.login_modal('INVOKE_DEFAULT') def is_user_logged(self): - if self.access_token and self.headers: + if (self.access_token or self.api_token) and self.headers: return True return False @@ -185,17 +460,25 @@ def is_user_pro(self): def logout(self): self.access_token = '' + self.api_token = '' self.headers = {} Cache.delete_key('username') Cache.delete_key('access_token') + Cache.delete_key('api_token') Cache.delete_key('key') props = get_sketchfab_props() - props.search_domain = "DEFAULT" + #props.search_domain = "DEFAULT" if 'current' in props.search_results: del props.search_results['current'] pprops = get_sketchfab_props_proxy() - pprops.search_domain = "DEFAULT" + #pprops.search_domain = "DEFAULT" + + self.user_orgs = [] + self.active_org = None + self.use_org_profile = False + props.use_org_profile = False + pprops.use_org_profile = False bpy.ops.wm.sketchfab_search('EXEC_DEFAULT') @@ -216,8 +499,11 @@ def parse_user_info(self, r, *args, **kargs): self.plan_type = user_data['account'] requests.get(Config.SKETCHFAB_ME + "/orgs", headers=self.headers, hooks={'response': self.parse_orgs_info}) else: - print('Invalid access token') + print('\nInvalid access or API token\nYou can get your API token here:\nhttps://sketchfab.com/settings/password\n') + set_login_status('ERROR', 'Failed to authenticate') + ShowMessage("ERROR", "Failed to authenticate", "Invalid access or API token") self.access_token = '' + self.api_token = '' self.headers = {} def parse_orgs_info(self, r, *args, **kargs): @@ -232,6 +518,7 @@ def parse_orgs_info(self, r, *args, **kargs): self.user_orgs.append({ "uid": org["uid"], "displayName": org["displayName"], + "username": org["username"], "url": org["publicProfileUrl"], "projects": [], }) @@ -284,28 +571,21 @@ def parse_projects_info(r, *args, **kargs): if orgs_data["next"] is not None: requests.get(orgs_data["next"], headers=self.headers, hooks={'response': self.parse_orgs_info}) - def parse_login(self, r, *args, **kwargs): - if r.status_code == 200 and 'access_token' in r.json(): - self.access_token = r.json()['access_token'] - self.build_headers() - self.request_user_info() - else: - if 'error_description' in r.json(): - print("Failed to login: {}".format(r.json()['error_description'])) - else: - print('Login failed.\n {}'.format(r.json())) + def request_thumbnail(self, thumbnails_json, model_uid): + # Avoid requesting twice the same data + if model_uid not in thumbnailsProgress: + thumbnailsProgress.add(model_uid) + url = Utils.get_thumbnail_url(thumbnails_json) + thread = ThumbnailCollector(url) + thread.start() - def request_thumbnail(self, thumbnails_json): - url = Utils.get_thumbnail_url(thumbnails_json) - thread = ThumbnailCollector(url) - thread.start() - - def request_model_info(self, uid): + def request_model_info(self, uid, callback=None): + callback = self.handle_model_info if callback is None else callback url = Config.SKETCHFAB_MODEL + '/' + uid - if self.use_org_profile: + if self.use_org_profile and self.active_org.get("uid"): url = Config.SKETCHFAB_ORGS + "/" + self.active_org["uid"] + "/models/" + uid - model_infothr = GetRequestThread(url, self.handle_model_info, self.headers) + model_infothr = GetRequestThread(url, callback, self.headers) model_infothr.start() def handle_model_info(self, r, *args, **kwargs): @@ -313,7 +593,7 @@ def handle_model_info(self, r, *args, **kwargs): uid = Utils.get_uid_from_model_url(r.url, self.use_org_profile) # Dirty fix to avoid processing obsolete result data - if 'current' not in skfb.search_results or uid not in skfb.search_results['current']: + if 'current' not in skfb.search_results or uid is None or uid not in skfb.search_results['current']: return model = skfb.search_results['current'][uid] @@ -327,9 +607,8 @@ def handle_model_info(self, r, *args, **kwargs): def search(self, query, search_cb): skfb = get_sketchfab_props() - if skfb.search_domain == "DEFAULT": - url = Config.BASE_SEARCH - elif skfb.search_domain == "OWN": + url = Config.BASE_SEARCH + if skfb.search_domain == "OWN": url = Config.BASE_SEARCH_OWN_MODELS elif skfb.search_domain == "STORE": url = Config.PURCHASED_MODELS @@ -339,47 +618,106 @@ def search(self, query, search_cb): url = Config.SKETCHFAB_ORGS + "/%s/models?isArchivesReady=true&projects=%s" % (self.active_org["uid"], skfb.search_domain) search_query = '{}{}'.format(url, query) - - searchthr = GetRequestThread(search_query, search_cb, self.headers) - searchthr.start() + if search_query not in ongoingSearches: + ongoingSearches.add(search_query) + searchthr = GetRequestThread(search_query, search_cb, self.headers) + searchthr.start() def search_cursor(self, url, search_cb): requests.get(url, headers=self.headers, hooks={'response': search_cb}) + def write_model_info(self, title, author, authorUrl, license, uid): + try: + downloadHistory = bpy.context.preferences.addons[__name__.split('.')[0]].preferences.downloadHistory + if downloadHistory != "": + downloadHistory = os.path.abspath(downloadHistory) + createFile = False + if not os.path.exists(downloadHistory): + createFile = True + with open(downloadHistory, 'a+') as f: + if createFile: + f.write("Model name, Author name, Author url, License, Model link,\n") + f.write("{}, {}, https://sketchfab.com/{}, {}, https://sketchfab.com/models/{},\n".format( + title.replace(",", " "), + author.replace(",", " "), + authorUrl.replace(",", " "), + license.replace(",", " "), + uid + )) + except: + print("Error encountered while saving data to history file") + + def parse_model_info_request(self, r, *args, **kargs): + try: + if r.status_code == 200: + result = r.json() + title = result['name'] + author = result['user']['displayName'] + username = result['user']['username'] + license = result["license"]["label"] + uid = result['uid'] + self.write_model_info(title, author, username, license, uid) + else: + print("Error encountered while getting model info ({})\n{}\n{}".format(r.status_code, r.url, str(r.json()))) + except: + print("Error encountered while parsing model info request: {}".format(r.url)) + def download_model(self, uid): skfb_model = get_sketchfab_model(uid) - if skfb_model.download_url: - # Check url sanity - if time.time() - skfb_model.time_url_requested < skfb_model.url_expires: + if skfb_model is not None: # The model comes from the search results + if skfb_model.download_url and (time.time() - skfb_model.time_url_requested < skfb_model.url_expires): self.get_archive(skfb_model.download_url) else: - print("Download url is outdated, requesting a new one") skfb_model.download_url = None skfb_model.url_expires = None skfb_model.time_url_requested = None + self.write_model_info(skfb_model.title, skfb_model.author, skfb_model.username, skfb_model.license, uid) requests.get(Utils.build_download_url(uid, self.use_org_profile, self.active_org), headers=self.headers, hooks={'response': self.handle_download}) - else: - requests.get(Utils.build_download_url(uid, self.use_org_profile, self.active_org), headers=self.headers, hooks={'response': self.handle_download}) + else: # Model comes from a direct link + skfb = get_sketchfab_props() + download_url = "" + + # If the model is in an org, find if the user has access to it + if "/orgs/" in skfb.manualImportPath: + try: + orgName = skfb.manualImportPath.split("/orgs/")[1].split("/")[0] + user_orgs = skfb.skfb_api.user_orgs + orgUid = "" + for org in user_orgs: + if org["username"] == orgName: + orgUid = org["uid"] + break + if orgUid: + download_url = '{}/{}/models/{}/download'.format(Config.SKETCHFAB_ORGS, orgUid, uid) + else: + ShowMessage("ERROR", "User not in Organization", "User does not appear to belong to org %s" % (orgName)) + return + except: + ShowMessage("ERROR", "Invalid url", "Cannot parse org name from url %s" % skfb.manualImportPath) + return + # Otherwise, request a direct download and get model info + else: + download_url = Utils.build_download_url(uid) + requests.get('{}/{}'.format(Config.SKETCHFAB_MODEL, uid), headers=skfb.skfb_api.headers, hooks={'response': self.parse_model_info_request}) + + requests.get(download_url, headers=self.headers, hooks={'response': self.handle_download}) def handle_download(self, r, *args, **kwargs): if r.status_code != 200 or 'gltf' not in r.json(): - print('Download not available for this model') + ShowMessage("ERROR", "This model is not downloadable", "Make sure your account has enough rights to download the model") return - print("\n\nDownload data for {}\n{}\n\n".format( - Utils.get_uid_from_model_url(r.url, self.use_org_profile), - str(r.json()) - )) - skfb = get_sketchfab_props() uid = Utils.get_uid_from_model_url(r.url, self.use_org_profile) + if uid is None: + return gltf = r.json()['gltf'] skfb_model = get_sketchfab_model(uid) - skfb_model.download_url = gltf['url'] - skfb_model.time_url_requested = time.time() - skfb_model.url_expires = gltf['expires'] - + if skfb_model is not None: + skfb_model.download_url = gltf['url'] + skfb_model.time_url_requested = time.time() + skfb_model.url_expires = gltf['expires'] self.get_archive(gltf['url']) def get_archive(self, url): @@ -424,7 +762,7 @@ def get_archive(self, url): import traceback print(traceback.format_exc()) else: - print("Failed to download model (url might be invalid)") + ShowMessage("ERROR", "Download error", "Failed to download model (url might be invalid)") model = get_sketchfab_model(uid) set_import_status("Import model ({})".format(model.download_size if model.download_size else 'fetching data')) return @@ -446,6 +784,17 @@ def update_tr(self, context): default="" ) + api_token : StringProperty( + name="API Token", + description="User API Token", + default="" + ) + + use_mail : BoolProperty( + name="Use mail / password", + description="Use mail/password login or API Token", + default=True, + ) password : StringProperty( name="password", @@ -509,13 +858,21 @@ def refresh_orgs(self, context): api = props.skfb_api api.use_org_profile = pprops.use_org_profile - api.active_org = [org for org in api.user_orgs if org["uid"] == pprops.active_org][0] + orgs = [org for org in api.user_orgs if org["uid"] == pprops.active_org] + api.active_org = orgs[0] if len(orgs) else None if pprops.use_org_profile != props.use_org_profile: props.use_org_profile = pprops.use_org_profile if pprops.active_org != props.active_org: props.active_org = pprops.active_org + if props.use_org_profile: + props.search_domain = "ACTIVE_ORG" + pprops.search_domain = "ACTIVE_ORG" + else: + props.search_domain = "DEFAULT" + pprops.search_domain = "DEFAULT" + refresh_search(self, context) def get_sorting_options(self, context): @@ -564,7 +921,6 @@ class SketchfabBrowserPropsProxy(bpy.types.PropertyGroup): name="Sort by", items=get_sorting_options, description="Sort ", - default=0, update=refresh_search, ) @@ -586,8 +942,8 @@ class SketchfabBrowserPropsProxy(bpy.types.PropertyGroup): name="", items=get_available_search_domains, description="Search domain ", - default=0, - update=refresh_search + update=refresh_search, + default=None ) use_org_profile : BoolProperty( @@ -643,8 +999,6 @@ class SketchfabBrowserProps(bpy.types.PropertyGroup): name="Sort by", items=get_sorting_options, description="Sort ", - default=0, - update=refresh_search, ) animated : BoolProperty( @@ -663,22 +1017,18 @@ class SketchfabBrowserProps(bpy.types.PropertyGroup): name="Search domain", items=get_available_search_domains, description="Search domain ", - default=0, - update=refresh_search ) use_org_profile : BoolProperty( name="Use organisation profile", description="Import/Export as a member of an organization\nLOL", default=False, - update=refresh_orgs ) active_org : EnumProperty( name="Org", items=get_user_orgs, description="Active org", - update=refresh_orgs ) status : StringProperty(name='status', default='idle') @@ -702,6 +1052,18 @@ class SketchfabBrowserProps(bpy.types.PropertyGroup): import_status : StringProperty(name='import', default='') + manualImportBoolean : BoolProperty( + name="Import from url", + description="Import a downloadable model from a url", + default=False, + ) + manualImportPath : StringProperty( + name="Url", + description="Paste full model url:\n* https://sketchfab.com/models/mymodel-XXXX\n* https://sketchfab.com/orgs/XXXX/3d-models/mymodel-YYYY", + default="", + maxlen=1024, + options={'TEXTEDIT_UPDATE'}) + def list_current_results(self, context): skfb = get_sketchfab_props() @@ -737,50 +1099,7 @@ def list_current_results(self, context): return preview_collection['thumbnails'] -def draw_search(layout, context): - props = get_sketchfab_props_proxy() - skfb_api = get_sketchfab_props().skfb_api - col = layout.box().column(align=True) - - ro = col.row() - ro.label(text="Search") - domain_col = ro.column() - domain_col.scale_x = 1.5 - domain_col.enabled = skfb_api.is_user_logged(); - domain_col.prop(props, "search_domain") - - ro = col.row() - ro.scale_y = 1.25 - ro.prop(props, "query") - ro.operator("wm.sketchfab_search", text="", icon='VIEWZOOM') - - # User selected own models but is not pro - if props.search_domain == "OWN" and skfb_api.is_user_logged() and not skfb_api.is_user_pro(): - col.label(text='A PRO account is required', icon='QUESTION') - col.label(text='to access your personal library') - - # Display a collapsible box for filters - col = layout.box().column(align=True) - col.enabled = (props.search_domain != "STORE") - row = col.row() - row.prop(props, "expanded_filters", icon="TRIA_DOWN" if props.expanded_filters else "TRIA_RIGHT", icon_only=True, emboss=False) - row.label(text="Search filters") - if props.expanded_filters: - if props.search_domain in ["DEFAULT", "OWN"]: - col.separator() - col.prop(props, "categories") - col.prop(props, "sort_by") - col.prop(props, "face_count") - row = col.row() - row.prop(props, "pbr") - row.prop(props, "staffpick") - row.prop(props, "animated") - else: - col.separator() - col.prop(props, "sort_by") - col.prop(props, "face_count") - pprops = get_sketchfab_props() def draw_model_info(layout, model, context): @@ -804,9 +1123,14 @@ def draw_model_info(layout, model, context): if(model.animated): ui_model_props.label(text='Animated: ' + model.animated, icon='ANIM_DATA') - import_ops = ui_model_props.column() + layout.separator() + +def draw_import_button(layout, model, context): + + import_ops = layout.row() skfb = get_sketchfab_props() - import_ops.enabled = skfb.skfb_api.is_user_logged() and bpy.context.mode == 'OBJECT' + + import_ops.enabled = skfb.skfb_api.is_user_logged() and bpy.context.mode == 'OBJECT' and Utils.is_valid_uuid(model.uid) if not skfb.skfb_api.is_user_logged(): downloadlabel = 'Log in to download models' elif bpy.context.mode != 'OBJECT': @@ -815,16 +1139,12 @@ def draw_model_info(layout, model, context): downloadlabel = "Import model" if model.download_size: downloadlabel += " ({})".format(model.download_size) - if skfb.import_status: downloadlabel = skfb.import_status download_icon = 'IMPORT' if import_ops.enabled else 'INFO' - import_ops.label(text='') - row = import_ops.row() - row.scale_y = 2.0 - row.operator("wm.sketchfab_download", icon=download_icon, text=downloadlabel, translate=False, emboss=True).model_uid = model.uid - + import_ops.scale_y = 2.0 + import_ops.operator("wm.sketchfab_download", icon=download_icon, text=downloadlabel, translate=False, emboss=True).model_uid = model.uid def set_log(log): get_sketchfab_props().status = log @@ -870,7 +1190,7 @@ def import_model(gltf_path, uid): def build_search_request(query, pbr, animated, staffpick, face_count, category, sort_by): - final_query = '&q={}'.format(query) + final_query = '&q={}'.format(query) if query else '' if animated: final_query = final_query + '&animated=true' @@ -906,6 +1226,9 @@ def build_search_request(query, pbr, animated, staffpick, face_count, category, def parse_results(r, *args, **kwargs): + + ongoingSearches.discard(r.url) + skfb = get_sketchfab_props() json_data = r.json() @@ -925,7 +1248,7 @@ def parse_results(r, *args, **kwargs): skfb.search_results['current'][result['uid']] = SketchfabModel(result) if not os.path.exists(os.path.join(Config.SKETCHFAB_THUMB_DIR, uid) + '.jpeg'): - skfb.skfb_api.request_thumbnail(result['thumbnails']) + skfb.skfb_api.request_thumbnail(result['thumbnails'], uid) elif uid not in skfb.custom_icons: skfb.custom_icons.load(uid, os.path.join(Config.SKETCHFAB_THUMB_DIR, "{}.jpeg".format(uid)), 'IMAGE') @@ -987,24 +1310,27 @@ def handle_thumbnail(self, r, *args, **kwargs): dl += len(data) f.write(data) + thumbnailsProgress.discard(uid) + props = get_sketchfab_props() if uid not in props.custom_icons: props.custom_icons.load(uid, os.path.join(Config.SKETCHFAB_THUMB_DIR, "{}.jpeg".format(uid)), 'IMAGE') class LoginModal(bpy.types.Operator): + """Login into your account""" bl_idname = "wm.login_modal" - bl_label = "Import glTF model into Sketchfab" + bl_label = "" bl_options = {'INTERNAL'} is_logging : BoolProperty(default=False) error : BoolProperty(default=False) error_message : StringProperty(default='') - def exectue(self, context): + def execute(self, context): return {'FINISHED'} - def handle_login(self, r, *args, **kwargs): + def handle_mail_login(self, r, *args, **kwargs): browser_props = get_sketchfab_props() if r.status_code == 200 and 'access_token' in r.json(): browser_props.skfb_api.access_token = r.json()['access_token'] @@ -1025,6 +1351,17 @@ def handle_login(self, r, *args, **kwargs): self.is_logging = False + def handle_token_login(self, api_token): + browser_props = get_sketchfab_props() + browser_props.skfb_api.api_token = api_token + login_props = get_sketchfab_login_props() + Cache.save_key('api_token', login_props.api_token) + + browser_props.skfb_api.build_headers() + set_login_status('INFO', '') + browser_props.skfb_api.request_user_info() + self.is_logging = False + def modal(self, context, event): if self.error: self.error = False @@ -1042,8 +1379,11 @@ def invoke(self, context, event): try: context.window_manager.modal_handler_add(self) login_props = get_sketchfab_login_props() - url = '{}&username={}&password={}'.format(Config.SKETCHFAB_OAUTH, urllib.parse.quote_plus(login_props.email), urllib.parse.quote_plus(login_props.password)) - requests.post(url, hooks={'response': self.handle_login}) + if(login_props.use_mail): + url = '{}&username={}&password={}'.format(Config.SKETCHFAB_OAUTH, urllib.parse.quote_plus(login_props.email), urllib.parse.quote_plus(login_props.password)) + requests.post(url, hooks={'response': self.handle_mail_login}) + else: + self.handle_token_login(login_props.api_token) except Exception as e: self.error = True self.error_message = str(e) @@ -1052,6 +1392,7 @@ def invoke(self, context, event): class ImportModalOperator(bpy.types.Operator): + """Imports the selected model into Blender""" bl_idname = "wm.import_modal" bl_label = "Import glTF model into Sketchfab" bl_options = {'INTERNAL'} @@ -1059,12 +1400,13 @@ class ImportModalOperator(bpy.types.Operator): gltf_path : StringProperty() uid : StringProperty() - def exectue(self, context): + def execute(self, context): print('IMPORT') return {'FINISHED'} def modal(self, context, event): - bpy.context.scene.render.engine = Version.ENGINE + if bpy.context.scene.render.engine not in ["CYCLES", "BLENDER_EEVEE"]: + bpy.context.scene.render.engine = Version.ENGINE gltf_importer = glTFImporter(self.gltf_path) gltf_importer.read() @@ -1159,8 +1501,12 @@ def draw(self, context): layout.prop(skfb_login, 'status', icon=skfb_login.status_type) else: layout.label(text="Login to your Sketchfab account", icon='INFO') - layout.prop(skfb_login, "email") - layout.prop(skfb_login, "password") + layout.prop(skfb_login, "use_mail") + if skfb_login.use_mail: + layout.prop(skfb_login, "email") + layout.prop(skfb_login, "password") + else: + layout.prop(skfb_login, "api_token") ops_row = layout.row() ops_row.operator('wm.sketchfab_signup', text='Create an account', icon='PLUS') login_icon = "LINKED" if bpy.app.version < (2,80,0) else "USER" @@ -1195,6 +1541,11 @@ def draw(self, context): pprops = get_sketchfab_props() +class Model: + def __init__(self, _uid): + self.uid = _uid + self.download_size = 0 + class SketchfabBrowse(View3DPanel, bpy.types.Panel): bl_idname = "VIEW3D_PT_sketchfab_browse" bl_label = "Import" @@ -1202,62 +1553,125 @@ class SketchfabBrowse(View3DPanel, bpy.types.Panel): uid = '' label = "Search results" + def draw_search(self, layout, context): + prop = get_sketchfab_props() + props = get_sketchfab_props_proxy() + skfb_api = prop.skfb_api + + # Add an option to import from url or uid + col = layout.box().column(align=True) + row = col.row() + row.prop(prop, "manualImportBoolean") + + if prop.manualImportBoolean: + row = col.row() + row.prop(prop, "manualImportPath") + + else: + col = layout.box().column(align=True) + ro = col.row() + ro.label(text="Search") + domain_col = ro.column() + domain_col.scale_x = 1.5 + domain_col.enabled = skfb_api.is_user_logged() + domain_col.prop(props, "search_domain") + + ro = col.row() + ro.scale_y = 1.25 + ro.prop(props, "query") + ro.operator("wm.sketchfab_search", text="", icon='VIEWZOOM') + + # User selected own models but is not pro + if props.search_domain == "OWN" and skfb_api.is_user_logged() and not skfb_api.is_user_pro(): + col.label(text='A PRO account is required', icon='QUESTION') + col.label(text='to access your personal library') + + # Display a collapsible box for filters + col = layout.box().column(align=True) + col.enabled = (props.search_domain != "STORE") + row = col.row() + row.prop(props, "expanded_filters", icon="TRIA_DOWN" if props.expanded_filters else "TRIA_RIGHT", icon_only=True, emboss=False) + row.label(text="Search filters") + if props.expanded_filters: + if props.search_domain in ["DEFAULT", "OWN"]: + col.separator() + col.prop(props, "categories") + col.prop(props, "sort_by") + col.prop(props, "face_count") + row = col.row() + row.prop(props, "pbr") + row.prop(props, "staffpick") + row.prop(props, "animated") + else: + col.separator() + col.prop(props, "sort_by") + col.prop(props, "face_count") + + pprops = get_sketchfab_props() + def draw_results(self, layout, context): props = get_sketchfab_props() col = layout.box().column(align=True) - #results = layout.column(align=True) - col.label(text=self.label) - - model = None + if not props.manualImportBoolean: - result_pages_ops = col.row() - if props.skfb_api.prev_results_url: - result_pages_ops.operator("wm.sketchfab_search_prev", text="Previous page", icon='FRAME_PREV') + #results = layout.column(align=True) + col.label(text=self.label) - if props.skfb_api.next_results_url: - result_pages_ops.operator("wm.sketchfab_search_next", text="Next page", icon='FRAME_NEXT') + model = None - #result_label = 'Click below to see more results' - #col.label(text=result_label, icon='INFO') - try: - col.template_icon_view(bpy.context.window_manager, 'result_previews', show_labels=True, scale=5.) - except Exception: - print('ResultsPanel: Failed to display results') - pass + result_pages_ops = col.row() + if props.skfb_api.prev_results_url: + result_pages_ops.operator("wm.sketchfab_search_prev", text="Previous page", icon='FRAME_PREV') - if 'current' not in props.search_results or not len(props.search_results['current']): - self.label = 'No results' - return - else: - self.label = "Search results" + if props.skfb_api.next_results_url: + result_pages_ops.operator("wm.sketchfab_search_next", text="Next page", icon='FRAME_NEXT') - if "current" in props.search_results: + #result_label = 'Click below to see more results' + #col.label(text=result_label, icon='INFO') + try: + col.template_icon_view(bpy.context.window_manager, 'result_previews', show_labels=True, scale=8) + except Exception: + print('ResultsPanel: Failed to display results') + pass - if bpy.context.window_manager.result_previews not in props.search_results['current']: + if 'current' not in props.search_results or not len(props.search_results['current']): + self.label = 'No results' return + else: + self.label = "Search results" - model = props.search_results['current'][bpy.context.window_manager.result_previews] + if "current" in props.search_results: - if not model: - return + if bpy.context.window_manager.result_previews not in props.search_results['current']: + return - if self.uid != model.uid: - self.uid = model.uid + model = props.search_results['current'][bpy.context.window_manager.result_previews] - if not model.info_requested: - props.skfb_api.request_model_info(model.uid) - model.info_requested = True + if not model: + return - draw_model_info(col, model, context) + if self.uid != model.uid: + self.uid = model.uid - def draw(self, context): - self.layout.enabled = get_plugin_enabled() + if not model.info_requested: + props.skfb_api.request_model_info(model.uid) + model.info_requested = True - draw_search(self.layout, context) + draw_model_info(col, model, context) + draw_import_button(col, model, context) + else: + uid = "" + if "sketchfab.com" in props.manualImportPath: + uid = props.manualImportPath[-32:] + m = Model(uid) + draw_import_button(col, m, context) + def draw(self, context): + self.layout.enabled = get_plugin_enabled() + self.draw_search(self.layout, context) self.draw_results(self.layout, context) def invoke(self, context, event): @@ -1285,13 +1699,17 @@ def draw(self, context): # Model properties col = layout.box().column(align=True) - col.prop(props, "title") - col.prop(props, "description") - col.prop(props, "tags") - col.prop(props, "draft") - col.prop(props, "private") - if props.private: - col.prop(props, "password") + if not props.reuploadBoolean: + col.prop(props, "title") + col.prop(props, "description") + col.prop(props, "tags") + col.prop(props, "draft") + col.prop(props, "private") + if props.private: + col.prop(props, "password") + col.prop(props, "reuploadBoolean") + if props.reuploadBoolean: + col.prop(props, "reuploadPath") # Project selection if member of an org if api.active_org and api.use_org_profile: @@ -1301,7 +1719,7 @@ def draw(self, context): # Upload button row = layout.row() row.scale_y = 2.0 - upload_label = "Upload" + upload_label = "Reupload" if props.reuploadBoolean else "Upload" upload_icon = "EXPORT" upload_enabled = api.is_user_logged() and bpy.context.mode == 'OBJECT' if not upload_enabled: @@ -1318,34 +1736,8 @@ def draw(self, context): if model_url: layout.operator("wm.url_open", text="View Online Model", icon='URL').url = model_url -def draw_results_icons(results, props, nbcol=4): - props = get_sketchfab_props() - current = props.search_results['current'] - - dimx = nbcol if current else 0 - dimy = int(24 / nbcol) if current else 0 - if dimx != 0 and dimy != 0: - for r in range(dimy): - ro = results.row() - for col in range(dimx): - col2 = ro.column(align=True) - index = r * dimx + col - if index >= len(current.keys()): - return - - model = current[list(current.keys())[index]] - - if model.uid in props.custom_icons: - col2.operator("wm.sketchfab_modelview", icon_value=props.custom_icons[model.uid].icon_id, text="{}".format(model.title + ' by ' + model.author)).uid = list(current.keys())[index] - else: - col2.operator("wm.sketchfab_modelview", text="{}".format(model.title + ' by ' + model.author)).uid = list(current.keys())[index] - else: - results.row() - results.row().label(text='No results') - results.row() - - class SketchfabLogger(bpy.types.Operator): + """Log in / out your Sketchab.com account""" bl_idname = 'wm.sketchfab_login' bl_label = 'Sketchfab Login' bl_options = {'INTERNAL'} @@ -1356,7 +1748,7 @@ def execute(self, context): set_login_status('FILE_REFRESH', 'Login to your Sketchfab account...') wm = context.window_manager if self.authenticate: - wm.sketchfab_browser.skfb_api.login(wm.sketchfab_api.email, wm.sketchfab_api.password) + wm.sketchfab_browser.skfb_api.login(wm.sketchfab_api.email, wm.sketchfab_api.password, wm.sketchfab_api.api_token) else: wm.sketchfab_browser.skfb_api.logout() wm.sketchfab_api.password = '' @@ -1369,6 +1761,7 @@ class SketchfabModel: def __init__(self, json_data): self.title = str(json_data['name']) self.author = json_data['user']['displayName'] + self.username = json_data['user']['username'] self.uid = json_data['uid'] self.vertex_count = json_data['vertexCount'] self.face_count = json_data['faceCount'] @@ -1391,8 +1784,15 @@ def __init__(self, json_data): self.time_url_requested = None self.url_expires = None +def ShowMessage(icon = "INFO", title = "Info", message = "Information"): + def draw(self, context): + self.layout.label(text=message) + print("\n{}: {}".format(icon, message)) + bpy.context.window_manager.popup_menu(draw, title = title, icon = icon) + class SketchfabDownloadModel(bpy.types.Operator): + """Import the selected model""" bl_idname = "wm.sketchfab_download" bl_label = "Downloading" bl_options = {'INTERNAL'} @@ -1406,6 +1806,7 @@ def execute(self, context): class ViewOnSketchfab(bpy.types.Operator): + """Upload your model to Sketchfab""" bl_idname = "wm.sketchfab_view" bl_label = "View the model on Sketchfab" bl_options = {'INTERNAL'} @@ -1427,6 +1828,9 @@ def clear_search(): class SketchfabSearch(bpy.types.Operator): + """Send a search query to Sketchfab + Searches on the selected domain (all site, own models for PRO+ users, organization...) + and takes into accounts various search filters""" bl_idname = "wm.sketchfab_search" bl_label = "Search Sketchfab" bl_options = {'INTERNAL'} @@ -1443,6 +1847,7 @@ def execute(self, context): class SketchfabSearchNextResults(bpy.types.Operator): + """Loads the next batch of 24 models from the search results""" bl_idname = "wm.sketchfab_search_next" bl_label = "Search Sketchfab" bl_options = {'INTERNAL'} @@ -1456,6 +1861,7 @@ def execute(self, context): class SketchfabSearchPreviousResults(bpy.types.Operator): + """Loads the previous batch of 24 models from the search results""" bl_idname = "wm.sketchfab_search_prev" bl_label = "Search Sketchfab" bl_options = {'INTERNAL'} @@ -1467,26 +1873,8 @@ def execute(self, context): skfb_api.search_cursor(skfb_api.prev_results_url, parse_results) return {'FINISHED'} - -class SketchfabOpenModel(bpy.types.Operator): - bl_idname = "wm.sketchfab_open" - bl_label = "Downloading" - bl_options = {'INTERNAL'} - - def execute(self, context): - return {'FINISHED'} - - def draw(self, context): - layout = self.layout - col = layout.column() - col.label(text="I'm downloading your model!") - - def invoke(self, context, event): - wm = context.window_manager - return wm.invoke_popup(self, width=550) - - class SketchfabCreateAccount(bpy.types.Operator): + """Create an account on sketchfab.com""" bl_idname = "wm.sketchfab_signup" bl_label = "Sketchfab" bl_options = {'INTERNAL'} @@ -1498,6 +1886,7 @@ def execute(self, context): class SketchfabNewVersion(bpy.types.Operator): + """Opens addon latest available release on github""" bl_idname = "wm.skfb_new_version" bl_label = "Sketchfab" bl_options = {'INTERNAL'} @@ -1509,6 +1898,7 @@ def execute(self, context): class SketchfabReportIssue(bpy.types.Operator): + """Open an issue on github tracker""" bl_idname = "wm.skfb_report_issue" bl_label = "Sketchfab" bl_options = {'INTERNAL'} @@ -1520,6 +1910,7 @@ def execute(self, context): class SketchfabHelp(bpy.types.Operator): + """Opens the addon README on github""" bl_idname = "wm.skfb_help" bl_label = "Sketchfab" bl_options = {'INTERNAL'} @@ -1543,6 +1934,12 @@ def activate_plugin(): props.skfb_api.access_token = cache_data['access_token'] props.skfb_api.build_headers() props.skfb_api.request_user_info() + props.skfb_api.use_mail = True + elif 'api_token' in cache_data: + props.skfb_api.api_token = cache_data['api_token'] + props.skfb_api.build_headers() + props.skfb_api.request_user_info() + props.skfb_api.use_mail = False global is_plugin_enabled is_plugin_enabled = True @@ -1556,6 +1953,7 @@ def activate_plugin(): class SketchfabEnable(bpy.types.Operator): + """Activate the addon (checks login, cache folders...)""" bl_idname = "wm.skfb_enable" bl_label = "Sketchfab" bl_options = {'INTERNAL'} @@ -1610,6 +2008,16 @@ class SketchfabExportProps(bpy.types.PropertyGroup): default="", maxlen=48 ) + reuploadBoolean : BoolProperty( + name="Reupload", + description="Reupload the model over an existing one", + default=False, + ) + reuploadPath : StringProperty( + name="Url", + description="Paste full model url to reupload to", + default="", + maxlen=1024) active_project : EnumProperty( name="Project", items=get_org_projects, @@ -1651,6 +2059,9 @@ def upload_report(report_message, report_type): # upload the blend-file to sketchfab def upload(filepath, filename): + props = get_sketchfab_props() + api = props.skfb_api + wm = bpy.context.window_manager props = wm.sketchfab_export @@ -1675,18 +2086,67 @@ def upload(filepath, filename): "modelFile": open(filepath, 'rb'), } - _headers = get_sketchfab_props().skfb_api.headers + _headers = api.headers - try: - api = get_sketchfab_props().skfb_api + uploadUrl = "" + modelUid = "" + requestFunction = requests.post + + # Are we reuploading ? + if props.reuploadBoolean: + + requestFunction = requests.put + + if "sketchfab.com/" not in props.reuploadPath: + return upload_report("reupload url is malformed %s" % props.reuploadPath, 'ERROR') + + # Get the model uid + try: + modelUid = props.reuploadPath[-32:] + if not Utils.is_valid_uuid(modelUid): + return upload_report("reupload url does not end with a valid uid (32 characters string): %s" % props.reuploadPath, 'ERROR') + except: + return upload_report("reupload url is malformed %s" % props.reuploadPath, 'ERROR') + + # If the model is in an org, find if the user has access to it + if "/orgs/" in props.reuploadPath: + if True:#try: + orgName = props.reuploadPath.split("/orgs/")[1].split("/")[0] + user_orgs = api.user_orgs + orgUid = "" + for org in user_orgs: + if org["username"] == orgName: + orgUid = org["uid"] + break + if orgUid: + uploadUrl = '{}/{}/models/{}'.format(Config.SKETCHFAB_ORGS, orgUid, modelUid) + else: + return upload_report("User does not appear to belong to org %s" % (orgName), 'ERROR') + else:#aexcept: + return upload_report("Cannot parse the org name from the url %s" % props.reuploadPath, 'ERROR') + # Otherwise, request a direct reupload + else: + uploadUrl = '{}/{}'.format(Config.SKETCHFAB_MODEL, modelUid) + + _data = { + "uid" : modelUid, + "source": "blender-exporter" + } + + else: + + # Org or not if len(api.user_orgs) and api.use_org_profile: - url = "%s/%s/models" % (Config.SKETCHFAB_ORGS, api.active_org["uid"]) + uploadUrl = "%s/%s/models" % (Config.SKETCHFAB_ORGS, api.active_org["uid"]) _data["orgProject"] = props.active_project else: - url = Config.SKETCHFAB_MODEL + uploadUrl = Config.SKETCHFAB_MODEL - r = requests.post( - url, + # Upload and parse the result + try: + print("Uploading to %s" % uploadUrl) + r = requestFunction( + uploadUrl, data = _data, files = _files, headers = _headers @@ -1694,11 +2154,15 @@ def upload(filepath, filename): except requests.exceptions.RequestException as e: return upload_report("Upload failed. Error: %s" % str(e), 'WARNING') - result = r.json() - if r.status_code != requests.codes.created: - return upload_report("Upload failed. Error: %s" % result["error"], 'WARNING') - sf_state.model_url = Config.SKETCHFAB_URL + "/models/" + result["uid"] - return upload_report("Upload complete. Available on your sketchfab.com dashboard.", 'INFO') + if r.status_code not in [requests.codes.ok, requests.codes.created, requests.codes.no_content]: + return upload_report("Upload failed. Error code: %s\nMessage:\n%s" % (str(r.status_code), str(r)), 'WARNING') + else: + try: + result = r.json() + sf_state.model_url = Config.SKETCHFAB_URL + "/models/" + result["uid"] + except: + sf_state.model_url = Config.SKETCHFAB_URL + "/models/" + modelUid + return upload_report("Upload complete. Available on your sketchfab.com dashboard.", 'INFO') class ExportSketchfab(bpy.types.Operator): @@ -1788,6 +2252,16 @@ def execute(self, context): self.report({'WARNING'}, "Error occured while preparing your file: %s" % str(e)) return {'FINISHED'} + # Check the generated file size against the user plans, to know if the upload will succeed + upload_limit = Config.SKETCHFAB_UPLOAD_LIMITS[get_sketchfab_props().skfb_api.plan_type] + if get_sketchfab_props().skfb_api.use_org_profile: + upload_limit = Config.SKETCHFAB_UPLOAD_LIMITS["enterprise"] + if size > upload_limit: + human_size_limit = Utils.humanify_size(upload_limit) + human_exported_size = Utils.humanify_size(size) + self.report({'ERROR'}, "Upload size is above your plan upload limit: %s > %s" % (human_exported_size, human_size_limit)) + return {'FINISHED'} + sf_state.uploading = True sf_state.size_label = Utils.humanify_size(size) self._thread = threading.Thread( @@ -1806,8 +2280,74 @@ def cancel(self, context): wm.event_timer_remove(self._timer) self._thread.join() +def get_temporary_path(): + + # Get the preferences cache directory + cachePath = bpy.context.preferences.addons[__name__.split('.')[0]].preferences.cachePath + + # The cachePath was set in the preferences + if cachePath: + return cachePath + else: + # Rely on Blender temporary directory + if bpy.app.version == (2, 79, 0): + if bpy.context.user_preferences.filepaths.temporary_directory: + return bpy.context.user_preferences.filepaths.temporary_directory + else: + return tempfile.mkdtemp() + else: + if bpy.context.preferences.filepaths.temporary_directory: + return bpy.context.preferences.filepaths.temporary_directory + else: + return tempfile.mkdtemp() + +def updateCacheDirectory(self, context): + + # Get the cache path from the preferences, or a default temporary + path = os.path.abspath(get_temporary_path()) + + # Delete the old directory + # Won't delete anything upon plugin intialization, only when switching path in preferences + if Config.SKETCHFAB_TEMP_DIR and os.path.exists(Config.SKETCHFAB_TEMP_DIR) and os.path.isdir(Config.SKETCHFAB_TEMP_DIR): + shutil.rmtree(Config.SKETCHFAB_TEMP_DIR) + + # Create the paths and directories for temporary directories + Config.SKETCHFAB_TEMP_DIR = os.path.join(path, "sketchfab_downloads") + Config.SKETCHFAB_THUMB_DIR = os.path.join(Config.SKETCHFAB_TEMP_DIR, 'thumbnails') + Config.SKETCHFAB_MODEL_DIR = os.path.join(Config.SKETCHFAB_TEMP_DIR, 'imports') + if not os.path.exists(Config.SKETCHFAB_TEMP_DIR): os.makedirs(Config.SKETCHFAB_TEMP_DIR) + if not os.path.exists(Config.SKETCHFAB_THUMB_DIR): os.makedirs(Config.SKETCHFAB_THUMB_DIR) + if not os.path.exists(Config.SKETCHFAB_MODEL_DIR): os.makedirs(Config.SKETCHFAB_MODEL_DIR) + +class SketchfabAddonPreferences(bpy.types.AddonPreferences): + bl_idname = __name__ + cachePath: StringProperty( + name="Cache folder", + description=( + "Temporary directory for downloads from sketchfab.com\n" + "Set by the OS by default, make sure to have write access\n" + "to this directory if you set it manually" + ), + subtype='DIR_PATH', + update=updateCacheDirectory + ) + downloadHistory : StringProperty( + name="Download history file", + description=( + ".csv file containing your downloads from sketchfab.com\n" + "If valid, the name, license and url of every model you\n" + "download through the plugin will be saved in this file" + ), + subtype='FILE_PATH' + ) + def draw(self, context): + layout = self.layout + layout.prop(self, "cachePath", text="Download directory") + layout.prop(self, "downloadHistory", text="Download history (.csv)") classes = ( + SketchfabAddonPreferences, + # Properties SketchfabBrowserProps, SketchfabLoginProps, @@ -1857,7 +2397,7 @@ def check_plugin_version(request, *args, **kwargs): def register(): sketchfab_icon = bpy.utils.previews.new() - icons_dir = os.path.join(os.path.dirname(__file__), "sketchfab", "resources") + icons_dir = os.path.join(os.path.dirname(__file__), "resources") sketchfab_icon.load("skfb", os.path.join(icons_dir, "logo.png"), 'IMAGE') sketchfab_icon.load("0", os.path.join(icons_dir, "placeholder.png"), 'IMAGE') @@ -1884,6 +2424,9 @@ def register(): type=SketchfabExportProps, ) + # If a cache path was set in preferences, use it + updateCacheDirectory(None, context=bpy.context) + def unregister(): for cls in classes: bpy.utils.unregister_class(cls) diff --git a/addons/io_sketchfab_plugin/blender/com/gltf2_blender_extras.py b/addons/io_sketchfab_plugin/blender/com/gltf2_blender_extras.py index 3ef8822..6c93e7b 100755 --- a/addons/io_sketchfab_plugin/blender/com/gltf2_blender_extras.py +++ b/addons/io_sketchfab_plugin/blender/com/gltf2_blender_extras.py @@ -18,7 +18,7 @@ # Custom properties, which are in most cases present and should not be imported/exported. -BLACK_LIST = ['cycles', 'cycles_visibility', 'cycles_curves', '_RNA_UI'] +BLACK_LIST = ['cycles', 'cycles_visibility', 'cycles_curves', 'glTF2ExportSettings'] def generate_extras(blender_element): diff --git a/addons/io_sketchfab_plugin/blender/com/gltf2_blender_math.py b/addons/io_sketchfab_plugin/blender/com/gltf2_blender_math.py index 9dcae66..31349dd 100644 --- a/addons/io_sketchfab_plugin/blender/com/gltf2_blender_math.py +++ b/addons/io_sketchfab_plugin/blender/com/gltf2_blender_math.py @@ -16,7 +16,7 @@ import math from mathutils import Matrix, Vector, Quaternion, Euler -from io_scene_gltf2.blender.com.gltf2_blender_data_path import get_target_property_name +from ...blender.com.gltf2_blender_data_path import get_target_property_name def list_to_mathutils(values: typing.List[float], data_path: str) -> typing.Union[Vector, Quaternion, Euler]: diff --git a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_KHR_materials_clearcoat.py b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_KHR_materials_clearcoat.py index 276f932..4629f3b 100644 --- a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_KHR_materials_clearcoat.py +++ b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_KHR_materials_clearcoat.py @@ -69,7 +69,7 @@ def clearcoat(mh, location, clearcoat_socket): ) -# [Texture] => [Seperate G] => [Roughness Factor] => +# [Texture] => [Separate G] => [Roughness Factor] => def clearcoat_roughness(mh, location, roughness_socket): x, y = location try: diff --git a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_animation_node.py b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_animation_node.py index 642515a..ac2c732 100644 --- a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_animation_node.py +++ b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_animation_node.py @@ -18,6 +18,7 @@ from ...io.imp.gltf2_io_binary import BinaryData from .gltf2_blender_animation_utils import make_fcurve from .gltf2_blender_vnode import VNode +from ...io.imp.gltf2_io_user_extensions import import_user_extensions class BlenderNodeAnim(): @@ -46,6 +47,8 @@ def do_channel(gltf, anim_idx, node_idx, channel): vnode = gltf.vnodes[node_idx] path = channel.target.path + import_user_extensions('gather_import_animation_channel_before_hook', gltf, animation, vnode, path, channel) + action = BlenderNodeAnim.get_or_create_action(gltf, node_idx, animation.track_name) keys = BinaryData.get_data_from_accessor(gltf, animation.samplers[channel.sampler].input) @@ -152,6 +155,8 @@ def do_channel(gltf, anim_idx, node_idx, channel): interpolation=animation.samplers[channel.sampler].interpolation, ) + import_user_extensions('gather_import_animation_channel_after_hook', gltf, animation, vnode, path, channel, action) + @staticmethod def get_or_create_action(gltf, node_idx, anim_name): vnode = gltf.vnodes[node_idx] diff --git a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_animation_utils.py b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_animation_utils.py index 688a785..c26b666 100644 --- a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_animation_utils.py +++ b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_animation_utils.py @@ -56,13 +56,18 @@ def make_fcurve(action, co, data_path, index=0, group_name='', interpolation=Non fcurve.keyframe_points.foreach_set('co', co) # Setting interpolation - ipo = { + ipoString = { 'CUBICSPLINE': 'BEZIER', 'LINEAR': 'LINEAR', 'STEP': 'CONSTANT', }[interpolation or 'LINEAR'] - ipo = bpy.types.Keyframe.bl_rna.properties['interpolation'].enum_items[ipo].value - fcurve.keyframe_points.foreach_set('interpolation', [ipo] * len(fcurve.keyframe_points)) + ipoInt = bpy.types.Keyframe.bl_rna.properties['interpolation'].enum_items[ipoString].value + try: + fcurve.keyframe_points.foreach_set('interpolation', [ipoInt] * len(fcurve.keyframe_points)) + except: + # Expects a string in some versions < 2.93 + for i in range(len(fcurve.keyframe_points)): + fcurve.keyframe_points[i].interpolation = ipoString # For CUBICSPLINE, also set the handle types to AUTO if interpolation == 'CUBICSPLINE': diff --git a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_animation_weight.py b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_animation_weight.py index 19723ed..e14cee8 100644 --- a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_animation_weight.py +++ b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_animation_weight.py @@ -16,6 +16,7 @@ from ...io.imp.gltf2_io_binary import BinaryData from .gltf2_blender_animation_utils import make_fcurve +from ...io.imp.gltf2_io_user_extensions import import_user_extensions class BlenderWeightAnim(): @@ -29,6 +30,9 @@ def anim(gltf, anim_idx, vnode_id): vnode = gltf.vnodes[vnode_id] node_idx = vnode.mesh_node_idx + + import_user_extensions('gather_import_animation_weight_before_hook', gltf, vnode, gltf.data.animations[anim_idx]) + if node_idx is None: return @@ -90,3 +94,5 @@ def anim(gltf, anim_idx, vnode_id): max_weight = max(coords[1:2]) if min_weight < kb.slider_min: kb.slider_min = min_weight if max_weight > kb.slider_max: kb.slider_max = max_weight + + import_user_extensions('gather_import_animation_weight_after_hook', gltf, vnode, animation) diff --git a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_camera.py b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_camera.py index cc73a69..a688a89 100644 --- a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_camera.py +++ b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_camera.py @@ -14,6 +14,7 @@ import bpy from ..com.gltf2_blender_extras import set_extras +from ...io.imp.gltf2_io_user_extensions import import_user_extensions class BlenderCamera(): @@ -22,10 +23,12 @@ def __new__(cls, *args, **kwargs): raise RuntimeError("%s should not be instantiated" % cls) @staticmethod - def create(gltf, camera_id): + def create(gltf, vnode, camera_id): """Camera creation.""" pycamera = gltf.data.cameras[camera_id] + import_user_extensions('gather_import_camera_before_hook', gltf, vnode, pycamera) + if not pycamera.name: pycamera.name = "Camera" @@ -55,5 +58,4 @@ def create(gltf, camera_id): # Infinite projection cam.clip_end = 1e12 # some big number - return cam diff --git a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_image.py b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_image.py index e2f78d8..fa8520d 100644 --- a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_image.py +++ b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_image.py @@ -20,6 +20,7 @@ import re from ...io.imp.gltf2_io_binary import BinaryData +from ...io.imp.gltf2_io_user_extensions import import_user_extensions # Note that Image is not a glTF2.0 object @@ -32,57 +33,76 @@ def __new__(cls, *args, **kwargs): def create(gltf, img_idx): """Image creation.""" img = gltf.data.images[img_idx] - img_name = img.name if img.blender_image_name is not None: # Image is already used somewhere return - tmp_dir = None - is_placeholder = False - try: - if img.uri is not None and not img.uri.startswith('data:'): - # Image stored in a file - path = join(dirname(gltf.filename), _uri_to_path(img.uri)) - img_name = img_name or basename(path) - - else: - # Image stored as data => create a tempfile, pack, and delete file - img_data = BinaryData.get_image_data(gltf, img_idx) - if img_data is None: - return - img_name = img_name or 'Image_%d' % img_idx - tmp_dir = tempfile.TemporaryDirectory(prefix='gltfimg-') - filename = _filenamify(img_name) or 'Image_%d' % img_idx - filename += _img_extension(img) - path = join(tmp_dir.name, filename) - with open(path, 'wb') as f: - f.write(img_data) - - num_images = len(bpy.data.images) - - try: - blender_image = bpy.data.images.load( - os.path.abspath(path), - check_existing=tmp_dir is None, - ) - except RuntimeError: - gltf.log.error("Missing image file (index %d): %s" % (img_idx, path)) - blender_image = _placeholder_image(img_name, os.path.abspath(path)) - is_placeholder = True - - if len(bpy.data.images) != num_images: # If created a new image - blender_image.name = img_name - - needs_pack = True - if not is_placeholder and needs_pack: - blender_image.pack() + import_user_extensions('gather_import_image_before_hook', gltf, img) + + if img.uri is not None and not img.uri.startswith('data:'): + blender_image = create_from_file(gltf, img_idx) + else: + blender_image = create_from_data(gltf, img_idx) + if blender_image: img.blender_image_name = blender_image.name - finally: - if tmp_dir is not None: - tmp_dir.cleanup() + import_user_extensions('gather_import_image_after_hook', gltf, img, blender_image) + + +def create_from_file(gltf, img_idx): + # Image stored in a file + + num_images = len(bpy.data.images) + + img = gltf.data.images[img_idx] + + path = join(dirname(gltf.filename), _uri_to_path(img.uri)) + path = os.path.abspath(path) + if bpy.data.is_saved and bpy.context.preferences.filepaths.use_relative_paths: + try: + path = bpy.path.relpath(path) + except: + # May happen on Windows if on different drives, eg. C:\ and D:\ + pass + + img_name = img.name or basename(path) + + try: + blender_image = bpy.data.images.load( + path, + check_existing=True, + ) + + needs_pack = True + if needs_pack: + blender_image.pack() + + except RuntimeError: + gltf.log.error("Missing image file (index %d): %s" % (img_idx, path)) + blender_image = _placeholder_image(img_name, os.path.abspath(path)) + + if len(bpy.data.images) != num_images: # If created a new image + blender_image.name = img_name + + return blender_image + + +def create_from_data(gltf, img_idx): + # Image stored as data => pack + img_data = BinaryData.get_image_data(gltf, img_idx) + if img_data is None: + return + img_name = 'Image_%d' % img_idx + + # Create image, width and height are dummy values + blender_image = bpy.data.images.new(img_name, 8, 8) + # Set packed file data + blender_image.pack(data=img_data.tobytes(), data_len=len(img_data)) + blender_image.source = 'FILE' + + return blender_image def _placeholder_image(name, path): image = bpy.data.images.new(name, 128, 128) @@ -94,14 +114,3 @@ def _placeholder_image(name, path): def _uri_to_path(uri): uri = urllib.parse.unquote(uri) return normpath(uri) - -def _img_extension(img): - if img.mime_type == 'image/png': - return '.png' - if img.mime_type == 'image/jpeg': - return '.jpg' - return '' - -def _filenamify(s): - s = s.strip().replace(' ', '_') - return re.sub(r'(?u)[^-\w.]', '', s) diff --git a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_light.py b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_light.py index 71990eb..6f001e1 100644 --- a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_light.py +++ b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_light.py @@ -16,6 +16,7 @@ from math import pi from ..com.gltf2_blender_extras import set_extras +from ...io.imp.gltf2_io_user_extensions import import_user_extensions class BlenderLight(): @@ -24,9 +25,12 @@ def __new__(cls, *args, **kwargs): raise RuntimeError("%s should not be instantiated" % cls) @staticmethod - def create(gltf, light_id): + def create(gltf, vnode, light_id): """Light creation.""" pylight = gltf.data.extensions['KHR_lights_punctual']['lights'][light_id] + + import_user_extensions('gather_import_light_before_hook', gltf, vnode, pylight) + if pylight['type'] == "directional": light = BlenderLight.create_directional(gltf, light_id) elif pylight['type'] == "point": diff --git a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_material.py b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_material.py index 5e49b7d..bbf8d39 100644 --- a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_material.py +++ b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_material.py @@ -18,6 +18,7 @@ from .gltf2_blender_pbrMetallicRoughness import MaterialHelper, pbr_metallic_roughness from .gltf2_blender_KHR_materials_pbrSpecularGlossiness import pbr_specular_glossiness from .gltf2_blender_KHR_materials_unlit import unlit +from ...io.imp.gltf2_io_user_extensions import import_user_extensions class BlenderMaterial(): @@ -30,6 +31,8 @@ def create(gltf, material_idx, vertex_color): """Material creation.""" pymaterial = gltf.data.materials[material_idx] + import_user_extensions('gather_import_material_before_hook', gltf, pymaterial, vertex_color) + name = pymaterial.name if name is None: name = "Material_" + str(material_idx) @@ -56,10 +59,11 @@ def create(gltf, material_idx, vertex_color): else: pbr_metallic_roughness(mh) + import_user_extensions('gather_import_material_after_hook', gltf, pymaterial, vertex_color, mat) + @staticmethod def set_double_sided(pymaterial, mat): mat.use_backface_culling = (pymaterial.double_sided != True) - mat.show_transparent_back = False @staticmethod def set_alpha_mode(pymaterial, mat): diff --git a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_mesh.py b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_mesh.py index 1c51255..9cd2a1d 100644 --- a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_mesh.py +++ b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_mesh.py @@ -21,6 +21,7 @@ from .gltf2_blender_material import BlenderMaterial from ...io.com.gltf2_io_debug import print_console from .gltf2_io_draco_compression_extension import decode_primitive +from ...io.imp.gltf2_io_user_extensions import import_user_extensions class BlenderMesh(): @@ -41,6 +42,9 @@ def create(gltf, mesh_idx, skin_idx): def create_mesh(gltf, mesh_idx, skin_idx): pymesh = gltf.data.meshes[mesh_idx] + + import_user_extensions('gather_import_mesh_before_hook', gltf, pymesh) + name = pymesh.name or 'Mesh_%d' % mesh_idx mesh = bpy.data.meshes.new(name) @@ -56,6 +60,8 @@ def create_mesh(gltf, mesh_idx, skin_idx): if tmp_ob: bpy.data.objects.remove(tmp_ob) + import_user_extensions('gather_import_mesh_after_hook', gltf, pymesh, mesh) + return mesh @@ -73,8 +79,9 @@ def do_primitives(gltf, mesh_idx, skin_idx, mesh, ob): if 'POSITION' not in prim.attributes: continue - if 'NORMAL' in prim.attributes: - has_normals = True + if True: #gltf.import_settings['import_shading'] == "NORMALS": + if 'NORMAL' in prim.attributes: + has_normals = True if skin_idx is not None: i = 0 @@ -223,12 +230,14 @@ def do_primitives(gltf, mesh_idx, skin_idx, mesh, ob): # Accessors are cached in case they are shared between primitives; clear # the cache now that all prims are done. gltf.decode_accessor_cache = {} - vert_locs, vert_normals, vert_joints, vert_weights, \ - sk_vert_locs, loop_vidxs, edge_vidxs = \ - merge_duplicate_verts( - vert_locs, vert_normals, vert_joints, vert_weights, \ - sk_vert_locs, loop_vidxs, edge_vidxs\ - ) + + if True: #gltf.import_settings['merge_vertices']: + vert_locs, vert_normals, vert_joints, vert_weights, \ + sk_vert_locs, loop_vidxs, edge_vidxs = \ + merge_duplicate_verts( + vert_locs, vert_normals, vert_joints, vert_weights, \ + sk_vert_locs, loop_vidxs, edge_vidxs\ + ) # --------------- # Convert all the arrays glTF -> Blender @@ -300,9 +309,9 @@ def do_primitives(gltf, mesh_idx, skin_idx, mesh, ob): # TODO: this is slow :/ if num_joint_sets: pyskin = gltf.data.skins[skin_idx] - for i, _ in enumerate(pyskin.joints): - # ob is a temp object, so don't worry about the name. - ob.vertex_groups.new(name='X%d' % i) + for i, node_idx in enumerate(pyskin.joints): + bone = gltf.vnodes[node_idx] + ob.vertex_groups.new(name=bone.blender_bone_name) vgs = list(ob.vertex_groups) @@ -543,6 +552,8 @@ def normalize_vecs(vectors): def set_poly_smoothing(gltf, pymesh, mesh, vert_normals, loop_vidxs): num_polys = len(mesh.polygons) + # assert gltf.import_settings['import_shading'] == "NORMALS" + # Try to guess which polys should be flat based on the fact that all the # loop normals for a flat poly are = the poly's normal. diff --git a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_node.py b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_node.py index ef410ad..cc9394f 100644 --- a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_node.py +++ b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_node.py @@ -19,6 +19,7 @@ from .gltf2_blender_camera import BlenderCamera from .gltf2_blender_light import BlenderLight from .gltf2_blender_vnode import VNode +from ...io.imp.gltf2_io_user_extensions import import_user_extensions class BlenderNode(): """Blender Node.""" @@ -35,7 +36,10 @@ def create_vnode(gltf, vnode_id): gltf.log.critical("Node %d of %d (id %s)", gltf.display_current_node, len(gltf.vnodes), vnode_id) if vnode.type == VNode.Object: - BlenderNode.create_object(gltf, vnode_id) + gltf_node = gltf.data.nodes[vnode_id] if isinstance(vnode_id, int) else None + import_user_extensions('gather_import_node_before_hook', gltf, vnode, gltf_node) + obj = BlenderNode.create_object(gltf, vnode_id) + import_user_extensions('gather_import_node_after_hook', gltf, vnode, gltf_node, obj) if vnode.is_arma: BlenderNode.create_bones(gltf, vnode_id) @@ -59,24 +63,32 @@ def create_object(gltf, vnode_id): elif vnode.camera_node_idx is not None: pynode = gltf.data.nodes[vnode.camera_node_idx] - cam = BlenderCamera.create(gltf, pynode.camera) + cam = BlenderCamera.create(gltf, vnode, pynode.camera) name = vnode.name or cam.name obj = bpy.data.objects.new(name, cam) + # Since we create the actual Blender object after the create call, we call the hook here + import_user_extensions('gather_import_camera_after_hook', gltf, vnode, obj, cam) + elif vnode.light_node_idx is not None: pynode = gltf.data.nodes[vnode.light_node_idx] - light = BlenderLight.create(gltf, pynode.extensions['KHR_lights_punctual']['light']) + light = BlenderLight.create(gltf, vnode, pynode.extensions['KHR_lights_punctual']['light']) name = vnode.name or light.name obj = bpy.data.objects.new(name, light) + # Since we create the actual Blender object after the create call, we call the hook here + import_user_extensions('gather_import_light_after_hook', gltf, vnode, obj, light) + elif vnode.is_arma: armature = bpy.data.armatures.new(vnode.arma_name) name = vnode.name or armature.name obj = bpy.data.objects.new(name, armature) else: + # Empty name = vnode.name or vnode.default_name obj = bpy.data.objects.new(name, None) + obj.empty_display_size = BlenderNode.calc_empty_display_size(gltf, vnode_id) vnode.blender_object = obj @@ -111,6 +123,17 @@ def create_object(gltf, vnode_id): return obj + @staticmethod + def calc_empty_display_size(gltf, vnode_id): + # Use min distance to parent/children to guess size + sizes = [] + vids = [vnode_id] + gltf.vnodes[vnode_id].children + for vid in vids: + vnode = gltf.vnodes[vid] + dist = vnode.trs()[0].length + sizes.append(dist * 0.4) + return max(min(sizes, default=1), 0.001) + @staticmethod def create_bones(gltf, arma_id): arma = gltf.vnodes[arma_id] @@ -145,8 +168,8 @@ def visit(id): # Depth-first walk arma_mat = vnode.editbone_arma_mat editbone.head = arma_mat @ Vector((0, 0, 0)) editbone.tail = arma_mat @ Vector((0, 1, 0)) - editbone.align_roll(arma_mat @ Vector((0, 0, 1)) - editbone.head) editbone.length = vnode.bone_length + editbone.align_roll(arma_mat @ Vector((0, 0, 1)) - editbone.head) if isinstance(id, int): pynode = gltf.data.nodes[id] @@ -184,6 +207,10 @@ def visit(id): # Depth-first walk @staticmethod def create_mesh_object(gltf, vnode): pynode = gltf.data.nodes[vnode.mesh_node_idx] + if not (0 <= pynode.mesh < len(gltf.data.meshes)): + # Avoid traceback for invalid gltf file: invalid reference to meshes array + # So return an empty blender object) + return bpy.data.objects.new(vnode.name or "Invalid Mesh Index", None) pymesh = gltf.data.meshes[pynode.mesh] # Key to cache the Blender mesh by. @@ -237,11 +264,6 @@ def setup_skinning(gltf, pynode, obj): # Armature/bones should have already been created. - # Create vertex groups for each joint - for node_idx in pyskin.joints: - bone = gltf.vnodes[node_idx] - obj.vertex_groups.new(name=bone.blender_bone_name) - # Create an Armature modifier first_bone = gltf.vnodes[pyskin.joints[0]] arma = gltf.vnodes[first_bone.bone_arma] diff --git a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_pbrMetallicRoughness.py b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_pbrMetallicRoughness.py index 1fb7648..b119a1a 100644 --- a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_pbrMetallicRoughness.py +++ b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_pbrMetallicRoughness.py @@ -62,7 +62,7 @@ def pbr_metallic_roughness(mh: MaterialHelper): mh, location=locs['emission'], color_socket=pbr_node.inputs['Emission'], - strength_socket=pbr_node.inputs['Emission Strength'], + strength_socket=pbr_node.inputs.get('Emission Strength', None) ) base_color( diff --git a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_scene.py b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_scene.py index d9dc909..327f6eb 100644 --- a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_scene.py +++ b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_scene.py @@ -17,6 +17,8 @@ from .gltf2_blender_node import BlenderNode from .gltf2_blender_animation import BlenderAnimation from .gltf2_blender_vnode import VNode, compute_vnodes +from ..com.gltf2_blender_extras import set_extras +from ...io.imp.gltf2_io_user_extensions import import_user_extensions class BlenderScene(): @@ -34,13 +36,24 @@ def create(gltf): if scene.render.engine not in ['CYCLES', 'BLENDER_EEVEE']: scene.render.engine = "BLENDER_EEVEE" + if gltf.data.scene is not None: + import_user_extensions('gather_import_scene_before_hook', gltf, gltf.data.scenes[gltf.data.scene], scene) + pyscene = gltf.data.scenes[gltf.data.scene] + set_extras(scene, pyscene.extras) + compute_vnodes(gltf) gltf.display_current_node = 0 # for debugging BlenderNode.create_vnode(gltf, 'root') + # User extensions before scene creation + import_user_extensions('gather_import_scene_after_nodes_hook', gltf, gltf.data.scenes[gltf.data.scene], scene) + + # User extensions after scene creation BlenderScene.create_animations(gltf) + import_user_extensions('gather_import_scene_after_animation_hook', gltf, gltf.data.scenes[gltf.data.scene], scene) + if bpy.context.mode != 'OBJECT': bpy.ops.object.mode_set(mode='OBJECT') BlenderScene.select_imported_objects(gltf) diff --git a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_texture.py b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_texture.py index ddeb5bf..d2590f0 100644 --- a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_texture.py +++ b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_texture.py @@ -16,8 +16,9 @@ from .gltf2_blender_image import BlenderImage from ..com.gltf2_blender_conversion import texture_transform_gltf_to_blender -from io_scene_gltf2.io.com.gltf2_io import Sampler -from io_scene_gltf2.io.com.gltf2_io_constants import TextureFilter, TextureWrap +from ...io.com.gltf2_io import Sampler +from ...io.com.gltf2_io_constants import TextureFilter, TextureWrap +from ...io.imp.gltf2_io_user_extensions import import_user_extensions def texture( mh, @@ -31,6 +32,9 @@ def texture( """Creates nodes for a TextureInfo and hooks up the color/alpha outputs.""" x, y = location pytexture = mh.gltf.data.textures[tex_info.index] + + import_user_extensions('gather_import_texture_before_hook', mh.gltf, pytexture, mh, tex_info, location, label, color_socket, alpha_socket, is_data) + if pytexture.sampler is not None: pysampler = mh.gltf.data.samplers[pytexture.sampler] else: @@ -166,6 +170,8 @@ def texture( # Outputs mh.node_tree.links.new(uv_socket, uv_map.outputs[0]) + import_user_extensions('gather_import_texture_after_hook', mh.gltf, pytexture, mh.node_tree, mh, tex_info, location, label, color_socket, alpha_socket, is_data) + def set_filtering(tex_img, pysampler): """Set the filtering/interpolation on an Image Texture from the glTf sampler.""" minf = pysampler.min_filter diff --git a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_vnode.py b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_vnode.py index 65c64af..ebbced7 100644 --- a/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_vnode.py +++ b/addons/io_sketchfab_plugin/blender/imp/gltf2_blender_vnode.py @@ -362,8 +362,7 @@ def pick_bind_pose(gltf): Pick the bind pose for all bones. Skinned meshes will be retargeted onto this bind pose during mesh creation. """ - guess_bind_pose = True # Matches gltf.import_settings['guess_original_bind_pose'] - if guess_bind_pose: + if True: # gltf.import_settings['guess_original_bind_pose'] # Record inverse bind matrices. We're going to milk them for information # about the original bind pose. inv_binds = {'root': Matrix.Identity(4)} @@ -391,7 +390,7 @@ def pick_bind_pose(gltf): vnode.bind_trans = Vector(vnode.base_trs[0]) vnode.bind_rot = Quaternion(vnode.base_trs[1]) - if guess_bind_pose: + if True: #gltf.import_settings['guess_original_bind_pose']: # Try to guess bind pose from inverse bind matrices if vnode_id in inv_binds and vnode.parent in inv_binds: # (bind matrix) = (parent bind matrix) (bind local). Solve for bind local... @@ -470,10 +469,11 @@ def temperance(gltf, bone_id, parent_rot): if child_locs: centroid = sum(child_locs, Vector((0, 0, 0))) rot = Vector((0, 1, 0)).rotation_difference(centroid) - # Snap to the local axes; required for local_rotation to be - # accurate when vnode has a non-uniform scaling. - # FORTUNE skips this, so it may look better, but may have errors. - rot = nearby_signed_perm_matrix(rot).to_quaternion() + if True: #gltf.import_settings['bone_heuristic'] == 'TEMPERANCE': + # Snap to the local axes; required for local_rotation to be + # accurate when vnode has a non-uniform scaling. + # FORTUNE skips this, so it may look better, but may have errors. + rot = nearby_signed_perm_matrix(rot).to_quaternion() return rot return parent_rot diff --git a/addons/io_sketchfab_plugin/blender/imp/gltf2_io_draco_compression_extension.py b/addons/io_sketchfab_plugin/blender/imp/gltf2_io_draco_compression_extension.py index bb6e502..47ac32e 100644 --- a/addons/io_sketchfab_plugin/blender/imp/gltf2_io_draco_compression_extension.py +++ b/addons/io_sketchfab_plugin/blender/imp/gltf2_io_draco_compression_extension.py @@ -14,10 +14,10 @@ from ctypes import * -from io_scene_gltf2.io.com.gltf2_io import BufferView -from io_scene_gltf2.io.imp.gltf2_io_binary import BinaryData +from ...io.com.gltf2_io import BufferView +from ...io.imp.gltf2_io_binary import BinaryData from ...io.com.gltf2_io_debug import print_console -from io_scene_gltf2.io.com.gltf2_io_draco_compression_extension import dll_path +from ...io.com.gltf2_io_draco_compression_extension import dll_path def decode_primitive(gltf, prim): diff --git a/addons/io_sketchfab_plugin/sketchfab/resources/logo.png b/addons/io_sketchfab_plugin/resources/logo.png similarity index 100% rename from addons/io_sketchfab_plugin/sketchfab/resources/logo.png rename to addons/io_sketchfab_plugin/resources/logo.png diff --git a/addons/io_sketchfab_plugin/sketchfab/resources/placeholder.png b/addons/io_sketchfab_plugin/resources/placeholder.png similarity index 100% rename from addons/io_sketchfab_plugin/sketchfab/resources/placeholder.png rename to addons/io_sketchfab_plugin/resources/placeholder.png diff --git a/addons/io_sketchfab_plugin/sketchfab/__init__.py b/addons/io_sketchfab_plugin/sketchfab/__init__.py deleted file mode 100644 index a0a3775..0000000 --- a/addons/io_sketchfab_plugin/sketchfab/__init__.py +++ /dev/null @@ -1,274 +0,0 @@ -""" -Copyright 2021 Sketchfab - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - https://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -""" - -import os -import bpy -import json -import shutil -import tempfile - - -class Config: - - # sometimes the path in preferences is empty - def get_temp_path(): - if bpy.app.version == (2, 79, 0): - if bpy.context.user_preferences.filepaths.temporary_directory: - return bpy.context.user_preferences.filepaths.temporary_directory - else: - return tempfile.mkdtemp() - else: - if bpy.context.preferences.filepaths.temporary_directory: - return bpy.context.preferences.filepaths.temporary_directory - else: - return tempfile.mkdtemp() - - ADDON_NAME = 'io_sketchfab' - GITHUB_REPOSITORY_URL = 'https://github.com/sketchfab/blender-plugin' - GITHUB_REPOSITORY_API_URL = 'https://api.github.com/repos/sketchfab/blender-plugin' - SKETCHFAB_REPORT_URL = 'https://help.sketchfab.com/hc/en-us/requests/new?type=exporters&subject=Blender+Plugin' - - SKETCHFAB_URL = 'https://sketchfab.com' - DUMMY_CLIENTID = 'hGC7unF4BHyEB0s7Orz5E1mBd3LluEG0ILBiZvF9' - SKETCHFAB_OAUTH = SKETCHFAB_URL + '/oauth2/token/?grant_type=password&client_id=' + DUMMY_CLIENTID - SKETCHFAB_API = 'https://api.sketchfab.com' - SKETCHFAB_SEARCH = SKETCHFAB_API + '/v3/search' - SKETCHFAB_MODEL = SKETCHFAB_API + '/v3/models' - SKETCHFAB_ORGS = SKETCHFAB_API + '/v3/orgs' - SKETCHFAB_SIGNUP = 'https://sketchfab.com/signup' - - BASE_SEARCH = SKETCHFAB_SEARCH + '?type=models&downloadable=true' - DEFAULT_FLAGS = '&staffpicked=true&sort_by=-staffpickedAt' - DEFAULT_SEARCH = SKETCHFAB_SEARCH + \ - '?type=models&downloadable=true' + DEFAULT_FLAGS - - SKETCHFAB_ME = '{}/v3/me'.format(SKETCHFAB_URL) - BASE_SEARCH_OWN_MODELS = SKETCHFAB_ME + '/search?type=models&downloadable=true' - PURCHASED_MODELS = SKETCHFAB_ME + "/models/purchases?" - - SKETCHFAB_PLUGIN_VERSION = '{}/releases'.format(GITHUB_REPOSITORY_API_URL) - # PATH management - SKETCHFAB_TEMP_DIR = os.path.join(get_temp_path(), 'sketchfab_downloads') - SKETCHFAB_THUMB_DIR = os.path.join(SKETCHFAB_TEMP_DIR, 'thumbnails') - SKETCHFAB_MODEL_DIR = os.path.join(SKETCHFAB_TEMP_DIR, 'imports') - - SKETCHFAB_CATEGORIES = (('ALL', 'All categories', 'All categories'), - ('animals-pets', 'Animals & Pets', 'Animals and Pets'), - ('architecture', 'Architecture', 'Architecture'), - ('art-abstract', 'Art & Abstract', 'Art & Abstract'), - ('cars-vehicles', 'Cars & vehicles', 'Cars & vehicles'), - ('characters-creatures', 'Characters & Creatures', 'Characters & Creatures'), - ('cultural-heritage-history', 'Cultural Heritage & History', 'Cultural Heritage & History'), - ('electronics-gadgets', 'Electronics & Gadgets', 'Electronics & Gadgets'), - ('fashion-style', 'Fashion & Style', 'Fashion & Style'), - ('food-drink', 'Food & Drink', 'Food & Drink'), - ('furniture-home', 'Furniture & Home', 'Furniture & Home'), - ('music', 'Music', 'Music'), - ('nature-plants', 'Nature & Plants', 'Nature & Plants'), - ('news-politics', 'News & Politics', 'News & Politics'), - ('people', 'People', 'People'), - ('places-travel', 'Places & Travel', 'Places & Travel'), - ('science-technology', 'Science & Technology', 'Science & Technology'), - ('sports-fitness', 'Sports & Fitness', 'Sports & Fitness'), - ('weapons-military', 'Weapons & Military', 'Weapons & Military')) - - SKETCHFAB_FACECOUNT = (('ANY', "All", ""), - ('10K', "Up to 10k", ""), - ('50K', "10k to 50k", ""), - ('100K', "50k to 100k", ""), - ('250K', "100k to 250k", ""), - ('250KP', "250k +", "")) - - SKETCHFAB_SORT_BY = (('RELEVANCE', "Relevance", ""), - ('LIKES', "Likes", ""), - ('VIEWS', "Views", ""), - ('RECENT', "Recent", "")) - - SKETCHFAB_SEARCH_DOMAIN = (('DEFAULT', "All site", "", 0), - ('OWN', "Own Models (PRO)", "", 1), - ('STORE', "Store purchases", "", 2)) - - MAX_THUMBNAIL_HEIGHT = 512 - - -class Utils: - def humanify_size(size): - suffix = 'B' - readable = size - - # Megabyte - if size > 1048576: - suffix = 'MB' - readable = size / 1048576.0 - # Kilobyte - elif size > 1024: - suffix = 'KB' - readable = size / 1024.0 - - readable = round(readable, 2) - return '{}{}'.format(readable, suffix) - - def humanify_number(number): - suffix = '' - readable = number - - if number > 1000000: - suffix = 'M' - readable = number / 1000000.0 - - elif number > 1000: - suffix = 'K' - readable = number / 1000.0 - - readable = round(readable, 2) - return '{}{}'.format(readable, suffix) - - def build_download_url(uid, use_org_profile=False, active_org=None): - if use_org_profile: - return '{}/{}/models/{}/download'.format(Config.SKETCHFAB_ORGS, active_org["uid"], uid) - else: - return '{}/{}/download'.format(Config.SKETCHFAB_MODEL, uid) - - - def thumbnail_file_exists(uid): - return os.path.exists(os.path.join(Config.SKETCHFAB_THUMB_DIR, '{}.jpeg'.format(uid))) - - - def clean_thumbnail_directory(): - if not os.path.exists(Config.SKETCHFAB_THUMB_DIR): - return - - from os import listdir - for file in listdir(Config.SKETCHFAB_THUMB_DIR): - os.remove(os.path.join(Config.SKETCHFAB_THUMB_DIR, file)) - - - def clean_downloaded_model_dir(uid): - shutil.rmtree(os.path.join(Config.SKETCHFAB_MODEL_DIR, uid)) - - - def get_thumbnail_url(thumbnails_json): - best_height = 0 - best_thumbnail = None - for image in thumbnails_json['images']: - if image['height'] <= Config.MAX_THUMBNAIL_HEIGHT and image['height'] > best_height: - best_height = image['height'] - best_thumbnail = image['url'] - - return best_thumbnail - - - def make_model_name(gltf_data): - if 'title' in gltf_data.asset.extras: - return gltf_data.asset.extras['title'] - - return 'GLTFModel' - - def setup_plugin(): - if not os.path.exists(Config.SKETCHFAB_THUMB_DIR): - os.makedirs(Config.SKETCHFAB_THUMB_DIR) - - def get_uid_from_thumbnail_url(thumbnail_url): - return thumbnail_url.split('/')[4] - - def get_uid_from_model_url(model_url, use_org_profile=False): - print("\nget_uid_from_model_url\n%s\n" % model_url) - return model_url.split('/')[7] if use_org_profile else model_url.split('/')[5] - - def get_uid_from_download_url(model_url): - return model_url.split('/')[6] - - def clean_node_hierarchy(objects, root_name): - """ - Removes the useless nodes in a hierarchy - TODO: Keep the transform (might impact Yup/Zup) - """ - # Find the parent object - root = None - for object in objects: - if object.parent is None: - root = object - if root is None: - return None - - # Go down its hierarchy until one child has multiple children, or a single mesh - # Keep the name while deleting objects in the hierarchy - diverges = False - while diverges==False: - children = root.children - if children is not None: - - if len(children)>1: - diverges = True - root.name = root_name - - if len(children)==1: - if children[0].type != "EMPTY": - diverges = True - root.name = root_name - if children[0].type == "MESH": # should always be the case - matrixcopy = children[0].matrix_world.copy() - children[0].parent = None - children[0].matrix_world = matrixcopy - bpy.data.objects.remove(root) - children[0].name = root_name - root = children[0] - - elif children[0].type == "EMPTY": - diverges = False - matrixcopy = children[0].matrix_world.copy() - children[0].parent = None - children[0].matrix_world = matrixcopy - bpy.data.objects.remove(root) - root = children[0] - else: - break - - # Select the root Empty node - root.select_set(True) - -class Cache: - SKETCHFAB_CACHE_FILE = os.path.join( - bpy.utils.user_resource("SCRIPTS", "sketchfab_cache", create=True), - ".cache" - ) # Use a user path to avoid permission-related errors - - def read(): - if not os.path.exists(Cache.SKETCHFAB_CACHE_FILE): - return {} - - with open(Cache.SKETCHFAB_CACHE_FILE, 'rb') as f: - data = f.read().decode('utf-8') - return json.loads(data) - - def get_key(key): - cache_data = Cache.read() - if key in cache_data: - return cache_data[key] - - def save_key(key, value): - cache_data = Cache.read() - cache_data[key] = value - with open(Cache.SKETCHFAB_CACHE_FILE, 'wb+') as f: - f.write(json.dumps(cache_data).encode('utf-8')) - - def delete_key(key): - cache_data = Cache.read() - if key in cache_data: - del cache_data[key] - - with open(Cache.SKETCHFAB_CACHE_FILE, 'wb+') as f: - f.write(json.dumps(cache_data).encode('utf-8')) diff --git a/build.sh b/build.sh index 17c98bf..30a0215 100755 --- a/build.sh +++ b/build.sh @@ -10,6 +10,9 @@ # # Make a symlink in blender from Powershell: # cmd /c mklink /d 'C:/Users/Norgeotloic/AppData/Roaming/Blender Foundation/Blender/2.79/scripts/addons/io_sketchfab_plugin' C:\Users\Norgeotloic\Documents\blender-plugin/ +# +# Check the patch +# git apply -v --reject --whitespace=fix --check ../khronos-gltf.patch # Get the plugin version version=$(cat addons/io_sketchfab_plugin/__init__.py | grep "'version': " | grep -o '(.*)' | tr -d '() ' | sed 's/,/-/g') diff --git a/glTF-Blender-IO b/glTF-Blender-IO index 7096305..6f9d0d9 160000 --- a/glTF-Blender-IO +++ b/glTF-Blender-IO @@ -1 +1 @@ -Subproject commit 709630548cdc184af6ea50b2ff3ddc5450bc0af3 +Subproject commit 6f9d0d9fc1bb30e2b0bb019342ffe86bd67358fc diff --git a/khronos-gltf.patch b/khronos-gltf.patch index 58c13db..c93f0a1 100644 --- a/khronos-gltf.patch +++ b/khronos-gltf.patch @@ -46,10 +46,10 @@ index 666fdf3f..81f88469 100644 +from .gltf2_io_gltf import * \ No newline at end of file diff --git a/addons/io_scene_gltf2/io/imp/gltf2_io_gltf.py b/addons/io_scene_gltf2/io/imp/gltf2_io_gltf.py -index aa9bda38..f8dca120 100644 +index 407afccd..d776d353 100644 --- a/addons/io_scene_gltf2/io/imp/gltf2_io_gltf.py +++ b/addons/io_scene_gltf2/io/imp/gltf2_io_gltf.py -@@ -30,19 +30,14 @@ class ImportError(RuntimeError): +@@ -30,20 +30,15 @@ class ImportError(RuntimeError): class glTFImporter(): """glTF Importer class.""" @@ -62,21 +62,14 @@ index aa9bda38..f8dca120 100644 self.buffers = {} self.accessor_cache = {} self.decode_accessor_cache = {} +- self.import_user_extensions = import_settings['import_user_extensions'] - - if 'loglevel' not in self.import_settings.keys(): - self.import_settings['loglevel'] = logging.ERROR - - log = Log(import_settings['loglevel']) ++ self.import_user_extensions = [] + log = Log(loglevel) self.log = log.logger self.log_handler = log.hdlr -@@ -139,7 +134,7 @@ class glTFImporter(): - if not isfile(self.filename): - raise ImportError("Please select a file") - -- with open(self.filename, 'rb') as f: -+ with open(self.filename, 'rb') as f: - content = memoryview(f.read()) - - if content[:4] == b'glTF':