Skip to content

Commit 50171e3

Browse files
committed
Initial commit
0 parents  commit 50171e3

File tree

154 files changed

+35981
-0
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

154 files changed

+35981
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
__pycache__/
2+
*.pyc
3+
/Builds/

README.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
![Lily Surface Scrapper](doc/imported.png)
2+
3+
Lily Surface Scrapper
4+
=====================
5+
6+
There are many sources for getting PBR textures on the Internet, but it is always a repetitive task to setup the shader once in Blender. Some sources provide an add-on to automatically handle this, but it remains painful to install a new add-on for each of them, and learn over how they slightly differ from the other one.
7+
8+
LilySurfaceScrapper suggest a very intuitive and unified workflow: browse your favorite library in your browser. Once you have made your choice, just copy the URL of the page and paste it in Blender. The script will prompt for potential variants if some are detected, then download the maps and setup the material.
9+
10+
This add-on has been designed to make it very easy to add new sources. You can just add a script in the `Scrappers/` directory, it will be automatically detected. See bellow for more information.
11+
12+
## Installation
13+
14+
Download the [last release](https://github.com/eliemichel/LilySurfaceScrapper/releases), then in Blender, go to `Edit > Preferences`, `Add-on`, `Install`, browse to the zip file.
15+
16+
![Add-on loaded in the User Preferences](doc/addon.png)
17+
18+
## Usage
19+
20+
1. Browse the website of the source of your choice, until you find a texture, say https://cc0textures.com/view.php?tex=Metal01
21+
2. Copy this URL
22+
23+
3. If you just opened Blender and did not save yet, the add-on is not available:
24+
25+
![Add-on loaded in the User Preferences](doc/not-saved.png)
26+
27+
4. Save your file, this is needed because the textures will be loaded in a directory called `LilySurface` next to it.
28+
29+
![Add-on loaded in the User Preferences](doc/saved.png)
30+
31+
5. Paste the URL of the texture:
32+
33+
![Add-on loaded in the User Preferences](doc/paste-url.png)
34+
35+
6. Select the variant, if there is more than one available on the page:
36+
37+
![Add-on loaded in the User Preferences](doc/select-variant.png)
38+
39+
You can then browse the downloaded files next to your blend. Note that they are not downloaded twice if you use the same URL and variant again.
40+
41+
![Add-on loaded in the User Preferences](doc/files.png)
42+
43+
44+
## Supported sources
45+
46+
There are currently two supported sources:
47+
48+
- cgbookcase: https://www.cgbookcase.com/
49+
- CC0Textures: https://cc0textures.com/
50+
51+
## Adding new sources
52+
53+
I tried to make it as easy as possible to add new sources of data. The only thing to do is to add a python file in `Scrappers/` and define in it a class deriving from `AbstractScrapper`.
54+
55+
You can start from a copy of [`Cc0texturesScrapper.py`](Scrappers/Cc0texturesScrapper.py) or [`CgbookcaseScrapper.py`](Scrappers/CgbookcaseScrapper.py). The former loads a zip and extracts maps while the second looks for a different URL for each map (base color, normal, etc.).
56+
57+
The following three methods are required:
58+
59+
### canHandleUrl(cls, url)
60+
61+
A static method (just add `@staticmethod` before its definition) that returns `True` only if the scrapper recognizes the URL `url`.
62+
63+
### fetchVariantList(self, url)
64+
65+
Return a list of variant names (a list of strings), to prompt the user. This is useful when a single page provides several versions of the material, like different resolutions (2K, 4K, etc.) or front/back textures.
66+
67+
This method may save info like the html page in `self`, to reuse it in `fetchVariant`.
68+
69+
### fetchVariant(self, variant_index, material_data)
70+
71+
Scrap the information of the variant numbered `variant_index`, and write it to `material_data`. The following fields of `material_data` can be filled:
72+
73+
- `material_data.name`: The name of the texture, typically prefixed by the source, followed by the texture name, then the variant name.
74+
- `material_data.maps['baseColor']`: The path to the base color map, or None
75+
- `material_data.maps['normal']`: The path to the normal map, or None
76+
- `material_data.maps['opacity']`: The path to the opacity map, or None
77+
- `material_data.maps['roughness']`: The path to the roughness map, or None
78+
- `material_data.maps['metallic']`: The path to the metallic map, or None
79+
80+
To implement these methods, you can rely on the following utils:
81+
82+
### self.fetchHtml(url)
83+
84+
Get the url as a [lxml.etree](https://lxml.de/tutorial.html) object. You can then call the `xpath()` method to explore the page using the very convenient [xpath synthax](https://en.wikipedia.org/wiki/XPath#Examples).
85+
86+
### fetchImage(self, url, material_name, map_name)
87+
88+
Get an image from the URL `url`, place it in a directory whose name is generated from the `material_name`, and call the map `map_name` + extension (if an extension is explicit in the URL). The function returns the path to the downloaded texture, and you can directly provide it to `material_data.maps[...]`.
89+
90+
### fetchZip(self, url, material_name, zip_name)
91+
92+
Get a zip file from the URL `url`. This works like `fetchImage()`, returning the path to the zip file. You can then use the [zipfile](https://docs.python.org/3/library/zipfile.html) module, like [`Cc0texturesScrapper.py`](Scrappers/Cc0texturesScrapper.py) does.
93+
94+
## TODO
95+
96+
- Handle bump map
97+
- Handle AO map
98+
- Include `lxml` more properly. I don't know the idiomatic way to package such a dependency in a Blender addon.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Copyright (c) 2019 Elie Michel
2+
#
3+
# Permission is hereby granted, free of charge, to any person obtaining a copy
4+
# of this software and associated documentation files (the “Software”), to deal
5+
# in the Software without restriction, including without limitation the rights
6+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
# copies of the Software, and to permit persons to whom the Software is
8+
# furnished to do so, subject to the following conditions:
9+
#
10+
# The above copyright notice and this permission notice shall be included in
11+
# all copies or substantial portions of the Software.
12+
#
13+
# The Software is provided “as is”, without warranty of any kind, express or
14+
# implied, including but not limited to the warranties of merchantability,
15+
# fitness for a particular purpose and noninfringement. In no event shall
16+
# the authors or copyright holders be liable for any claim, damages or other
17+
# liability, whether in an action of contract, tort or otherwise, arising from,
18+
# out of or in connection with the software or the use or other dealings in the
19+
# Software.
20+
#
21+
# This file is part of LilySurfaceScrapper, a Blender add-on to import materials
22+
# from a single URL
23+
24+
import os
25+
import bpy
26+
from mathutils import Vector
27+
from .MaterialData import MaterialData
28+
29+
class CyclesMaterialData(MaterialData):
30+
def _getCyclesImage(self, imgpath):
31+
"""Avoid reloading an image that has already been loaded"""
32+
for img in bpy.data.images:
33+
if os.path.abspath(img.filepath) == os.path.abspath(imgpath):
34+
return img
35+
return bpy.data.images.load(imgpath)
36+
37+
def _autoAlignNodes(self, root):
38+
def makeTree(node):
39+
descendentCount = 0
40+
children = []
41+
for i in node.inputs:
42+
for l in i.links:
43+
subtree = makeTree(l.from_node)
44+
children.append(subtree)
45+
descendentCount += subtree[2] + 1
46+
return node, children, descendentCount
47+
48+
tree = makeTree(root)
49+
50+
def placeNodes(tree, rootLocation, xstep = 400, ystep = 250):
51+
root, children, count = tree
52+
root.location = rootLocation
53+
childLoc = rootLocation + Vector((-xstep, ystep * count / 2.))
54+
acc = 0.25
55+
for child in children:
56+
print(child[0].name, acc)
57+
acc += (child[2]+1)/2.
58+
placeNodes(child, childLoc + Vector((0, -ystep * acc)))
59+
acc += (child[2]+1)/2.
60+
61+
placeNodes(tree, Vector((0,0)))
62+
63+
64+
def createMaterial(self):
65+
mat = bpy.data.materials.new(name=self.name)
66+
mat.use_nodes = True
67+
nodes = mat.node_tree.nodes
68+
links = mat.node_tree.links
69+
principled = nodes["Principled BSDF"]
70+
mat_output = nodes["Material Output"]
71+
principled.inputs["Roughness"].default_value = 1.0
72+
# Translate our internal map names into cycles principled inputs
73+
input_tr = {
74+
'baseColor': 'Base Color',
75+
'normal': 'Normal',
76+
'roughness': 'Roughness',
77+
'metallic': 'Metallic',
78+
'opacity': '<custom>',
79+
}
80+
for input, img in self.maps.items():
81+
if img is None or input not in input_tr:
82+
continue
83+
texture_node = nodes.new(type="ShaderNodeTexImage")
84+
texture_node.image = self._getCyclesImage(img)
85+
texture_node.color_space = 'COLOR' if input == 'baseColor' else 'NONE'
86+
if input == 'opacity':
87+
transparence_node = nodes.new(type="ShaderNodeBsdfTransparent")
88+
mix_node = nodes.new(type="ShaderNodeMixShader")
89+
links.new(texture_node.outputs[0], mix_node.inputs[0])
90+
links.new(transparence_node.outputs[0], mix_node.inputs[1])
91+
links.new(principled.outputs[0], mix_node.inputs[2])
92+
links.new(mix_node.outputs[0], mat_output.inputs[0])
93+
elif input == "normal":
94+
normal_node = nodes.new(type="ShaderNodeNormalMap")
95+
links.new(texture_node.outputs["Color"], normal_node.inputs["Color"])
96+
links.new(normal_node.outputs["Normal"], principled.inputs["Normal"])
97+
else:
98+
links.new(texture_node.outputs[0], principled.inputs[input_tr[input]])
99+
100+
self._autoAlignNodes(mat.node_tree.nodes['Material Output'])
101+
102+
return mat
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Copyright (c) 2019 Elie Michel
2+
#
3+
# Permission is hereby granted, free of charge, to any person obtaining a copy
4+
# of this software and associated documentation files (the “Software”), to deal
5+
# in the Software without restriction, including without limitation the rights
6+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
# copies of the Software, and to permit persons to whom the Software is
8+
# furnished to do so, subject to the following conditions:
9+
#
10+
# The above copyright notice and this permission notice shall be included in
11+
# all copies or substantial portions of the Software.
12+
#
13+
# The Software is provided “as is”, without warranty of any kind, express or
14+
# implied, including but not limited to the warranties of merchantability,
15+
# fitness for a particular purpose and noninfringement. In no event shall
16+
# the authors or copyright holders be liable for any claim, damages or other
17+
# liability, whether in an action of contract, tort or otherwise, arising from,
18+
# out of or in connection with the software or the use or other dealings in the
19+
# Software.
20+
#
21+
# This file is part of LilySurfaceScrapper, a Blender add-on to import materials
22+
# from a single URL
23+
24+
import os
25+
26+
from .Scrappers.AbstractScrapper import AbstractScrapper
27+
from .settings import TEXTURE_DIR, UNSUPPORTED_PROVIDER_ERR
28+
29+
# dirty but useful, for one to painlessly write scrapping class
30+
# and just drop them in the scrappers dir
31+
def makeScrappersList():
32+
import importlib
33+
scrappers_names = []
34+
scrappers_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "Scrappers")
35+
for f in os.listdir(scrappers_dir):
36+
if f.endswith(".py") and os.path.isfile(os.path.join(scrappers_dir, f)):
37+
scrappers_names.append(f[:-3])
38+
scrappers = []
39+
for s in scrappers_names:
40+
module = importlib.import_module('.Scrappers.' + s, package='LilySurfaceScrapper')
41+
for x in dir(module):
42+
if x == 'AbstractScrapper':
43+
continue
44+
m = getattr(module, x)
45+
if isinstance(m, type) and issubclass(m, AbstractScrapper):
46+
scrappers.append(m)
47+
return scrappers
48+
all_scrappers = makeScrappersList()
49+
50+
class MaterialData():
51+
"""Internal representation of materials, responsible on one side for
52+
scrapping texture providers and on the other side to build blender materials.
53+
This class must not use the Blender API. Put Blender related stuff in subclasses
54+
like CyclesMaterialData."""
55+
56+
@classmethod
57+
def makeScrapper(cls, url):
58+
global all_scrappers
59+
for S in all_scrappers:
60+
if S.canHandleUrl(url):
61+
return S()
62+
return None
63+
64+
def __init__(self, url, texture_root=""):
65+
"""url: Base url to scrap
66+
texture_root: root directory where to store downloaded textures
67+
"""
68+
self.url = url
69+
self.name = "Name"
70+
self.error = None
71+
self.texture_root = texture_root
72+
self.maps = {
73+
'baseColor': None,
74+
'normal': None,
75+
'opacity': None,
76+
'roughness': None,
77+
'metallic': None,
78+
}
79+
self._variants = None
80+
self._scrapper = MaterialData.makeScrapper(url)
81+
if self._scrapper is None:
82+
self.error = UNSUPPORTED_PROVIDER_ERR
83+
else:
84+
self._scrapper.texture_root = texture_root
85+
86+
def getVariantList(self):
87+
if self.error is not None:
88+
return None
89+
if self._variants is not None:
90+
return self._variants
91+
self._variants = self._scrapper.fetchVariantList(self.url)
92+
if self._variants is None:
93+
self.error = self.scrapper.error
94+
return self._variants
95+
96+
def selectVariant(self, variant_index):
97+
if self.error is not None:
98+
return False
99+
if self._variants is None:
100+
self.getVariantList()
101+
if not self._scrapper.fetchVariant(variant_index, self):
102+
return False
103+
return True
104+
105+
def createMaterial(self):
106+
"""Implement this in derived classes"""
107+
raise NotImplementedError
108+

0 commit comments

Comments
 (0)