Spaces:
Running
on
A100
Running
on
A100
# -*- coding: utf-8 -*- | |
"""Load LDraw GPLv2 license. | |
This program is free software; you can redistribute it and/or | |
modify it under the terms of the GNU General Public License | |
as published by the Free Software Foundation; either version 2 | |
of the License, or (at your option) any later version. | |
This program is distributed in the hope that it will be useful, | |
but WITHOUT ANY WARRANTY; without even the implied warranty of | |
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
GNU General Public License for more details. | |
You should have received a copy of the GNU General Public License | |
along with this program; if not, write to the Free Software Foundation, | |
Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |
""" | |
""" | |
Import LDraw | |
This module loads LDraw compatible files into Blender. Set the | |
Options first, then call loadFromFile() function with the full | |
filepath of a file to load. | |
Accepts .io, .mpd, .ldr, .l3b, and .dat files. | |
Toby Nelson - tobymnelson@gmail.com | |
""" | |
import os | |
import sys | |
import math | |
import mathutils | |
import traceback | |
import glob | |
import bpy | |
import datetime | |
import struct | |
import re | |
import bmesh | |
import copy | |
import platform | |
import itertools | |
import operator | |
import zipfile | |
import tempfile | |
from pprint import pprint | |
# ************************************************************************************** | |
def linkToScene(ob): | |
if bpy.context.collection.objects.find(ob.name) < 0: | |
bpy.context.collection.objects.link(ob) | |
# ************************************************************************************** | |
def linkToCollection(collectionName, ob): | |
# Add object to the appropriate collection | |
if hasCollections: | |
if bpy.data.collections[collectionName].objects.find(ob.name) < 0: | |
bpy.data.collections[collectionName].objects.link(ob) | |
else: | |
bpy.data.groups[collectionName].objects.link(ob) | |
# ************************************************************************************** | |
def unlinkFromScene(ob): | |
if bpy.context.collection.objects.find(ob.name) >= 0: | |
bpy.context.collection.objects.unlink(ob) | |
# ************************************************************************************** | |
def selectObject(ob): | |
ob.select_set(state=True) | |
bpy.context.view_layer.objects.active = ob | |
# ************************************************************************************** | |
def deselectObject(ob): | |
ob.select_set(state=False) | |
bpy.context.view_layer.objects.active = None | |
# ************************************************************************************** | |
def addPlane(location, size): | |
bpy.ops.mesh.primitive_plane_add(size=size, enter_editmode=False, location=location) | |
# ************************************************************************************** | |
def useDenoising(scene, useDenoising): | |
if hasattr(getLayers(scene)[0], "cycles"): | |
getLayers(scene)[0].cycles.use_denoising = useDenoising | |
# ************************************************************************************** | |
def getLayerNames(scene): | |
return list(map((lambda x: x.name), getLayers(scene))) | |
# ************************************************************************************** | |
def deleteEdge(bm, edge): | |
bmesh.ops.delete(bm, geom=edge, context='EDGES') | |
# ************************************************************************************** | |
def getLayers(scene): | |
# Get the render/view layers we are interested in: | |
return scene.view_layers | |
# ************************************************************************************** | |
def getDiffuseColor(color): | |
return color + (1.0,) | |
# ************************************************************************************** | |
def ShowMessageBox(message = "", title = "Message Box", icon = 'INFO'): | |
def draw(self, context): | |
self.layout.label(text=message) | |
bpy.context.window_manager.popup_menu(draw, title = title, icon = icon) | |
# ************************************************************************************** | |
# ************************************************************************************** | |
class Options: | |
"""User Options""" | |
# Full filepath to ldraw folder. If empty, some standard locations are attempted | |
ldrawDirectory = r"" # Full filepath to the ldraw parts library (searches some standard locations if left blank) | |
instructionsLook = False # Set up scene to look like Lego Instruction booklets | |
#scale = 0.01 # Size of the lego model to create. (0.04 is LeoCAD scale) | |
realScale = 1 # Scale of lego model to create (1 represents real Lego scale) | |
useUnofficialParts = True # Additionally searches <ldraw-dir>/unofficial/parts and /p for files | |
resolution = "Standard" # Choose from "High", "Standard", or "Low" | |
defaultColour = "4" # Default colour ("4" = red) | |
createInstances = True # Multiple bricks share geometry (recommended) | |
useColourScheme = "lgeo" # "ldraw", "alt", or "lgeo". LGEO gives the most true-to-life colours. | |
numberNodes = True # Each node's name has a numerical prefix eg. 00001_car.dat (keeps nodes listed in a fixed order) | |
removeDoubles = True # Remove duplicate vertices (recommended) | |
smoothShading = True # Smooth the surface normals (recommended) | |
edgeSplit = True # Edge split modifier (recommended if you use smoothShading) | |
gaps = True # Introduces a tiny space between each brick | |
realGapWidth = 0.0002 # Width of gap between bricks (in metres) | |
curvedWalls = True # Manipulate normals to make surfaces look slightly concave | |
importCameras = True # LeoCAD can specify cameras within the ldraw file format. Choose to load them or ignore them. | |
positionObjectOnGroundAtOrigin = True # Centre the object at the origin, sitting on the z=0 plane | |
flattenHierarchy = False # All parts are under the root object - no sub-models | |
minifigHierarchy = True # Parts of minifigs are automatically parented to each other in a hierarchy | |
flattenGroups = False # All LEOCad groups are ignored - no groups | |
usePrincipledShaderWhenAvailable = True # Use the new principled shader | |
scriptDirectory = os.path.dirname( os.path.realpath(__file__) ) | |
# We have the option of including the 'LEGO' logo on each stud | |
useLogoStuds = False # Use the studs with the 'LEGO' logo on them | |
logoStudVersion = "4" # Which version of the logo to use ("3" (flat), "4" (rounded) or "5" (subtle rounded)) | |
instanceStuds = False # Each stud is a new Blender object (slow) | |
# LSynth (http://www.holly-wood.it/lsynth/tutorial-en.html) is a collection of parts used to render string, hoses, cables etc | |
useLSynthParts = True # LSynth is used to render string, hoses etc. | |
LSynthDirectory = r"" # Full path to the lsynth parts (Defaults to <ldrawdir>/unofficial/lsynth if left blank) | |
studLogoDirectory = r"" # Optional full path to the stud logo parts (if not found in unofficial directory) | |
# Ambiguous Normals | |
# Older LDraw parts (parts not yet BFC certified) have ambiguous normals. | |
# We resolve this by creating double sided faces ("double") or by taking a best guess ("guess") | |
resolveAmbiguousNormals = "guess" # How to resolve ambiguous normals | |
overwriteExistingMaterials = True # If there's an existing material with the same name, do we overwrite it, or use it? | |
overwriteExistingMeshes = True # If there's an existing mesh with the same name, do we overwrite it, or use it? | |
verbose = 1 # 1 = Show messages while working, 0 = Only show warnings/errors | |
addBevelModifier = True # Adds a bevel modifier to each part (for rounded edges) | |
bevelWidth = 0.5 # Width of bevel | |
addWorldEnvironmentTexture = True # Add an environment texture | |
addGroundPlane = True # Add a ground plane | |
setRenderSettings = True # Set render percentage, denoising | |
removeDefaultObjects = True # Remove cube and lamp | |
positionCamera = True # Position the camera where so we get the whole object in shot | |
cameraBorderPercent = 0.05 # Add a border gap around the positioned object (0.05 = 5%) for the rendered image | |
def meshOptionsString(): | |
"""These options change the mesh, so if they change, a new mesh needs to be cached""" | |
return "_".join([str(Options.realScale), | |
str(Options.useUnofficialParts), | |
str(Options.instructionsLook), | |
str(Options.resolution), | |
str(Options.defaultColour), | |
str(Options.createInstances), | |
str(Options.useColourScheme), | |
str(Options.removeDoubles), | |
str(Options.smoothShading), | |
str(Options.gaps), | |
str(Options.realGapWidth), | |
str(Options.curvedWalls), | |
str(Options.flattenHierarchy), | |
str(Options.minifigHierarchy), | |
str(Options.useLogoStuds), | |
str(Options.logoStudVersion), | |
str(Options.instanceStuds), | |
str(Options.useLSynthParts), | |
str(Options.LSynthDirectory), | |
str(Options.studLogoDirectory), | |
str(Options.resolveAmbiguousNormals), | |
str(Options.addBevelModifier), | |
str(Options.bevelWidth)]) | |
# ************************************************************************************** | |
# Globals | |
globalBrickCount = 0 | |
globalObjectsToAdd = [] # Blender objects to add to the scene | |
globalCamerasToAdd = [] # Camera data to add to the scene | |
globalContext = None | |
globalPoints = [] | |
globalScaleFactor = 0.0004 | |
globalWeldDistance = 0.0005 | |
hasCollections = None | |
lightName = "Light" | |
# ************************************************************************************** | |
# Dictionary with as keys the part numbers (without any extension for decorations) | |
# of pieces that have grainy slopes, and as values a set containing the angles (in | |
# degrees) of the face's normal to the horizontal plane. Use a tuple to represent a | |
# range within which the angle must lie. | |
globalSlopeBricks = { | |
'962':{45}, | |
'2341':{-45}, | |
'2449':{-16}, | |
'2875':{45}, | |
'2876':{(40, 63)}, | |
'3037':{45}, | |
'3038':{45}, | |
'3039':{45}, | |
'3040':{45}, | |
'3041':{45}, | |
'3042':{45}, | |
'3043':{45}, | |
'3044':{45}, | |
'3045':{45}, | |
'3046':{45}, | |
'3048':{45}, | |
'3049':{45}, | |
'3135':{45}, | |
'3297':{63}, | |
'3298':{63}, | |
'3299':{63}, | |
'3300':{63}, | |
'3660':{-45}, | |
'3665':{-45}, | |
'3675':{63}, | |
'3676':{-45}, | |
'3678b':{24}, | |
'3684':{15}, | |
'3685':{16}, | |
'3688':{15}, | |
'3747':{-63}, | |
'4089':{-63}, | |
'4161':{63}, | |
'4286':{63}, | |
'4287':{-63}, | |
'4445':{45}, | |
'4460':{16}, | |
'4509':{63}, | |
'4854':{-45}, | |
'4856':{(-60, -70), -45}, | |
'4857':{45}, | |
'4858':{72}, | |
'4861':{45, 63}, | |
'4871':{-45}, | |
'4885':{72}, #blank | |
'6069':{72, 45}, | |
'6153':{(60, 70), (26, 34)}, | |
'6227':{45}, | |
'6270':{45}, | |
'13269':{(40, 63)}, | |
'13548':{(45, 35)}, | |
'15571':{45}, | |
'18759':{-45}, | |
'22390':{(40, 55)}, #blank | |
'22391':{(40, 55)}, | |
'22889':{-45}, | |
'28192':{45}, #blank | |
'30180':{47}, | |
'30182':{45}, | |
'30183':{-45}, | |
'30249':{35}, | |
'30283':{-45}, | |
'30363':{72}, | |
'30373':{-24}, | |
'30382':{11, 45}, | |
'30390':{-45}, | |
'30499':{16}, | |
'32083':{45}, | |
'43708':{(64, 72)}, | |
'43710':{72, 45}, | |
'43711':{72, 45}, | |
'47759':{(40, 63)}, | |
'52501':{-45}, | |
'60219':{-45}, | |
'60477':{72}, | |
'60481':{24}, | |
'63341':{45}, | |
'72454':{-45}, | |
'92946':{45}, | |
'93348':{72}, | |
'95188':{65}, | |
'99301':{63}, | |
'303923':{45}, | |
'303926':{45}, | |
'304826':{45}, | |
'329826':{64}, | |
'374726':{-64}, | |
'428621':{64}, | |
'4162628':{17}, | |
'4195004':{45}, | |
} | |
globalLightBricks = { | |
'62930.dat':(1.0,0.373,0.059,1.0), | |
'54869.dat':(1.0,0.052,0.017,1.0) | |
} | |
# Create a regular dictionary of parts with ranges of angles to check | |
margin = 5 # Allow 5 degrees either way to compensate for measuring inaccuracies | |
globalSlopeAngles = {} | |
for part, angles in globalSlopeBricks.items(): | |
globalSlopeAngles[part] = {(c-margin, c+margin) if type(c) is not tuple else (min(c)-margin,max(c)+margin) for c in angles} | |
# ************************************************************************************** | |
def internalPrint(message): | |
"""Debug print with identification timestamp.""" | |
# Current timestamp (with milliseconds trimmed to two places) | |
timestamp = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-4] | |
message = "{0} [importldraw] {1}".format(timestamp, message) | |
print("{0}".format(message)) | |
global globalContext | |
if globalContext is not None: | |
globalContext.report({'INFO'}, message) | |
# ************************************************************************************** | |
def debugPrint(message): | |
"""Debug print with identification timestamp.""" | |
if Options.verbose > 0: | |
internalPrint(message) | |
# ************************************************************************************** | |
def printWarningOnce(key, message=None): | |
if message is None: | |
message = key | |
if key not in Configure.warningSuppression: | |
internalPrint("WARNING: {0}".format(message)) | |
Configure.warningSuppression[key] = True | |
global globalContext | |
if globalContext is not None: | |
globalContext.report({'WARNING'}, message) | |
# ************************************************************************************** | |
def printError(message): | |
internalPrint("ERROR: {0}".format(message)) | |
global globalContext | |
if globalContext is not None: | |
globalContext.report({'ERROR'}, message) | |
# ************************************************************************************** | |
# ************************************************************************************** | |
class Math: | |
identityMatrix = mathutils.Matrix(( | |
(1.0, 0.0, 0.0, 0.0), | |
(0.0, 1.0, 0.0, 0.0), | |
(0.0, 0.0, 1.0, 0.0), | |
(0.0, 0.0, 0.0, 1.0) | |
)) | |
rotationMatrix = mathutils.Matrix.Rotation(math.radians(-90), 4, 'X') | |
reflectionMatrix = mathutils.Matrix(( | |
(1.0, 0.0, 0.0, 0.0), | |
(0.0, 1.0, 0.0, 0.0), | |
(0.0, 0.0, -1.0, 0.0), | |
(0.0, 0.0, 0.0, 1.0) | |
)) | |
def clamp01(value): | |
return max(min(value, 1.0), 0.0) | |
def __init__(self): | |
global globalScaleFactor | |
# Rotation and scale matrices that convert LDraw coordinate space to Blender coordinate space | |
Math.scaleMatrix = mathutils.Matrix(( | |
(globalScaleFactor, 0.0, 0.0, 0.0), | |
(0.0, globalScaleFactor, 0.0, 0.0), | |
(0.0, 0.0, globalScaleFactor, 0.0), | |
(0.0, 0.0, 0.0, 1.0) | |
)) | |
# ************************************************************************************** | |
# ************************************************************************************** | |
class Configure: | |
"""Configuration. | |
Attempts to find the ldraw directory (platform specific directories are searched). | |
Stores the list of paths to parts libraries that we search for individual parts. | |
Stores warning messages we have already seen so we don't see them again. | |
""" | |
searchPaths = [] | |
warningSuppression = {} | |
tempDir = None | |
def appendPath(path): | |
if os.path.exists(path): | |
Configure.searchPaths.append(path) | |
def __setSearchPaths(): | |
Configure.searchPaths = [] | |
# Always search for parts in the 'models' folder | |
Configure.appendPath(os.path.join(Configure.ldrawInstallDirectory, "models")) | |
# Search for stud logo parts | |
if Options.useLogoStuds and Options.studLogoDirectory != "": | |
if Options.resolution == "Low": | |
Configure.appendPath(os.path.join(Options.studLogoDirectory, "8")) | |
Configure.appendPath(Options.studLogoDirectory) | |
# Search unofficial parts | |
if Options.useUnofficialParts: | |
Configure.appendPath(os.path.join(Configure.ldrawInstallDirectory, "unofficial", "parts")) | |
if Options.resolution == "High": | |
Configure.appendPath(os.path.join(Configure.ldrawInstallDirectory, "unofficial", "p", "48")) | |
elif Options.resolution == "Low": | |
Configure.appendPath(os.path.join(Configure.ldrawInstallDirectory, "unofficial", "p", "8")) | |
Configure.appendPath(os.path.join(Configure.ldrawInstallDirectory, "unofficial", "p")) | |
# Add 'Tente' parts too | |
Configure.appendPath(os.path.join(Configure.ldrawInstallDirectory, "tente", "parts")) | |
if Options.resolution == "High": | |
Configure.appendPath(os.path.join(Configure.ldrawInstallDirectory, "tente", "p", "48")) | |
elif Options.resolution == "Low": | |
Configure.appendPath(os.path.join(Configure.ldrawInstallDirectory, "tente", "p", "8")) | |
Configure.appendPath(os.path.join(Configure.ldrawInstallDirectory, "tente", "p")) | |
# Search LSynth parts | |
if Options.useLSynthParts: | |
if Options.LSynthDirectory != "": | |
Configure.appendPath(Options.LSynthDirectory) | |
else: | |
Configure.appendPath(os.path.join(Configure.ldrawInstallDirectory, "unofficial", "lsynth")) | |
debugPrint("Use LSynth Parts requested") | |
# Search official parts | |
Configure.appendPath(os.path.join(Configure.ldrawInstallDirectory, "parts")) | |
if Options.resolution == "High": | |
Configure.appendPath(os.path.join(Configure.ldrawInstallDirectory, "p", "48")) | |
debugPrint("High-res primitives selected") | |
elif Options.resolution == "Low": | |
Configure.appendPath(os.path.join(Configure.ldrawInstallDirectory, "p", "8")) | |
debugPrint("Low-res primitives selected") | |
else: | |
debugPrint("Standard-res primitives selected") | |
Configure.appendPath(os.path.join(Configure.ldrawInstallDirectory, "p")) | |
def isWindows(): | |
return platform.system() == "Windows" | |
def isMac(): | |
return platform.system() == "Darwin" | |
def isLinux(): | |
return platform.system() == "Linux" | |
def findDefaultLDrawDirectory(): | |
result = "" | |
# Get list of possible ldraw installation directories for the platform | |
if Configure.isWindows(): | |
ldrawPossibleDirectories = [ | |
"C:\\LDraw", | |
"C:\\Program Files\\LDraw", | |
"C:\\Program Files (x86)\\LDraw", | |
"C:\\Program Files\\Studio 2.0\\ldraw", | |
] | |
elif Configure.isMac(): | |
ldrawPossibleDirectories = [ | |
"~/ldraw/", | |
"/Applications/LDraw/", | |
"/Applications/ldraw/", | |
"/usr/local/share/ldraw", | |
"/Applications/Studio 2.0/ldraw", | |
] | |
else: # Default to Linux if not Windows or Mac | |
ldrawPossibleDirectories = [ | |
"~/LDraw", | |
"~/ldraw", | |
"~/.LDraw", | |
"~/.ldraw", | |
"/usr/local/share/ldraw", | |
] | |
# Search possible directories | |
for dir in ldrawPossibleDirectories: | |
dir = os.path.expanduser(dir) | |
if os.path.isfile(os.path.join(dir, "LDConfig.ldr")): | |
result = dir | |
break | |
return result | |
def setLDrawDirectory(): | |
if Options.ldrawDirectory == "": | |
Configure.ldrawInstallDirectory = Configure.findDefaultLDrawDirectory() | |
else: | |
Configure.ldrawInstallDirectory = os.path.expanduser(Options.ldrawDirectory) | |
debugPrint("The LDraw Parts Library path to be used is: {0}".format(Configure.ldrawInstallDirectory)) | |
Configure.__setSearchPaths() | |
def __init__(self): | |
Configure.setLDrawDirectory() | |
# ************************************************************************************** | |
# ************************************************************************************** | |
class LegoColours: | |
"""Parses and stores a table of colour / material definitions. Converts colour space.""" | |
colours = {} | |
def __getValue(line, value): | |
"""Parses a colour value from the ldConfig.ldr file""" | |
if value in line: | |
n = line.index(value) | |
return line[n + 1] | |
def __sRGBtoRGBValue(value): | |
# See https://en.wikipedia.org/wiki/SRGB#The_reverse_transformation | |
if value < 0.04045: | |
return value / 12.92 | |
return ((value + 0.055)/1.055)**2.4 | |
def isDark(colour): | |
R = colour[0] | |
G = colour[1] | |
B = colour[2] | |
# Measure the perceived brightness of colour | |
brightness = math.sqrt( 0.299*R*R + 0.587*G*G + 0.114*B*B ) | |
# Dark colours have white lines | |
if brightness < 0.03: | |
return True | |
return False | |
def sRGBtoLinearRGB(sRGBColour): | |
# See https://en.wikipedia.org/wiki/SRGB#The_reverse_transformation | |
(sr, sg, sb) = sRGBColour | |
r = LegoColours.__sRGBtoRGBValue(sr) | |
g = LegoColours.__sRGBtoRGBValue(sg) | |
b = LegoColours.__sRGBtoRGBValue(sb) | |
return (r,g,b) | |
def hexDigitsToLinearRGBA(hexDigits, alpha): | |
# String is "RRGGBB" format | |
int_tuple = struct.unpack('BBB', bytes.fromhex(hexDigits)) | |
sRGB = tuple([val / 255 for val in int_tuple]) | |
linearRGB = LegoColours.sRGBtoLinearRGB(sRGB) | |
return (linearRGB[0], linearRGB[1], linearRGB[2], alpha) | |
def hexStringToLinearRGBA(hexString): | |
"""Convert colour hex value to RGB value.""" | |
# Handle direct colours | |
# Direct colours are documented here: http://www.hassings.dk/l3/l3p.html | |
match = re.fullmatch(r"0x0*([0-9])((?:[A-F0-9]{2}){3})", hexString) | |
if match is not None: | |
digit = match.group(1) | |
rgb_str = match.group(2) | |
interleaved = False | |
if digit == "2": # Opaque | |
alpha = 1.0 | |
elif digit == "3": # Transparent | |
alpha = 0.5 | |
elif digit == "4": # Opaque | |
alpha = 1.0 | |
interleaved = True | |
elif digit == "5": # More Transparent | |
alpha = 0.333 | |
interleaved = True | |
elif digit == "6": # Less transparent | |
alpha = 0.666 | |
interleaved = True | |
elif digit == "7": # Invisible | |
alpha = 0.0 | |
interleaved = True | |
else: | |
alpha = 1.0 | |
if interleaved: | |
# Input string is six hex digits of two colours "RGBRGB". | |
# This was designed to be a dithered colour. | |
# Take the average of those two colours (R+R,G+G,B+B) * 0.5 | |
r = float(int(rgb_str[0], 16)) / 15 | |
g = float(int(rgb_str[1], 16)) / 15 | |
b = float(int(rgb_str[2], 16)) / 15 | |
colour1 = LegoColours.sRGBtoLinearRGB((r,g,b)) | |
r = float(int(rgb_str[3], 16)) / 15 | |
g = float(int(rgb_str[4], 16)) / 15 | |
b = float(int(rgb_str[5], 16)) / 15 | |
colour2 = LegoColours.sRGBtoLinearRGB((r,g,b)) | |
return (0.5 * (colour1[0] + colour2[0]), | |
0.5 * (colour1[1] + colour2[1]), | |
0.5 * (colour1[2] + colour2[2]), alpha) | |
# String is "RRGGBB" format | |
return LegoColours.hexDigitsToLinearRGBA(rgb_str, alpha) | |
return None | |
def __overwriteColour(index, sRGBColour): | |
if index in LegoColours.colours: | |
# Colour Space Management: Convert sRGB colour values to Blender's linear RGB colour space | |
LegoColours.colours[index]["colour"] = LegoColours.sRGBtoLinearRGB(sRGBColour) | |
def __readColourTable(): | |
"""Reads the colour values from the LDConfig.ldr file. For details of the | |
Ldraw colour system see: http://www.ldraw.org/article/547""" | |
if Options.useColourScheme == "alt": | |
configFilename = "LDCfgalt.ldr" | |
else: | |
configFilename = "LDConfig.ldr" | |
configFilepath = os.path.join(Configure.ldrawInstallDirectory, configFilename) | |
ldconfig_lines = "" | |
if os.path.exists(configFilepath): | |
with open(configFilepath, "rt", encoding="utf_8") as ldconfig: | |
ldconfig_lines = ldconfig.readlines() | |
for line in ldconfig_lines: | |
if len(line) > 3: | |
if line[2:4].lower() == '!c': | |
line_split = line.split() | |
name = line_split[2] | |
code = int(line_split[4]) | |
linearRGBA = LegoColours.hexDigitsToLinearRGBA(line_split[6][1:], 1.0) | |
colour = { | |
"name": name, | |
"colour": linearRGBA[0:3], | |
"alpha": linearRGBA[3], | |
"luminance": 0.0, | |
"material": "BASIC" | |
} | |
if "ALPHA" in line_split: | |
colour["alpha"] = int(LegoColours.__getValue(line_split, "ALPHA")) / 256.0 | |
if "LUMINANCE" in line_split: | |
colour["luminance"] = int(LegoColours.__getValue(line_split, "LUMINANCE")) | |
if "CHROME" in line_split: | |
colour["material"] = "CHROME" | |
if "PEARLESCENT" in line_split: | |
colour["material"] = "PEARLESCENT" | |
if "RUBBER" in line_split: | |
colour["material"] = "RUBBER" | |
if "METAL" in line_split: | |
colour["material"] = "METAL" | |
if "MATERIAL" in line_split: | |
subline = line_split[line_split.index("MATERIAL"):] | |
colour["material"] = LegoColours.__getValue(subline, "MATERIAL") | |
# current `FABRIC [VELVET | CANVAS | STRING | FUR]` is not yet supported. | |
if colour["material"] == "FABRIC": | |
debugPrint(f"Unsupported material finish: {colour['material']} for [colour: {name} code: {code}] in line: {subline}") | |
# Note, not all finishes have a secondary value | |
finishValue = LegoColours.__getValue(subline, "VALUE") | |
if finishValue is not None: | |
hexDigits = finishValue[1:] | |
colour["secondary_colour"] = LegoColours.hexDigitsToLinearRGBA(hexDigits, 1.0) | |
colour["fraction"] = LegoColours.__getValue(subline, "FRACTION") | |
colour["vfraction"] = LegoColours.__getValue(subline, "VFRACTION") | |
colour["size"] = LegoColours.__getValue(subline, "SIZE") | |
colour["minsize"] = LegoColours.__getValue(subline, "MINSIZE") | |
colour["maxsize"] = LegoColours.__getValue(subline, "MAXSIZE") | |
LegoColours.colours[code] = colour | |
if Options.useColourScheme == "lgeo": | |
# LGEO is a parts library for rendering LEGO using the povray rendering software. | |
# It has a list of LEGO colours suitable for realistic rendering. | |
# I've extracted the following colours from the LGEO file: lg_color.inc | |
# LGEO is downloadable from http://ldraw.org/downloads-2/downloads.html | |
# We overwrite the standard LDraw colours if we have better LGEO colours. | |
LegoColours.__overwriteColour(0, ( 33/255, 33/255, 33/255)) | |
LegoColours.__overwriteColour(1, ( 13/255, 105/255, 171/255)) | |
LegoColours.__overwriteColour(2, ( 40/255, 127/255, 70/255)) | |
LegoColours.__overwriteColour(3, ( 0/255, 143/255, 155/255)) | |
LegoColours.__overwriteColour(4, (196/255, 40/255, 27/255)) | |
LegoColours.__overwriteColour(5, (205/255, 98/255, 152/255)) | |
LegoColours.__overwriteColour(6, ( 98/255, 71/255, 50/255)) | |
LegoColours.__overwriteColour(7, (161/255, 165/255, 162/255)) | |
LegoColours.__overwriteColour(8, (109/255, 110/255, 108/255)) | |
LegoColours.__overwriteColour(9, (180/255, 210/255, 227/255)) | |
LegoColours.__overwriteColour(10, ( 75/255, 151/255, 74/255)) | |
LegoColours.__overwriteColour(11, ( 85/255, 165/255, 175/255)) | |
LegoColours.__overwriteColour(12, (242/255, 112/255, 94/255)) | |
LegoColours.__overwriteColour(13, (252/255, 151/255, 172/255)) | |
LegoColours.__overwriteColour(14, (245/255, 205/255, 47/255)) | |
LegoColours.__overwriteColour(15, (242/255, 243/255, 242/255)) | |
LegoColours.__overwriteColour(17, (194/255, 218/255, 184/255)) | |
LegoColours.__overwriteColour(18, (249/255, 233/255, 153/255)) | |
LegoColours.__overwriteColour(19, (215/255, 197/255, 153/255)) | |
LegoColours.__overwriteColour(20, (193/255, 202/255, 222/255)) | |
LegoColours.__overwriteColour(21, (224/255, 255/255, 176/255)) | |
LegoColours.__overwriteColour(22, (107/255, 50/255, 123/255)) | |
LegoColours.__overwriteColour(23, ( 35/255, 71/255, 139/255)) | |
LegoColours.__overwriteColour(25, (218/255, 133/255, 64/255)) | |
LegoColours.__overwriteColour(26, (146/255, 57/255, 120/255)) | |
LegoColours.__overwriteColour(27, (164/255, 189/255, 70/255)) | |
LegoColours.__overwriteColour(28, (149/255, 138/255, 115/255)) | |
LegoColours.__overwriteColour(29, (228/255, 173/255, 200/255)) | |
LegoColours.__overwriteColour(30, (172/255, 120/255, 186/255)) | |
LegoColours.__overwriteColour(31, (225/255, 213/255, 237/255)) | |
LegoColours.__overwriteColour(32, ( 0/255, 20/255, 20/255)) | |
LegoColours.__overwriteColour(33, (123/255, 182/255, 232/255)) | |
LegoColours.__overwriteColour(34, (132/255, 182/255, 141/255)) | |
LegoColours.__overwriteColour(35, (217/255, 228/255, 167/255)) | |
LegoColours.__overwriteColour(36, (205/255, 84/255, 75/255)) | |
LegoColours.__overwriteColour(37, (228/255, 173/255, 200/255)) | |
LegoColours.__overwriteColour(38, (255/255, 43/255, 0/225)) | |
LegoColours.__overwriteColour(40, (166/255, 145/255, 130/255)) | |
LegoColours.__overwriteColour(41, (170/255, 229/255, 255/255)) | |
LegoColours.__overwriteColour(42, (198/255, 255/255, 0/255)) | |
LegoColours.__overwriteColour(43, (193/255, 223/255, 240/255)) | |
LegoColours.__overwriteColour(44, (150/255, 112/255, 159/255)) | |
LegoColours.__overwriteColour(46, (247/255, 241/255, 141/255)) | |
LegoColours.__overwriteColour(47, (252/255, 252/255, 252/255)) | |
LegoColours.__overwriteColour(52, (156/255, 149/255, 199/255)) | |
LegoColours.__overwriteColour(54, (255/255, 246/255, 123/255)) | |
LegoColours.__overwriteColour(57, (226/255, 176/255, 96/255)) | |
LegoColours.__overwriteColour(65, (236/255, 201/255, 53/255)) | |
LegoColours.__overwriteColour(66, (202/255, 176/255, 0/255)) | |
LegoColours.__overwriteColour(67, (255/255, 255/255, 255/255)) | |
LegoColours.__overwriteColour(68, (243/255, 207/255, 155/255)) | |
LegoColours.__overwriteColour(69, (142/255, 66/255, 133/255)) | |
LegoColours.__overwriteColour(70, (105/255, 64/255, 39/255)) | |
LegoColours.__overwriteColour(71, (163/255, 162/255, 164/255)) | |
LegoColours.__overwriteColour(72, ( 99/255, 95/255, 97/255)) | |
LegoColours.__overwriteColour(73, (110/255, 153/255, 201/255)) | |
LegoColours.__overwriteColour(74, (161/255, 196/255, 139/255)) | |
LegoColours.__overwriteColour(77, (220/255, 144/255, 149/255)) | |
LegoColours.__overwriteColour(78, (246/255, 215/255, 179/255)) | |
LegoColours.__overwriteColour(79, (255/255, 255/255, 255/255)) | |
LegoColours.__overwriteColour(80, (140/255, 140/255, 140/255)) | |
LegoColours.__overwriteColour(82, (219/255, 172/255, 52/255)) | |
LegoColours.__overwriteColour(84, (170/255, 125/255, 85/255)) | |
LegoColours.__overwriteColour(85, ( 52/255, 43/255, 117/255)) | |
LegoColours.__overwriteColour(86, (124/255, 92/255, 69/255)) | |
LegoColours.__overwriteColour(89, (155/255, 178/255, 239/255)) | |
LegoColours.__overwriteColour(92, (204/255, 142/255, 104/255)) | |
LegoColours.__overwriteColour(100, (238/255, 196/255, 182/255)) | |
LegoColours.__overwriteColour(115, (199/255, 210/255, 60/255)) | |
LegoColours.__overwriteColour(134, (174/255, 122/255, 89/255)) | |
LegoColours.__overwriteColour(135, (171/255, 173/255, 172/255)) | |
LegoColours.__overwriteColour(137, (106/255, 122/255, 150/255)) | |
LegoColours.__overwriteColour(142, (220/255, 188/255, 129/255)) | |
LegoColours.__overwriteColour(148, ( 62/255, 60/255, 57/255)) | |
LegoColours.__overwriteColour(151, ( 14/255, 94/255, 77/255)) | |
LegoColours.__overwriteColour(179, (160/255, 160/255, 160/255)) | |
LegoColours.__overwriteColour(183, (242/255, 243/255, 242/255)) | |
LegoColours.__overwriteColour(191, (248/255, 187/255, 61/255)) | |
LegoColours.__overwriteColour(212, (159/255, 195/255, 233/255)) | |
LegoColours.__overwriteColour(216, (143/255, 76/255, 42/255)) | |
LegoColours.__overwriteColour(226, (253/255, 234/255, 140/255)) | |
LegoColours.__overwriteColour(232, (125/255, 187/255, 221/255)) | |
LegoColours.__overwriteColour(256, ( 33/255, 33/255, 33/255)) | |
LegoColours.__overwriteColour(272, ( 32/255, 58/255, 86/255)) | |
LegoColours.__overwriteColour(273, ( 13/255, 105/255, 171/255)) | |
LegoColours.__overwriteColour(288, ( 39/255, 70/255, 44/255)) | |
LegoColours.__overwriteColour(294, (189/255, 198/255, 173/255)) | |
LegoColours.__overwriteColour(297, (170/255, 127/255, 46/255)) | |
LegoColours.__overwriteColour(308, ( 53/255, 33/255, 0/255)) | |
LegoColours.__overwriteColour(313, (171/255, 217/255, 255/255)) | |
LegoColours.__overwriteColour(320, (123/255, 46/255, 47/255)) | |
LegoColours.__overwriteColour(321, ( 70/255, 155/255, 195/255)) | |
LegoColours.__overwriteColour(322, (104/255, 195/255, 226/255)) | |
LegoColours.__overwriteColour(323, (211/255, 242/255, 234/255)) | |
LegoColours.__overwriteColour(324, (196/255, 0/255, 38/255)) | |
LegoColours.__overwriteColour(326, (226/255, 249/255, 154/255)) | |
LegoColours.__overwriteColour(330, (119/255, 119/255, 78/255)) | |
LegoColours.__overwriteColour(334, (187/255, 165/255, 61/255)) | |
LegoColours.__overwriteColour(335, (149/255, 121/255, 118/255)) | |
LegoColours.__overwriteColour(366, (209/255, 131/255, 4/255)) | |
LegoColours.__overwriteColour(373, (135/255, 124/255, 144/255)) | |
LegoColours.__overwriteColour(375, (193/255, 194/255, 193/255)) | |
LegoColours.__overwriteColour(378, (120/255, 144/255, 129/255)) | |
LegoColours.__overwriteColour(379, ( 94/255, 116/255, 140/255)) | |
LegoColours.__overwriteColour(383, (224/255, 224/255, 224/255)) | |
LegoColours.__overwriteColour(406, ( 0/255, 29/255, 104/255)) | |
LegoColours.__overwriteColour(449, (129/255, 0/255, 123/255)) | |
LegoColours.__overwriteColour(450, (203/255, 132/255, 66/255)) | |
LegoColours.__overwriteColour(462, (226/255, 155/255, 63/255)) | |
LegoColours.__overwriteColour(484, (160/255, 95/255, 52/255)) | |
LegoColours.__overwriteColour(490, (215/255, 240/255, 0/255)) | |
LegoColours.__overwriteColour(493, (101/255, 103/255, 97/255)) | |
LegoColours.__overwriteColour(494, (208/255, 208/255, 208/255)) | |
LegoColours.__overwriteColour(496, (163/255, 162/255, 164/255)) | |
LegoColours.__overwriteColour(503, (199/255, 193/255, 183/255)) | |
LegoColours.__overwriteColour(504, (137/255, 135/255, 136/255)) | |
LegoColours.__overwriteColour(511, (250/255, 250/255, 250/255)) | |
def lightenRGBA(colour, scale): | |
# Moves the linear RGB values closer to white | |
# scale = 0 means full white | |
# scale = 1 means color stays same | |
colour = ((1.0 - colour[0]) * scale, | |
(1.0 - colour[1]) * scale, | |
(1.0 - colour[2]) * scale, | |
colour[3]) | |
return (Math.clamp01(1.0 - colour[0]), | |
Math.clamp01(1.0 - colour[1]), | |
Math.clamp01(1.0 - colour[2]), | |
colour[3]) | |
def isFluorescentTransparent(colName): | |
if (colName == "Trans_Neon_Orange"): | |
return True | |
if (colName == "Trans_Neon_Green"): | |
return True | |
if (colName == "Trans_Neon_Yellow"): | |
return True | |
if (colName == "Trans_Bright_Green"): | |
return True | |
return False | |
def __init__(self): | |
LegoColours.__readColourTable() | |
# ************************************************************************************** | |
# ************************************************************************************** | |
class FileSystem: | |
""" | |
Reads text files in different encodings. Locates full filepath for a part. | |
""" | |
# Takes a case-insensitive filepath and constructs a case sensitive version (based on an actual existing file) | |
# See https://stackoverflow.com/questions/8462449/python-case-insensitive-file-name/8462613#8462613 | |
def pathInsensitive(path): | |
""" | |
Get a case-insensitive path for use on a case sensitive system. | |
>>> path_insensitive('/Home') | |
'/home' | |
>>> path_insensitive('/Home/chris') | |
'/home/chris' | |
>>> path_insensitive('/HoME/CHris/') | |
'/home/chris/' | |
>>> path_insensitive('/home/CHRIS') | |
'/home/chris' | |
>>> path_insensitive('/Home/CHRIS/.gtk-bookmarks') | |
'/home/chris/.gtk-bookmarks' | |
>>> path_insensitive('/home/chris/.GTK-bookmarks') | |
'/home/chris/.gtk-bookmarks' | |
>>> path_insensitive('/HOME/Chris/.GTK-bookmarks') | |
'/home/chris/.gtk-bookmarks' | |
>>> path_insensitive("/HOME/Chris/I HOPE this doesn't exist") | |
"/HOME/Chris/I HOPE this doesn't exist" | |
""" | |
return FileSystem.__pathInsensitive(path) or path | |
def __pathInsensitive(path): | |
""" | |
Recursive part of path_insensitive to do the work. | |
""" | |
if path == '' or os.path.exists(path): | |
return path | |
base = os.path.basename(path) # may be a directory or a file | |
dirname = os.path.dirname(path) | |
suffix = '' | |
if not base: # dir ends with a slash? | |
if len(dirname) < len(path): | |
suffix = path[:len(path) - len(dirname)] | |
base = os.path.basename(dirname) | |
dirname = os.path.dirname(dirname) | |
if not os.path.exists(dirname): | |
debug_dirname = dirname | |
dirname = FileSystem.__pathInsensitive(dirname) | |
if not dirname: | |
return | |
# at this point, the directory exists but not the file | |
try: # we are expecting dirname to be a directory, but it could be a file | |
files = CachedDirectoryFilenames.getCached(dirname) | |
if files is None: | |
files = os.listdir(dirname) | |
CachedDirectoryFilenames.addToCache(dirname, files) | |
except OSError: | |
return | |
baselow = base.lower() | |
try: | |
basefinal = next(fl for fl in files if fl.lower() == baselow) | |
except StopIteration: | |
return | |
if basefinal: | |
return os.path.join(dirname, basefinal) + suffix | |
else: | |
return | |
def __checkEncoding(filepath): | |
"""Check the encoding of a file for Endian encoding.""" | |
filepath = FileSystem.pathInsensitive(filepath) | |
# Open it, read just the area containing a possible byte mark | |
with open(filepath, "rb") as encode_check: | |
encoding = encode_check.readline(3) | |
# The file uses UCS-2 (UTF-16) Big Endian encoding | |
if encoding == b"\xfe\xff\x00": | |
return "utf_16_be" | |
# The file uses UCS-2 (UTF-16) Little Endian | |
elif encoding == b"\xff\xfe0": | |
return "utf_16_le" | |
# Use LDraw model standard UTF-8 | |
else: | |
return "utf_8" | |
def readTextFile(filepath): | |
"""Read a text file, with various checks for type of encoding""" | |
filepath = FileSystem.pathInsensitive(filepath) | |
lines = None | |
if os.path.exists(filepath): | |
# Try to read using the suspected encoding | |
file_encoding = FileSystem.__checkEncoding(filepath) | |
try: | |
with open(filepath, "rt", encoding=file_encoding) as f_in: | |
lines = f_in.readlines() | |
except: | |
# If all else fails, read using Latin 1 encoding | |
with open(filepath, "rt", encoding="latin_1") as f_in: | |
lines = f_in.readlines() | |
return lines | |
def locate(filename, rootPath = None): | |
"""Given a file name of an ldraw file, find the full path""" | |
partName = filename.replace("\\", os.path.sep) | |
partName = os.path.expanduser(partName) | |
if rootPath is None: | |
rootPath = os.path.dirname(filename) | |
allSearchPaths = Configure.searchPaths[:] | |
if rootPath not in allSearchPaths: | |
allSearchPaths.append(rootPath) | |
for path in allSearchPaths: | |
fullPathName = os.path.join(path, partName) | |
fullPathName = FileSystem.pathInsensitive(fullPathName) | |
if os.path.exists(fullPathName): | |
return fullPathName | |
return None | |
# ************************************************************************************** | |
# ************************************************************************************** | |
class CachedDirectoryFilenames: | |
"""Cached dictionary of directory filenames keyed by directory path""" | |
__cache = {} # Dictionary | |
def getCached(key): | |
if key in CachedDirectoryFilenames.__cache: | |
return CachedDirectoryFilenames.__cache[key] | |
return None | |
def addToCache(key, value): | |
CachedDirectoryFilenames.__cache[key] = value | |
def clearCache(): | |
CachedDirectoryFilenames.__cache = {} | |
# ************************************************************************************** | |
# ************************************************************************************** | |
class CachedFiles: | |
"""Cached dictionary of LDrawFile objects keyed by filename""" | |
__cache = {} # Dictionary of exact filenames as keys, and file contents as values | |
__lowercache = {} # Dictionary of lowercase filenames as keys, and file contents as values | |
def getCached(key): | |
# Look for an exact match in the cache first | |
if key in CachedFiles.__cache: | |
return CachedFiles.__cache[key] | |
# Look for a case-insensitive match next | |
if key.lower() in CachedFiles.__lowercache: | |
return CachedFiles.__lowercache[key.lower()] | |
return None | |
def addToCache(key, value): | |
CachedFiles.__cache[key] = value | |
CachedFiles.__lowercache[key.lower()] = value | |
def clearCache(): | |
CachedFiles.__cache = {} | |
CachedFiles.__lowercache = {} | |
# ************************************************************************************** | |
# ************************************************************************************** | |
class CachedGeometry: | |
"""Cached dictionary of LDrawGeometry objects""" | |
__cache = {} # Dictionary | |
def getCached(key): | |
if key in CachedGeometry.__cache: | |
return CachedGeometry.__cache[key] | |
return None | |
def addToCache(key, value): | |
CachedGeometry.__cache[key] = value | |
def clearCache(): | |
CachedGeometry.__cache = {} | |
# ************************************************************************************** | |
# ************************************************************************************** | |
class FaceInfo: | |
def __init__(self, faceColour, culling, windingCCW, isGrainySlopeAllowed): | |
self.faceColour = faceColour | |
self.culling = culling | |
self.windingCCW = windingCCW | |
self.isGrainySlopeAllowed = isGrainySlopeAllowed | |
# ************************************************************************************** | |
# ************************************************************************************** | |
class LDrawGeometry: | |
"""Stores the geometry for an LDrawFile""" | |
def __init__(self): | |
self.points = [] | |
self.faces = [] | |
self.faceInfo = [] | |
self.edges = [] | |
self.edgeIndices = [] | |
def parseFace(self, parameters, cull, ccw, isGrainySlopeAllowed): | |
"""Parse a face from parameters""" | |
num_points = int(parameters[0]) | |
colourName = parameters[1] | |
newPoints = [] | |
for i in range(num_points): | |
blenderPos = Math.scaleMatrix @ mathutils.Vector( (float(parameters[i * 3 + 2]), | |
float(parameters[i * 3 + 3]), | |
float(parameters[i * 3 + 4])) ) | |
newPoints.append(blenderPos) | |
# Fix "bowtie" quadrilaterals (see http://wiki.ldraw.org/index.php?title=LDraw_technical_restrictions#Complex_quadrilaterals) | |
if num_points == 4: | |
nA = (newPoints[1] - newPoints[0]).cross(newPoints[2] - newPoints[0]) | |
nB = (newPoints[2] - newPoints[1]).cross(newPoints[3] - newPoints[1]) | |
nC = (newPoints[3] - newPoints[2]).cross(newPoints[0] - newPoints[2]) | |
if (nA.dot(nB) < 0): | |
newPoints[2], newPoints[3] = newPoints[3], newPoints[2] | |
elif (nB.dot(nC) < 0): | |
newPoints[2], newPoints[1] = newPoints[1], newPoints[2] | |
pointCount = len(self.points) | |
newFace = list(range(pointCount, pointCount + num_points)) | |
self.points.extend(newPoints) | |
self.faces.append(newFace) | |
self.faceInfo.append(FaceInfo(colourName, cull, ccw, isGrainySlopeAllowed)) | |
def parseEdge(self, parameters): | |
"""Parse an edge from parameters""" | |
colourName = parameters[1] | |
if colourName == "24": | |
blenderPos1 = Math.scaleMatrix @ mathutils.Vector( (float(parameters[2]), | |
float(parameters[3]), | |
float(parameters[4])) ) | |
blenderPos2 = Math.scaleMatrix @ mathutils.Vector( (float(parameters[5]), | |
float(parameters[6]), | |
float(parameters[7])) ) | |
self.edges.append((blenderPos1, blenderPos2)) | |
def verify(self, face, numPoints): | |
for i in face: | |
assert i < numPoints | |
assert i >= 0 | |
def appendGeometry(self, geometry, matrix, isStud, isStudLogo, parentMatrix, cull, invert): | |
combinedMatrix = parentMatrix @ matrix | |
isReflected = combinedMatrix.determinant() < 0.0 | |
reflectStudLogo = isStudLogo and isReflected | |
fixedMatrix = matrix.copy() | |
if reflectStudLogo: | |
fixedMatrix = matrix @ Math.reflectionMatrix | |
invert = not invert | |
# Append face information | |
pointCount = len(self.points) | |
newFaceInfo = [] | |
for index, face in enumerate(geometry.faces): | |
# Gather points for this face (and transform points) | |
newPoints = [] | |
for i in face: | |
newPoints.append(fixedMatrix @ geometry.points[i]) | |
# Add clockwise and/or anticlockwise sets of points as appropriate | |
newFace = face.copy() | |
for i in range(len(newFace)): | |
newFace[i] += pointCount | |
faceInfo = geometry.faceInfo[index] | |
faceCCW = faceInfo.windingCCW != invert | |
faceCull = faceInfo.culling and cull | |
# If we are going to resolve ambiguous normals by "best guess" we will let | |
# Blender calculate that for us later. Just cull with arbitrary winding for now. | |
if not faceCull: | |
if Options.resolveAmbiguousNormals == "guess": | |
faceCull = True | |
if faceCCW or not faceCull: | |
self.points.extend(newPoints) | |
self.faces.append(newFace) | |
newFaceInfo.append(FaceInfo(faceInfo.faceColour, True, True, not isStud and faceInfo.isGrainySlopeAllowed)) | |
self.verify(newFace, len(self.points)) | |
if not faceCull: | |
newFace = newFace.copy() | |
pointCount += len(newPoints) | |
for i in range(len(newFace)): | |
newFace[i] += len(newPoints) | |
if not faceCCW or not faceCull: | |
self.points.extend(newPoints[::-1]) | |
self.faces.append(newFace) | |
newFaceInfo.append(FaceInfo(faceInfo.faceColour, True, True, not isStud and faceInfo.isGrainySlopeAllowed)) | |
self.verify(newFace, len(self.points)) | |
self.faceInfo.extend(newFaceInfo) | |
assert len(self.faces) == len(self.faceInfo) | |
# Append edge information | |
newEdges = [] | |
for edge in geometry.edges: | |
newEdges.append( (fixedMatrix @ edge[0], fixedMatrix @ edge[1]) ) | |
self.edges.extend(newEdges) | |
# ************************************************************************************** | |
# ************************************************************************************** | |
class LDrawNode: | |
"""A node in the hierarchy. References one LDrawFile""" | |
def __init__(self, filename, isFullFilepath, parentFilepath, colourName=Options.defaultColour, matrix=Math.identityMatrix, bfcCull=True, bfcInverted=False, isLSynthPart=False, isSubPart=False, isRootNode=True, groupNames=[]): | |
self.filename = filename | |
self.isFullFilepath = isFullFilepath | |
self.parentFilepath = parentFilepath | |
self.matrix = matrix | |
self.colourName = colourName | |
self.bfcInverted = bfcInverted | |
self.bfcCull = bfcCull | |
self.file = None | |
self.isLSynthPart = isLSynthPart | |
self.isSubPart = isSubPart | |
self.isRootNode = isRootNode | |
self.groupNames = groupNames.copy() | |
def look_at(obj_camera, target, up_vector): | |
bpy.context.view_layer.update() | |
loc_camera = obj_camera.matrix_world.to_translation() | |
#print("CamLoc = " + str(loc_camera[0]) + "," + str(loc_camera[1]) + "," + str(loc_camera[2])) | |
#print("TarLoc = " + str(target[0]) + "," + str(target[1]) + "," + str(target[2])) | |
#print("UpVec = " + str(up_vector[0]) + "," + str(up_vector[1]) + "," + str(up_vector[2])) | |
# back vector is a vector pointing from the target to the camera | |
back = loc_camera - target; | |
back.normalize() | |
# If our back and up vectors are very close to pointing the same way (or opposite), choose a different up_vector | |
if (abs(back.dot(up_vector)) > 0.9999): | |
up_vector=mathutils.Vector((0.0,0.0,1.0)) | |
if (abs(back.dot(up_vector)) > 0.9999): | |
up_vector=mathutils.Vector((1.0,0.0,0.0)) | |
right = up_vector.cross(back) | |
right.normalize() | |
up = back.cross(right) | |
up.normalize() | |
row1 = [ right.x, up.x, back.x, loc_camera.x ] | |
row2 = [ right.y, up.y, back.y, loc_camera.y ] | |
row3 = [ right.z, up.z, back.z, loc_camera.z ] | |
row4 = [ 0.0, 0.0, 0.0, 1.0 ] | |
#bpy.ops.mesh.primitive_ico_sphere_add(location=loc_camera+up,size=0.1) | |
#bpy.ops.mesh.primitive_cylinder_add(location=loc_camera+back,radius = 0.1, depth=0.2) | |
#bpy.ops.mesh.primitive_cone_add(location=loc_camera+right,radius1=0.1, radius2=0, depth=0.2) | |
obj_camera.matrix_world = mathutils.Matrix((row1, row2, row3, row4)) | |
#print(obj_camera.matrix_world) | |
def isBlenderObjectNode(self): | |
""" | |
Calculates if this node should become a Blender object. | |
Some nodes will become objects in Blender, some will not. | |
Typically nodes that reference a model or a part become Blender Objects, but not nodes that reference subparts. | |
""" | |
# The root node is always a Blender node | |
if self.isRootNode: | |
return True | |
# General rule: We are a Blender object if we are a part or higher (ie. if we are not a subPart) | |
isBON = not self.isSubPart | |
# Exception #1 - If flattening the hierarchy, we only want parts (not models) | |
if Options.flattenHierarchy: | |
isBON = self.file.isPart and not self.isSubPart | |
# Exception #2 - We are not a Blender Object if we are an LSynth part (so that all LSynth parts become a single mesh) | |
if self.isLSynthPart: | |
isBON = False | |
# Exception #3 - We are a Blender Object if we are a stud to be instanced | |
if Options.instanceStuds and self.file.isStud: | |
isBON = True | |
return isBON | |
def load(self): | |
# Is this file in the cache? | |
self.file = CachedFiles.getCached(self.filename) | |
if self.file is None: | |
# Not in cache, so load file | |
self.file = LDrawFile(self.filename, self.isFullFilepath, self.parentFilepath, None, self.isSubPart) | |
assert self.file is not None | |
# Add the new file to the cache | |
CachedFiles.addToCache(self.filename, self.file) | |
# Load any children | |
for child in self.file.childNodes: | |
child.load() | |
def resolveColour(colourName, realColourName): | |
if colourName == "16": | |
return realColourName | |
return colourName | |
def printBFC(self, depth=0): | |
# For debugging, displays BFC information | |
debugPrint("{0}Node {1} has cull={2} and invert={3} det={4}".format(" "*(depth*4), self.filename, self.bfcCull, self.bfcInverted, self.matrix.determinant())) | |
for child in self.file.childNodes: | |
child.printBFC(depth + 1) | |
def getBFCCode(accumCull, accumInvert, bfcCull, bfcInverted): | |
index = (8 if accumCull else 0) + (4 if accumInvert else 0) + (2 if bfcCull else 0) + (1 if bfcInverted else 0) | |
# Normally meshes are culled and not inverted, so don't bother with a code in this case | |
if index == 10: | |
return "" | |
# If this is out of the ordinary, add a code that makes it a unique name to cache the mesh properly | |
return "_{0}".format(index) | |
def getBlenderGeometry(self, realColourName, basename, parentMatrix=Math.identityMatrix, accumCull=True, accumInvert=False): | |
""" | |
Returns the geometry for the Blender Object at this node. | |
It accumulates the geometry of itself with all the geometry of it's children | |
recursively (specifically - those children that are not Blender Object nodes). | |
The result will become a single mesh in Blender. | |
""" | |
assert self.file is not None | |
accumCull = accumCull and self.bfcCull | |
accumInvert = accumInvert != self.bfcInverted | |
ourColourName = LDrawNode.resolveColour(self.colourName, realColourName) | |
code = LDrawNode.getBFCCode(accumCull, accumInvert, self.bfcCull, self.bfcInverted) | |
meshName = "Mesh_{0}_{1}{2}".format(basename, ourColourName, code) | |
key = (self.filename, ourColourName, accumCull, accumInvert, self.bfcCull, self.bfcInverted) | |
bakedGeometry = CachedGeometry.getCached(key) | |
if bakedGeometry is None: | |
combinedMatrix = parentMatrix @ self.matrix | |
# Start with a copy of our file's geometry | |
assert len(self.file.geometry.faces) == len(self.file.geometry.faceInfo) | |
bakedGeometry = LDrawGeometry() | |
bakedGeometry.appendGeometry(self.file.geometry, Math.identityMatrix, self.file.isStud, self.file.isStudLogo, combinedMatrix, self.bfcCull, self.bfcInverted) | |
# Replaces the default colour 16 in our faceColours list with a specific colour | |
for faceInfo in bakedGeometry.faceInfo: | |
faceInfo.faceColour = LDrawNode.resolveColour(faceInfo.faceColour, ourColourName) | |
# Append each child's geometry | |
for child in self.file.childNodes: | |
assert child.file is not None | |
if not child.isBlenderObjectNode(): | |
childColourName = LDrawNode.resolveColour(child.colourName, ourColourName) | |
childMeshName, bg = child.getBlenderGeometry(childColourName, basename, combinedMatrix, accumCull, accumInvert) | |
isStud = child.file.isStud | |
isStudLogo = child.file.isStudLogo | |
bakedGeometry.appendGeometry(bg, child.matrix, isStud, isStudLogo, combinedMatrix, self.bfcCull, self.bfcInverted) | |
CachedGeometry.addToCache(key, bakedGeometry) | |
assert len(bakedGeometry.faces) == len(bakedGeometry.faceInfo) | |
return (meshName, bakedGeometry) | |
# ************************************************************************************** | |
# ************************************************************************************** | |
class LDrawCamera: | |
"""Data about a camera""" | |
def __init__(self): | |
self.vert_fov_degrees = 30.0 | |
self.near = 0.01 | |
self.far = 100.0 | |
self.position = mathutils.Vector((0.0, 0.0, 0.0)) | |
self.target_position = mathutils.Vector((1.0, 0.0, 0.0)) | |
self.up_vector = mathutils.Vector((0.0, 1.0, 0.0)) | |
self.name = "Camera" | |
self.orthographic = False | |
self.hidden = False | |
def createCameraNode(self): | |
camData = bpy.data.cameras.new(self.name) | |
camera = bpy.data.objects.new(self.name, camData) | |
# Add to scene | |
camera.location = self.position | |
camera.data.sensor_fit = 'VERTICAL' | |
camera.data.angle = self.vert_fov_degrees * 3.1415926 / 180.0 | |
camera.data.clip_end = self.far | |
camera.data.clip_start = self.near | |
camera.hide_set(self.hidden) | |
self.hidden = False | |
if self.orthographic: | |
dist_target_to_camera = (self.position - self.target_position).length | |
camera.data.ortho_scale = dist_target_to_camera / 1.92 | |
camera.data.type = 'ORTHO' | |
self.orthographic = False | |
else: | |
camera.data.type = 'PERSP' | |
linkToScene(camera) | |
LDrawNode.look_at(camera, self.target_position, self.up_vector) | |
return camera | |
# ************************************************************************************** | |
# ************************************************************************************** | |
class LDrawFile: | |
"""Stores the contents of a single LDraw file. | |
Specifically this represents an IO, LDR, L3B, DAT or one '0 FILE' section of an MPD. | |
Splits up an MPD file into '0 FILE' sections and caches them.""" | |
def __loadLegoFile(self, filepath, isFullFilepath, parentFilepath): | |
# Resolve full filepath if necessary | |
if isFullFilepath is False: | |
if parentFilepath == "": | |
parentDir = os.path.dirname(filepath) | |
else: | |
parentDir = os.path.dirname(parentFilepath) | |
result = FileSystem.locate(filepath, parentDir) | |
if result is None: | |
printWarningOnce("Missing file {0}".format(filepath)) | |
return False | |
filepath = result | |
if os.path.splitext(filepath)[1] == ".io": | |
# Check if the file is encrypted (password protected) | |
is_encrypted = False | |
zf = zipfile.ZipFile(filepath) | |
for zinfo in zf.infolist(): | |
is_encrypted |= zinfo.flag_bits & 0x1 | |
if is_encrypted: | |
ShowMessageBox("Oops, this .io file is password protected", "Password protected files are not supported", 'ERROR') | |
return False | |
# Get a temporary directory. Store the TemporaryDirectory object in Configure so it's scope lasts long enough | |
Configure.tempDir = tempfile.TemporaryDirectory() | |
directory_to_extract_to = Configure.tempDir.name | |
# Decompress to temporary directory | |
with zipfile.ZipFile(filepath, 'r') as zip_ref: | |
zip_ref.extractall(directory_to_extract_to) | |
# It's the 'model.ldr' file we want to use | |
filepath = os.path.join(directory_to_extract_to, "model.ldr") | |
# Add the subdirectories of the directory to the search paths | |
Configure.appendPath(os.path.join(directory_to_extract_to, "CustomParts")) | |
Configure.appendPath(os.path.join(directory_to_extract_to, "CustomParts", "parts")) | |
if Options.resolution == "High": | |
Configure.appendPath(os.path.join(directory_to_extract_to, "CustomParts", "p", "48")) | |
elif Options.resolution == "Low": | |
Configure.appendPath(os.path.join(directory_to_extract_to, "CustomParts", "p", "8")) | |
Configure.appendPath(os.path.join(directory_to_extract_to, "CustomParts", "p")) | |
Configure.appendPath(os.path.join(directory_to_extract_to, "CustomParts", "s")) | |
Configure.appendPath(os.path.join(directory_to_extract_to, "CustomParts", "s", "s")) | |
self.fullFilepath = filepath | |
# Load text into local lines variable | |
lines = FileSystem.readTextFile(filepath) | |
if lines is None: | |
printWarningOnce("Could not read file {0}".format(filepath)) | |
lines = [] | |
# MPD files have separate sections between '0 FILE' and '0 NOFILE' lines. | |
# Split into sections between "0 FILE" and "0 NOFILE" lines | |
sections = [] | |
startLine = 0 | |
endLine = 0 | |
lineCount = 0 | |
sectionFilename = filepath | |
foundEnd = False | |
for line in lines: | |
parameters = line.strip().split() | |
if len(parameters) > 2: | |
if parameters[0] == "0" and parameters[1] == "FILE": | |
if foundEnd == False: | |
endLine = lineCount | |
if endLine > startLine: | |
sections.append((sectionFilename, lines[startLine:endLine])) | |
startLine = lineCount | |
foundEnd = False | |
sectionFilename = " ".join(parameters[2:]) | |
if parameters[0] == "0" and parameters[1] == "NOFILE": | |
endLine = lineCount | |
foundEnd = True | |
sections.append((sectionFilename, lines[startLine:endLine])) | |
lineCount += 1 | |
if foundEnd == False: | |
endLine = lineCount | |
if endLine > startLine: | |
sections.append((sectionFilename, lines[startLine:endLine])) | |
if len(sections) == 0: | |
return False | |
# First section is the main one | |
self.filename = sections[0][0] | |
self.lines = sections[0][1] | |
# Remaining sections are loaded into the cached files | |
for (sectionFilename, lines) in sections[1:]: | |
# Load section | |
file = LDrawFile(sectionFilename, False, filepath, lines, False) | |
assert file is not None | |
# Cache section | |
CachedFiles.addToCache(sectionFilename, file) | |
return True | |
def __isStud(filename): | |
"""Is this file a stud?""" | |
if LDrawFile.__isStudLogo(filename): | |
return True | |
# Extract just the filename, in lower case | |
filename = filename.replace("\\", os.path.sep) | |
name = os.path.basename(filename).lower() | |
return name in ( | |
"stud2.dat", | |
"stud6.dat", | |
"stud6a.dat", | |
"stud7.dat", | |
"stud10.dat", | |
"stud13.dat", | |
"stud15.dat", | |
"stud20.dat", | |
"studa.dat", | |
"teton.dat", # TENTE | |
"stud-logo3.dat", "stud-logo4.dat", "stud-logo5.dat", | |
"stud2-logo3.dat", "stud2-logo4.dat", "stud2-logo5.dat", | |
"stud6-logo3.dat", "stud6-logo4.dat", "stud6-logo5.dat", | |
"stud6a-logo3.dat", "stud6a-logo4.dat", "stud6a-logo5.dat", | |
"stud7-logo3.dat", "stud7-logo4.dat", "stud7-logo5.dat", | |
"stud10-logo3.dat", "stud10-logo4.dat", "stud10-logo5.dat", | |
"stud13-logo3.dat", "stud13-logo4.dat", "stud13-logo5.dat", | |
"stud15-logo3.dat", "stud15-logo4.dat", "stud15-logo5.dat", | |
"stud20-logo3.dat", "stud20-logo4.dat", "stud20-logo5.dat", | |
"studa-logo3.dat", "studa-logo4.dat", "studa-logo5.dat", | |
"studtente-logo.dat" # TENTE | |
) | |
def __isStudLogo(filename): | |
"""Is this file a stud logo?""" | |
# Extract just the filename, in lower case | |
filename = filename.replace("\\", os.path.sep) | |
name = os.path.basename(filename).lower() | |
return name in ("logo3.dat", "logo4.dat", "logo5.dat", "logotente.dat") | |
def __init__(self, filename, isFullFilepath, parentFilepath, lines = None, isSubPart=False): | |
"""Loads an LDraw file (IO, LDR, L3B, DAT or MPD)""" | |
global globalCamerasToAdd | |
global globalScaleFactor | |
self.filename = filename | |
self.lines = lines | |
self.isPart = False | |
self.isSubPart = isSubPart | |
self.isStud = LDrawFile.__isStud(filename) | |
self.isStudLogo = LDrawFile.__isStudLogo(filename) | |
self.isLSynthPart = False | |
self.isDoubleSided = False | |
self.geometry = LDrawGeometry() | |
self.childNodes = [] | |
self.bfcCertified = None | |
self.isModel = False | |
isGrainySlopeAllowed = not self.isStud | |
if self.lines is None: | |
# Load the file into self.lines | |
if not self.__loadLegoFile(self.filename, isFullFilepath, parentFilepath): | |
return | |
else: | |
# We are loading a section of our parent document, so full filepath is that of the parent | |
self.fullFilepath = parentFilepath | |
# BFC = Back face culling. The rules are arcane and complex, but at least | |
# it's kind of documented: http://www.ldraw.org/article/415.html | |
bfcLocalCull = True | |
bfcWindingCCW = True | |
bfcInvertNext = False | |
processingLSynthParts = False | |
camera = LDrawCamera() | |
currentGroupNames = [] | |
#debugPrint("Processing file {0}, isSubPart = {1}, found {2} lines".format(self.filename, self.isSubPart, len(self.lines))) | |
for line in self.lines: | |
parameters = line.strip().split() | |
# Skip empty lines | |
if len(parameters) == 0: | |
continue | |
# Pad with empty values to simplify parsing code | |
while len(parameters) < 9: | |
parameters.append("") | |
# Parse LDraw comments (some of which have special significance) | |
if parameters[0] == "0": | |
if parameters[1] == "!LDRAW_ORG": | |
partType = parameters[2].lower() | |
if 'part' in partType: | |
self.isPart = True | |
if 'subpart' in partType: | |
self.isSubPart = True | |
if 'primitive' in partType: | |
self.isSubPart = True | |
#if 'shortcut' in partType: | |
# self.isPart = True | |
if parameters[1] == "BFC": | |
# If unsure about being certified yet... | |
if self.bfcCertified is None: | |
if parameters[2] == "NOCERTIFY": | |
self.bfcCertified = False | |
else: | |
self.bfcCertified = True | |
if "CW" in parameters: | |
bfcWindingCCW = False | |
if "CCW" in parameters: | |
bfcWindingCCW = True | |
if "CLIP" in parameters: | |
bfcLocalCull = True | |
if "NOCLIP" in parameters: | |
bfcLocalCull = False | |
if "INVERTNEXT" in parameters: | |
bfcInvertNext = True | |
if parameters[1] == "SYNTH": | |
if parameters[2] == "SYNTHESIZED": | |
if parameters[3] == "BEGIN": | |
processingLSynthParts = True | |
if parameters[3] == "END": | |
processingLSynthParts = False | |
if parameters[1] == "!LDCAD": | |
if parameters[2] == "GENERATED": | |
processingLSynthParts = True | |
if parameters[1] == "!LEOCAD": | |
if parameters[2] == "GROUP": | |
if parameters[3] == "BEGIN": | |
currentGroupNames.append(" ".join(parameters[4:])) | |
elif parameters[3] == "END": | |
currentGroupNames.pop(-1) | |
if parameters[2] == "CAMERA": | |
if Options.importCameras: | |
parameters = parameters[3:] | |
while( len(parameters) > 0): | |
if parameters[0] == "FOV": | |
camera.vert_fov_degrees = float(parameters[1]) | |
parameters = parameters[2:] | |
elif parameters[0] == "ZNEAR": | |
camera.near = globalScaleFactor * float(parameters[1]) | |
parameters = parameters[2:] | |
elif parameters[0] == "ZFAR": | |
camera.far = globalScaleFactor * float(parameters[1]) | |
parameters = parameters[2:] | |
elif parameters[0] == "POSITION": | |
camera.position = Math.scaleMatrix @ mathutils.Vector((float(parameters[1]), float(parameters[2]), float(parameters[3]))) | |
parameters = parameters[4:] | |
elif parameters[0] == "TARGET_POSITION": | |
camera.target_position = Math.scaleMatrix @ mathutils.Vector((float(parameters[1]), float(parameters[2]), float(parameters[3]))) | |
parameters = parameters[4:] | |
elif parameters[0] == "UP_VECTOR": | |
camera.up_vector = mathutils.Vector((float(parameters[1]), float(parameters[2]), float(parameters[3]))) | |
parameters = parameters[4:] | |
elif parameters[0] == "ORTHOGRAPHIC": | |
camera.orthographic = True | |
parameters = parameters[1:] | |
elif parameters[0] == "HIDDEN": | |
camera.hidden = True | |
parameters = parameters[1:] | |
elif parameters[0] == "NAME": | |
camera.name = line.split(" NAME ",1)[1].strip() | |
globalCamerasToAdd.append(camera) | |
camera = LDrawCamera() | |
# By definition this is the last of the parameters | |
parameters = [] | |
else: | |
parameters = parameters[1:] | |
else: | |
if self.bfcCertified is None: | |
self.bfcCertified = False | |
self.isModel = (not self.isPart) and (not self.isSubPart) | |
# Parse a File reference | |
if parameters[0] == "1": | |
(x, y, z, a, b, c, d, e, f, g, h, i) = map(float, parameters[2:14]) | |
(x, y, z) = Math.scaleMatrix @ mathutils.Vector((x, y, z)) | |
localMatrix = mathutils.Matrix( ((a, b, c, x), (d, e, f, y), (g, h, i, z), (0, 0, 0, 1)) ) | |
new_filename = " ".join(parameters[14:]) | |
new_colourName = parameters[1] | |
det = localMatrix.determinant() | |
if det < 0: | |
bfcInvertNext = not bfcInvertNext | |
canCullChildNode = (self.bfcCertified or self.isModel) and bfcLocalCull and (det != 0) | |
if new_filename != "": | |
newNode = LDrawNode(new_filename, False, self.fullFilepath, new_colourName, localMatrix, canCullChildNode, bfcInvertNext, processingLSynthParts, not self.isModel, False, currentGroupNames) | |
self.childNodes.append(newNode) | |
else: | |
printWarningOnce("In file '{0}', the line '{1}' is not formatted corectly (ignoring).".format(self.fullFilepath, line)) | |
# Parse an edge | |
elif parameters[0] == "2": | |
self.geometry.parseEdge(parameters) | |
# Parse a Face (either a triangle or a quadrilateral) | |
elif parameters[0] == "3" or parameters[0] == "4": | |
if self.bfcCertified is None: | |
self.bfcCertified = False | |
if not self.bfcCertified or not bfcLocalCull: | |
printWarningOnce("Found double-sided polygons in file {0}".format(self.filename)) | |
self.isDoubleSided = True | |
assert len(self.geometry.faces) == len(self.geometry.faceInfo) | |
self.geometry.parseFace(parameters, self.bfcCertified and bfcLocalCull, bfcWindingCCW, isGrainySlopeAllowed) | |
assert len(self.geometry.faces) == len(self.geometry.faceInfo) | |
bfcInvertNext = False | |
#debugPrint("File {0} is part = {1}, is subPart = {2}, isModel = {3}".format(filename, self.isPart, isSubPart, self.isModel)) | |
# ************************************************************************************** | |
# ************************************************************************************** | |
class BlenderMaterials: | |
"""Creates and stores a cache of materials for Blender""" | |
__material_list = {} | |
if bpy.app.version >= (4, 0, 0): | |
__hasPrincipledShader = True | |
else: | |
__hasPrincipledShader = "ShaderNodeBsdfPrincipled" in [node.nodetype for node in getattr(bpy.types, "NODE_MT_category_SH_NEW_SHADER").category.items(None)] | |
def __getGroupName(name): | |
if Options.instructionsLook: | |
return name + " Instructions" | |
return name | |
def __createNodeBasedMaterial(blenderName, col, isSlopeMaterial=False): | |
"""Set Cycles Material Values.""" | |
# Reuse current material if it exists, otherwise create a new material | |
if bpy.data.materials.get(blenderName) is None: | |
material = bpy.data.materials.new(blenderName) | |
else: | |
material = bpy.data.materials[blenderName] | |
# Use nodes | |
material.use_nodes = True | |
if col is not None: | |
if len(col["colour"]) == 3: | |
colour = col["colour"] + (1.0,) | |
material.diffuse_color = getDiffuseColor(col["colour"][0:3]) | |
if Options.instructionsLook: | |
material.blend_method = 'BLEND' | |
material.show_transparent_back = False | |
if col is not None: | |
# Dark colours have white lines | |
if LegoColours.isDark(colour): | |
material.line_color = (1.0, 1.0, 1.0, 1.0) | |
nodes = material.node_tree.nodes | |
links = material.node_tree.links | |
# Remove any existing nodes | |
for n in nodes: | |
nodes.remove(n) | |
if col is not None: | |
isTransparent = col["alpha"] < 1.0 | |
if Options.instructionsLook: | |
BlenderMaterials.__createCyclesBasic(nodes, links, colour, col["alpha"], "") | |
elif col["name"] == "Milky_White": | |
BlenderMaterials.__createCyclesMilkyWhite(nodes, links, colour) | |
elif col["luminance"] > 0: | |
BlenderMaterials.__createCyclesEmission(nodes, links, colour, col["alpha"], col["luminance"]) | |
elif col["material"] == "CHROME": | |
BlenderMaterials.__createCyclesChrome(nodes, links, colour) | |
elif col["material"] == "PEARLESCENT": | |
BlenderMaterials.__createCyclesPearlescent(nodes, links, colour) | |
elif col["material"] == "METAL": | |
BlenderMaterials.__createCyclesMetal(nodes, links, colour) | |
elif col["material"] == "GLITTER": | |
BlenderMaterials.__createCyclesGlitter(nodes, links, colour, col["secondary_colour"]) | |
elif col["material"] == "SPECKLE": | |
BlenderMaterials.__createCyclesSpeckle(nodes, links, colour, col["secondary_colour"]) | |
elif col["material"] == "RUBBER": | |
BlenderMaterials.__createCyclesRubber(nodes, links, colour, col["alpha"]) | |
else: | |
BlenderMaterials.__createCyclesBasic(nodes, links, colour, col["alpha"], col["name"]) | |
if isSlopeMaterial and not Options.instructionsLook: | |
BlenderMaterials.__createCyclesSlopeTexture(nodes, links, 0.6) | |
elif Options.curvedWalls and not Options.instructionsLook: | |
BlenderMaterials.__createCyclesConcaveWalls(nodes, links, 20 * globalScaleFactor) | |
material["Lego.isTransparent"] = isTransparent | |
return material | |
BlenderMaterials.__createCyclesBasic(nodes, links, (1.0, 1.0, 0.0, 1.0), 1.0, "") | |
material["Lego.isTransparent"] = False | |
return material | |
def __nodeConcaveWalls(nodes, strength, x, y): | |
node = nodes.new('ShaderNodeGroup') | |
node.node_tree = bpy.data.node_groups[BlenderMaterials.__getGroupName('Concave Walls')] | |
node.location = x, y | |
node.inputs['Strength'].default_value = strength | |
return node | |
def __nodeSlopeTexture(nodes, strength, x, y): | |
node = nodes.new('ShaderNodeGroup') | |
node.node_tree = bpy.data.node_groups[BlenderMaterials.__getGroupName('Slope Texture')] | |
node.location = x, y | |
node.inputs['Strength'].default_value = strength | |
return node | |
def __nodeLegoStandard(nodes, colour, x, y): | |
node = nodes.new('ShaderNodeGroup') | |
node.node_tree = bpy.data.node_groups[BlenderMaterials.__getGroupName('Lego Standard')] | |
node.location = x, y | |
node.inputs['Color'].default_value = colour | |
return node | |
def __nodeLegoTransparentFluorescent(nodes, colour, x, y): | |
node = nodes.new('ShaderNodeGroup') | |
node.node_tree = bpy.data.node_groups[BlenderMaterials.__getGroupName('Lego Transparent Fluorescent')] | |
node.location = x, y | |
node.inputs['Color'].default_value = colour | |
return node | |
def __nodeLegoTransparent(nodes, colour, x, y): | |
node = nodes.new('ShaderNodeGroup') | |
node.node_tree = bpy.data.node_groups[BlenderMaterials.__getGroupName('Lego Transparent')] | |
node.location = x, y | |
node.inputs['Color'].default_value = colour | |
return node | |
def __nodeLegoRubberSolid(nodes, colour, x, y): | |
node = nodes.new('ShaderNodeGroup') | |
node.node_tree = bpy.data.node_groups[BlenderMaterials.__getGroupName('Lego Rubber Solid')] | |
node.location = x, y | |
node.inputs['Color'].default_value = colour | |
return node | |
def __nodeLegoRubberTranslucent(nodes, colour, x, y): | |
node = nodes.new('ShaderNodeGroup') | |
node.node_tree = bpy.data.node_groups[BlenderMaterials.__getGroupName('Lego Rubber Translucent')] | |
node.location = x, y | |
node.inputs['Color'].default_value = colour | |
return node | |
def __nodeLegoEmission(nodes, colour, luminance, x, y): | |
node = nodes.new('ShaderNodeGroup') | |
node.node_tree = bpy.data.node_groups[BlenderMaterials.__getGroupName('Lego Emission')] | |
node.location = x, y | |
node.inputs['Color'].default_value = colour | |
node.inputs['Luminance'].default_value = luminance | |
return node | |
def __nodeLegoChrome(nodes, colour, x, y): | |
node = nodes.new('ShaderNodeGroup') | |
node.node_tree = bpy.data.node_groups[BlenderMaterials.__getGroupName('Lego Chrome')] | |
node.location = x, y | |
node.inputs['Color'].default_value = colour | |
return node | |
def __nodeLegoPearlescent(nodes, colour, x, y): | |
node = nodes.new('ShaderNodeGroup') | |
node.node_tree = bpy.data.node_groups[BlenderMaterials.__getGroupName('Lego Pearlescent')] | |
node.location = x, y | |
node.inputs['Color'].default_value = colour | |
return node | |
def __nodeLegoMetal(nodes, colour, x, y): | |
node = nodes.new('ShaderNodeGroup') | |
node.node_tree = bpy.data.node_groups[BlenderMaterials.__getGroupName('Lego Metal')] | |
node.location = x, y | |
node.inputs['Color'].default_value = colour | |
return node | |
def __nodeLegoGlitter(nodes, colour, glitterColour, x, y): | |
node = nodes.new('ShaderNodeGroup') | |
node.node_tree = bpy.data.node_groups[BlenderMaterials.__getGroupName('Lego Glitter')] | |
node.location = x, y | |
node.inputs['Color'].default_value = colour | |
node.inputs['Glitter Color'].default_value = glitterColour | |
return node | |
def __nodeLegoSpeckle(nodes, colour, speckleColour, x, y): | |
node = nodes.new('ShaderNodeGroup') | |
node.node_tree = bpy.data.node_groups[BlenderMaterials.__getGroupName('Lego Speckle')] | |
node.location = x, y | |
node.inputs['Color'].default_value = colour | |
node.inputs['Speckle Color'].default_value = speckleColour | |
return node | |
def __nodeLegoMilkyWhite(nodes, colour, x, y): | |
node = nodes.new('ShaderNodeGroup') | |
node.node_tree = bpy.data.node_groups[BlenderMaterials.__getGroupName('Lego Milky White')] | |
node.location = x, y | |
node.inputs['Color'].default_value = colour | |
return node | |
def __nodeMix(nodes, factor, x, y): | |
node = nodes.new('ShaderNodeMixShader') | |
node.location = x, y | |
node.inputs['Fac'].default_value = factor | |
return node | |
def __nodeOutput(nodes, x, y): | |
node = nodes.new('ShaderNodeOutputMaterial') | |
node.location = x, y | |
return node | |
def __nodeDielectric(nodes, roughness, reflection, transparency, ior, x, y): | |
node = nodes.new('ShaderNodeGroup') | |
node.node_tree = bpy.data.node_groups['PBR-Dielectric'] | |
node.location = x, y | |
node.inputs['Roughness'].default_value = roughness | |
node.inputs['Reflection'].default_value = reflection | |
node.inputs['Transparency'].default_value = transparency | |
node.inputs['IOR'].default_value = ior | |
return node | |
def __nodePrincipled(nodes, subsurface, sub_rad, metallic, roughness, clearcoat, clearcoat_roughness, ior, transmission, x, y): | |
node = nodes.new('ShaderNodeBsdfPrincipled') | |
node.location = x, y | |
# Some inputs are renamed in Blender 4 | |
if bpy.app.version >= (4, 0, 0): | |
node.inputs['Subsurface Weight'].default_value = subsurface | |
node.inputs['Coat Weight'].default_value = clearcoat | |
node.inputs['Coat Roughness'].default_value = clearcoat_roughness | |
node.inputs['Transmission Weight'].default_value = transmission | |
else: | |
# Blender 3.X or earlier | |
node.inputs['Subsurface'].default_value = subsurface | |
node.inputs['Clearcoat'].default_value = clearcoat | |
node.inputs['Clearcoat Roughness'].default_value = clearcoat_roughness | |
node.inputs['Transmission'].default_value = transmission | |
node.inputs['Subsurface Radius'].default_value = mathutils.Vector( (sub_rad, sub_rad, sub_rad) ) | |
node.inputs['Metallic'].default_value = metallic | |
node.inputs['Roughness'].default_value = roughness | |
node.inputs['IOR'].default_value = ior | |
return node | |
def __nodeHSV(nodes, h, s, v, x, y): | |
node = nodes.new('ShaderNodeHueSaturation') | |
node.location = x, y | |
node.inputs[0].default_value = h | |
node.inputs[1].default_value = s | |
node.inputs[2].default_value = v | |
return node | |
def __nodeSeparateHSV(nodes, x, y): | |
node = nodes.new('ShaderNodeSeparateHSV') | |
node.location = x, y | |
return node | |
def __nodeCombineHSV(nodes, x, y): | |
node = nodes.new('ShaderNodeCombineHSV') | |
node.location = x, y | |
return node | |
def __nodeTexCoord(nodes, x, y): | |
node = nodes.new('ShaderNodeTexCoord') | |
node.location = x, y | |
return node | |
def __nodeTexWave(nodes, wave_type, wave_profile, scale, distortion, detail, detailScale, x, y): | |
node = nodes.new('ShaderNodeTexWave') | |
node.wave_type = wave_type | |
node.wave_profile = wave_profile | |
node.inputs[1].default_value = scale | |
node.inputs[2].default_value = distortion | |
node.inputs[3].default_value = detail | |
node.inputs[4].default_value = detailScale | |
node.location = x, y | |
return node | |
def __nodeDiffuse(nodes, roughness, x, y): | |
node = nodes.new('ShaderNodeBsdfDiffuse') | |
node.location = x, y | |
node.inputs['Color'].default_value = (1,1,1,1) | |
node.inputs['Roughness'].default_value = roughness | |
return node | |
def __nodeGlass(nodes, roughness, ior, distribution, x, y): | |
node = nodes.new('ShaderNodeBsdfGlass') | |
node.location = x, y | |
node.distribution = distribution | |
node.inputs['Color'].default_value = (1,1,1,1) | |
node.inputs['Roughness'].default_value = roughness | |
node.inputs['IOR'].default_value = ior | |
return node | |
def __nodeFresnel(nodes, ior, x, y): | |
node = nodes.new('ShaderNodeFresnel') | |
node.location = x, y | |
node.inputs['IOR'].default_value = ior | |
return node | |
def __nodeGlossy(nodes, colour, roughness, distribution, x, y): | |
node = nodes.new('ShaderNodeBsdfGlossy') | |
node.location = x, y | |
node.distribution = distribution | |
node.inputs['Color'].default_value = colour | |
node.inputs['Roughness'].default_value = roughness | |
return node | |
def __nodeTranslucent(nodes, x, y): | |
node = nodes.new('ShaderNodeBsdfTranslucent') | |
node.location = x, y | |
return node | |
def __nodeTransparent(nodes, x, y): | |
node = nodes.new('ShaderNodeBsdfTransparent') | |
node.location = x, y | |
return node | |
def __nodeAddShader(nodes, x, y): | |
node = nodes.new('ShaderNodeAddShader') | |
node.location = x, y | |
return node | |
def __nodeVolume(nodes, density, x, y): | |
node = nodes.new('ShaderNodeVolumeAbsorption') | |
node.inputs['Density'].default_value = density | |
node.location = x, y | |
return node | |
def __nodeLightPath(nodes, x, y): | |
node = nodes.new('ShaderNodeLightPath') | |
node.location = x, y | |
return node | |
def __nodeMath(nodes, operation, x, y): | |
node = nodes.new('ShaderNodeMath') | |
node.operation = operation | |
node.location = x, y | |
return node | |
def __nodeVectorMath(nodes, operation, x, y): | |
node = nodes.new('ShaderNodeVectorMath') | |
node.operation = operation | |
node.location = x, y | |
return node | |
def __nodeEmission(nodes, x, y): | |
node = nodes.new('ShaderNodeEmission') | |
node.location = x, y | |
return node | |
def __nodeVoronoi(nodes, scale, x, y): | |
node = nodes.new('ShaderNodeTexVoronoi') | |
node.location = x, y | |
node.inputs['Scale'].default_value = scale | |
return node | |
def __nodeGamma(nodes, gamma, x, y): | |
node = nodes.new('ShaderNodeGamma') | |
node.location = x, y | |
node.inputs['Gamma'].default_value = gamma | |
return node | |
def __nodeColorRamp(nodes, pos1, colour1, pos2, colour2, x, y): | |
node = nodes.new('ShaderNodeValToRGB') | |
node.location = x, y | |
node.color_ramp.elements[0].position = pos1 | |
node.color_ramp.elements[0].color = colour1 | |
node.color_ramp.elements[1].position = pos2 | |
node.color_ramp.elements[1].color = colour2 | |
return node | |
def __nodeNoiseTexture(nodes, scale, detail, distortion, x, y): | |
node = nodes.new('ShaderNodeTexNoise') | |
node.location = x, y | |
node.inputs['Scale'].default_value = scale | |
node.inputs['Detail'].default_value = detail | |
node.inputs['Distortion'].default_value = distortion | |
return node | |
def __nodeBumpShader(nodes, strength, distance, x, y): | |
node = nodes.new('ShaderNodeBump') | |
node.location = x, y | |
node.inputs[0].default_value = strength | |
node.inputs[1].default_value = distance | |
return node | |
def __nodeRefraction(nodes, roughness, ior, x, y): | |
node = nodes.new('ShaderNodeBsdfRefraction') | |
node.inputs['Roughness'].default_value = roughness | |
node.inputs['IOR'].default_value = ior | |
node.location = x, y | |
return node | |
def __getGroup(nodes): | |
out = None | |
for x in nodes: | |
if x.type == 'GROUP': | |
return x | |
return None | |
def __createCyclesConcaveWalls(nodes, links, strength): | |
"""Concave wall normals for Cycles render engine""" | |
node = BlenderMaterials.__nodeConcaveWalls(nodes, strength, -200, 5) | |
out = BlenderMaterials.__getGroup(nodes) | |
if out is not None: | |
links.new(node.outputs['Normal'], out.inputs['Normal']) | |
def __createCyclesSlopeTexture(nodes, links, strength): | |
"""Slope face normals for Cycles render engine""" | |
node = BlenderMaterials.__nodeSlopeTexture(nodes, strength, -200, 5) | |
out = BlenderMaterials.__getGroup(nodes) | |
if out is not None: | |
links.new(node.outputs['Normal'], out.inputs['Normal']) | |
def __createCyclesBasic(nodes, links, diffColour, alpha, colName): | |
"""Basic Material for Cycles render engine.""" | |
if alpha < 1: | |
if LegoColours.isFluorescentTransparent(colName): | |
node = BlenderMaterials.__nodeLegoTransparentFluorescent(nodes, diffColour, 0, 5) | |
else: | |
node = BlenderMaterials.__nodeLegoTransparent(nodes, diffColour, 0, 5) | |
else: | |
node = BlenderMaterials.__nodeLegoStandard(nodes, diffColour, 0, 5) | |
out = BlenderMaterials.__nodeOutput(nodes, 200, 0) | |
links.new(node.outputs['Shader'], out.inputs[0]) | |
def __createCyclesEmission(nodes, links, diffColour, alpha, luminance): | |
"""Emission material for Cycles render engine.""" | |
node = BlenderMaterials.__nodeLegoEmission(nodes, diffColour, luminance/100.0, 0, 5) | |
out = BlenderMaterials.__nodeOutput(nodes, 200, 0) | |
links.new(node.outputs['Shader'], out.inputs[0]) | |
def __createCyclesChrome(nodes, links, diffColour): | |
"""Chrome material for Cycles render engine.""" | |
node = BlenderMaterials.__nodeLegoChrome(nodes, diffColour, 0, 5) | |
out = BlenderMaterials.__nodeOutput(nodes, 200, 0) | |
links.new(node.outputs['Shader'], out.inputs[0]) | |
def __createCyclesPearlescent(nodes, links, diffColour): | |
"""Pearlescent material for Cycles render engine.""" | |
node = BlenderMaterials.__nodeLegoPearlescent(nodes, diffColour, 0, 5) | |
out = BlenderMaterials.__nodeOutput(nodes, 200, 0) | |
links.new(node.outputs['Shader'], out.inputs[0]) | |
def __createCyclesMetal(nodes, links, diffColour): | |
"""Metal material for Cycles render engine.""" | |
node = BlenderMaterials.__nodeLegoMetal(nodes, diffColour, 0, 5) | |
out = BlenderMaterials.__nodeOutput(nodes, 200, 0) | |
links.new(node.outputs['Shader'], out.inputs[0]) | |
def __createCyclesGlitter(nodes, links, diffColour, glitterColour): | |
"""Glitter material for Cycles render engine.""" | |
glitterColour = LegoColours.lightenRGBA(glitterColour, 0.5) | |
node = BlenderMaterials.__nodeLegoGlitter(nodes, diffColour, glitterColour, 0, 5) | |
out = BlenderMaterials.__nodeOutput(nodes, 200, 0) | |
links.new(node.outputs['Shader'], out.inputs[0]) | |
def __createCyclesSpeckle(nodes, links, diffColour, speckleColour): | |
"""Speckle material for Cycles render engine.""" | |
speckleColour = LegoColours.lightenRGBA(speckleColour, 0.5) | |
node = BlenderMaterials.__nodeLegoSpeckle(nodes, diffColour, speckleColour, 0, 5) | |
out = BlenderMaterials.__nodeOutput(nodes, 200, 0) | |
links.new(node.outputs['Shader'], out.inputs[0]) | |
def __createCyclesRubber(nodes, links, diffColour, alpha): | |
"""Rubber material colours for Cycles render engine.""" | |
out = BlenderMaterials.__nodeOutput(nodes, 200, 0) | |
if alpha < 1.0: | |
rubber = BlenderMaterials.__nodeLegoRubberTranslucent(nodes, diffColour, 0, 5) | |
else: | |
rubber = BlenderMaterials.__nodeLegoRubberSolid(nodes, diffColour, 0, 5) | |
links.new(rubber.outputs[0], out.inputs[0]) | |
def __createCyclesMilkyWhite(nodes, links, diffColour): | |
"""Milky White material for Cycles render engine.""" | |
node = BlenderMaterials.__nodeLegoMilkyWhite(nodes, diffColour, 0, 5) | |
out = BlenderMaterials.__nodeOutput(nodes, 200, 0) | |
links.new(node.outputs['Shader'], out.inputs[0]) | |
def __is_int(s): | |
try: | |
int(s) | |
return True | |
except ValueError: | |
return False | |
def __getColourData(colourName): | |
"""Get the colour data associated with the colour name""" | |
# Try the LDraw defined colours | |
if BlenderMaterials.__is_int(colourName): | |
colourInt = int(colourName) | |
if colourInt in LegoColours.colours: | |
return LegoColours.colours[colourInt] | |
# Handle direct colours | |
# Direct colours are documented here: http://www.hassings.dk/l3/l3p.html | |
linearRGBA = LegoColours.hexStringToLinearRGBA(colourName) | |
if linearRGBA is None: | |
printWarningOnce("Could not decode {0} to a colour".format(colourName)) | |
return None | |
return { | |
"name": colourName, | |
"colour": linearRGBA[0:3], | |
"alpha": linearRGBA[3], | |
"luminance": 0.0, | |
"material": "BASIC" | |
} | |
# ********************************************************************************** | |
def getMaterial(colourName, isSlopeMaterial): | |
pureColourName = colourName | |
if isSlopeMaterial: | |
colourName = colourName + "_s" | |
# If it's already in the cache, use that | |
if (colourName in BlenderMaterials.__material_list): | |
result = BlenderMaterials.__material_list[colourName] | |
return result | |
# Create a name for the material based on the colour | |
if Options.instructionsLook: | |
blenderName = "MatInst_{0}".format(colourName) | |
elif Options.curvedWalls and not isSlopeMaterial: | |
blenderName = "Material_{0}_c".format(colourName) | |
else: | |
blenderName = "Material_{0}".format(colourName) | |
# If the name already exists in Blender, use that | |
if Options.overwriteExistingMaterials is False: | |
if blenderName in bpy.data.materials: | |
return bpy.data.materials[blenderName] | |
# Create new material | |
col = BlenderMaterials.__getColourData(pureColourName) | |
material = BlenderMaterials.__createNodeBasedMaterial(blenderName, col, isSlopeMaterial) | |
if material is None: | |
printWarningOnce("Could not create material for blenderName {0}".format(blenderName)) | |
# Add material to cache | |
BlenderMaterials.__material_list[colourName] = material | |
return material | |
# ********************************************************************************** | |
def clearCache(): | |
BlenderMaterials.__material_list = {} | |
# ********************************************************************************** | |
def addInputSocket(group, my_socket_type, myname): | |
if bpy.app.version >= (4, 0, 0): | |
if my_socket_type.endswith("FloatFactor"): | |
my_socket_type = my_socket_type[:-6] | |
elif my_socket_type.endswith("VectorDirection"): | |
my_socket_type = my_socket_type[:-9] | |
group.interface.new_socket(name=myname, in_out="INPUT", socket_type=my_socket_type) | |
else: | |
if my_socket_type.endswith("Vector"): | |
my_socket_type += "Direction" | |
group.inputs.new(my_socket_type, myname) | |
# ********************************************************************************** | |
def addOutputSocket(group, my_socket_type, myname): | |
if bpy.app.version >= (4, 0, 0): | |
if my_socket_type.endswith("FloatFactor"): | |
my_socket_type = my_socket_type[:-6] | |
elif my_socket_type.endswith("VectorDirection"): | |
my_socket_type = my_socket_type[:-9] | |
group.interface.new_socket(name=myname, in_out="OUTPUT", socket_type=my_socket_type) | |
else: | |
if my_socket_type.endswith("Vector"): | |
my_socket_type += "Direction" | |
group.outputs.new(my_socket_type, myname) | |
# ********************************************************************************** | |
def setDefaults(group, name, default_value, min_value, max_value): | |
if bpy.app.version >= (4, 0, 0): | |
group_inputs = group.nodes["Group Input"].outputs | |
group_inputs[name].default_value = default_value | |
# TODO: How to set min_value and max_value? | |
else: | |
group_inputs = group.inputs | |
group_inputs[name].default_value = default_value | |
group_inputs[name].min_value = min_value | |
group_inputs[name].max_value = max_value | |
# ********************************************************************************** | |
def __createGroup(name, x1, y1, x2, y2, createShaderOutput): | |
group = bpy.data.node_groups.new(name, 'ShaderNodeTree') | |
# create input node | |
node_input = group.nodes.new('NodeGroupInput') | |
node_input.location = (x1,y1) | |
# create output node | |
node_output = group.nodes.new('NodeGroupOutput') | |
node_output.location = (x2,y2) | |
if createShaderOutput: | |
BlenderMaterials.addOutputSocket(group, 'NodeSocketShader', 'Shader') | |
return (group, node_input, node_output) | |
# ********************************************************************************** | |
def __createBlenderDistanceToCenterNodeGroup(): | |
if bpy.data.node_groups.get('Distance-To-Center') is None: | |
debugPrint("createBlenderDistanceToCenterNodeGroup #create") | |
# create a group | |
group, node_input, node_output = BlenderMaterials.__createGroup('Distance-To-Center', -930, 0, 240, 0, False) | |
BlenderMaterials.addOutputSocket(group, 'NodeSocketVectorDirection', 'Vector') | |
# create nodes | |
node_texture_coordinate = BlenderMaterials.__nodeTexCoord(group.nodes, -730, 0) | |
node_vector_subtraction1 = BlenderMaterials.__nodeVectorMath(group.nodes, 'SUBTRACT', -535, 0) | |
node_vector_subtraction1.inputs[1].default_value[0] = 0.5 | |
node_vector_subtraction1.inputs[1].default_value[1] = 0.5 | |
node_vector_subtraction1.inputs[1].default_value[2] = 0.5 | |
node_normalize = BlenderMaterials.__nodeVectorMath(group.nodes, 'NORMALIZE', -535, -245) | |
node_dot_product = BlenderMaterials.__nodeVectorMath(group.nodes, 'DOT_PRODUCT', -340, -125) | |
node_multiply = group.nodes.new('ShaderNodeMixRGB') | |
node_multiply.blend_type = 'MULTIPLY' | |
node_multiply.inputs['Fac'].default_value = 1.0 | |
node_multiply.location = -145, -125 | |
node_vector_subtraction2 = BlenderMaterials.__nodeVectorMath(group.nodes, 'SUBTRACT', 40, 0) | |
# link nodes together | |
group.links.new(node_texture_coordinate.outputs['Generated'], node_vector_subtraction1.inputs[0]) | |
group.links.new(node_texture_coordinate.outputs['Normal'], node_normalize.inputs[0]) | |
group.links.new(node_vector_subtraction1.outputs['Vector'], node_dot_product.inputs[0]) | |
group.links.new(node_normalize.outputs['Vector'], node_dot_product.inputs[1]) | |
group.links.new(node_dot_product.outputs['Value'], node_multiply.inputs['Color1']) | |
group.links.new(node_normalize.outputs['Vector'], node_multiply.inputs['Color2']) | |
group.links.new(node_vector_subtraction1.outputs['Vector'], node_vector_subtraction2.inputs[0]) | |
group.links.new(node_multiply.outputs['Color'], node_vector_subtraction2.inputs[1]) | |
group.links.new(node_vector_subtraction2.outputs['Vector'], node_output.inputs[0]) | |
# ********************************************************************************** | |
def __createBlenderVectorElementPowerNodeGroup(): | |
if bpy.data.node_groups.get('Vector-Element-Power') is None: | |
debugPrint("createBlenderVectorElementPowerNodeGroup #create") | |
# create a group | |
group, node_input, node_output = BlenderMaterials.__createGroup('Vector-Element-Power', -580, 0, 400, 0, False) | |
BlenderMaterials.addInputSocket(group, 'NodeSocketFloat', 'Exponent') | |
BlenderMaterials.addInputSocket(group, 'NodeSocketVectorDirection', 'Vector') | |
BlenderMaterials.addOutputSocket(group, 'NodeSocketVectorDirection', 'Vector') | |
# create nodes | |
node_separate_xyz = group.nodes.new('ShaderNodeSeparateXYZ') | |
node_separate_xyz.location = -385, -140 | |
node_abs_x = BlenderMaterials.__nodeMath(group.nodes, 'ABSOLUTE', -180, 180) | |
node_abs_y = BlenderMaterials.__nodeMath(group.nodes, 'ABSOLUTE', -180, 0) | |
node_abs_z = BlenderMaterials.__nodeMath(group.nodes, 'ABSOLUTE', -180, -180) | |
node_power_x = BlenderMaterials.__nodeMath(group.nodes, 'POWER', 20, 180) | |
node_power_y = BlenderMaterials.__nodeMath(group.nodes, 'POWER', 20, 0) | |
node_power_z = BlenderMaterials.__nodeMath(group.nodes, 'POWER', 20, -180) | |
node_combine_xyz = group.nodes.new('ShaderNodeCombineXYZ') | |
node_combine_xyz.location = 215, 0 | |
# link nodes together | |
group.links.new(node_input.outputs['Vector'], node_separate_xyz.inputs[0]) | |
group.links.new(node_separate_xyz.outputs['X'], node_abs_x.inputs[0]) | |
group.links.new(node_separate_xyz.outputs['Y'], node_abs_y.inputs[0]) | |
group.links.new(node_separate_xyz.outputs['Z'], node_abs_z.inputs[0]) | |
group.links.new(node_abs_x.outputs['Value'], node_power_x.inputs[0]) | |
group.links.new(node_input.outputs['Exponent'], node_power_x.inputs[1]) | |
group.links.new(node_abs_y.outputs['Value'], node_power_y.inputs[0]) | |
group.links.new(node_input.outputs['Exponent'], node_power_y.inputs[1]) | |
group.links.new(node_abs_z.outputs['Value'], node_power_z.inputs[0]) | |
group.links.new(node_input.outputs['Exponent'], node_power_z.inputs[1]) | |
group.links.new(node_power_x.outputs['Value'], node_combine_xyz.inputs['X']) | |
group.links.new(node_power_y.outputs['Value'], node_combine_xyz.inputs['Y']) | |
group.links.new(node_power_z.outputs['Value'], node_combine_xyz.inputs['Z']) | |
group.links.new(node_combine_xyz.outputs['Vector'], node_output.inputs[0]) | |
# ********************************************************************************** | |
def __createBlenderConvertToNormalsNodeGroup(): | |
if bpy.data.node_groups.get('Convert-To-Normals') is None: | |
debugPrint("createBlenderConvertToNormalsNodeGroup #create") | |
# create a group | |
group, node_input, node_output = BlenderMaterials.__createGroup('Convert-To-Normals', -490, 0, 400, 0, False) | |
BlenderMaterials.addInputSocket(group, 'NodeSocketFloat', 'Vector Length') | |
BlenderMaterials.addInputSocket(group, 'NodeSocketFloat', 'Smoothing') | |
BlenderMaterials.addInputSocket(group, 'NodeSocketFloat', 'Strength') | |
BlenderMaterials.addInputSocket(group, 'NodeSocketVectorDirection', 'Normal') | |
BlenderMaterials.addOutputSocket(group, 'NodeSocketVectorDirection', 'Normal') | |
# create nodes | |
node_power = BlenderMaterials.__nodeMath(group.nodes, 'POWER', -290, 150) | |
node_colorramp = group.nodes.new('ShaderNodeValToRGB') | |
node_colorramp.color_ramp.color_mode = 'RGB' | |
node_colorramp.color_ramp.interpolation = 'EASE' | |
node_colorramp.color_ramp.elements[0].color = (1, 1, 1, 1) | |
node_colorramp.color_ramp.elements[1].color = (0, 0, 0, 1) | |
node_colorramp.color_ramp.elements[1].position = 0.45 | |
node_colorramp.location = -95, 150 | |
node_bump = group.nodes.new('ShaderNodeBump') | |
node_bump.inputs['Distance'].default_value = 0.02 | |
node_bump.location = 200, 0 | |
# link nodes together | |
group.links.new(node_input.outputs['Vector Length'], node_power.inputs[0]) | |
group.links.new(node_input.outputs['Smoothing'], node_power.inputs[1]) | |
group.links.new(node_power.outputs['Value'], node_colorramp.inputs[0]) | |
group.links.new(node_input.outputs['Strength'], node_bump.inputs['Strength']) | |
group.links.new(node_colorramp.outputs['Color'], node_bump.inputs['Height']) | |
group.links.new(node_input.outputs['Normal'], node_bump.inputs['Normal']) | |
group.links.new(node_bump.outputs['Normal'], node_output.inputs[0]) | |
# ********************************************************************************** | |
def __createBlenderConcaveWallsNodeGroup(): | |
if bpy.data.node_groups.get('Concave Walls') is None: | |
debugPrint("createBlenderConcaveWallsNodeGroup #create") | |
# create a group | |
group, node_input, node_output = BlenderMaterials.__createGroup('Concave Walls', -530, 0, 300, 0, False) | |
BlenderMaterials.addInputSocket(group, 'NodeSocketFloat', 'Strength') | |
BlenderMaterials.addInputSocket(group, 'NodeSocketVectorDirection', 'Normal') | |
BlenderMaterials.addOutputSocket(group, 'NodeSocketVectorDirection', 'Normal') | |
# create nodes | |
node_distance_to_center = group.nodes.new('ShaderNodeGroup') | |
node_distance_to_center.node_tree = bpy.data.node_groups['Distance-To-Center'] | |
node_distance_to_center.location = (-340,105) | |
node_vector_elements_power = group.nodes.new('ShaderNodeGroup') | |
node_vector_elements_power.node_tree = bpy.data.node_groups['Vector-Element-Power'] | |
node_vector_elements_power.location = (-120,105) | |
node_vector_elements_power.inputs['Exponent'].default_value = 4.0 | |
node_convert_to_normals = group.nodes.new('ShaderNodeGroup') | |
node_convert_to_normals.node_tree = bpy.data.node_groups['Convert-To-Normals'] | |
node_convert_to_normals.location = (90,0) | |
node_convert_to_normals.inputs['Strength'].default_value = 0.2 | |
node_convert_to_normals.inputs['Smoothing'].default_value = 0.3 | |
# link nodes together | |
group.links.new(node_distance_to_center.outputs['Vector'], node_vector_elements_power.inputs['Vector']) | |
group.links.new(node_vector_elements_power.outputs['Vector'], node_convert_to_normals.inputs['Vector Length']) | |
group.links.new(node_input.outputs['Strength'], node_convert_to_normals.inputs['Strength']) | |
group.links.new(node_input.outputs['Normal'], node_convert_to_normals.inputs['Normal']) | |
group.links.new(node_convert_to_normals.outputs['Normal'], node_output.inputs['Normal']) | |
# ********************************************************************************** | |
def __createBlenderSlopeTextureNodeGroup(): | |
global globalScaleFactor | |
if bpy.data.node_groups.get('Slope Texture') is None: | |
debugPrint("createBlenderSlopeTextureNodeGroup #create") | |
# create a group | |
group, node_input, node_output = BlenderMaterials.__createGroup('Slope Texture', -530, 0, 300, 0, False) | |
BlenderMaterials.addInputSocket(group, 'NodeSocketFloat', 'Strength') | |
BlenderMaterials.addInputSocket(group, 'NodeSocketVectorDirection', 'Normal') | |
BlenderMaterials.addOutputSocket(group, 'NodeSocketVectorDirection', 'Normal') | |
# create nodes | |
node_texture_coordinate = BlenderMaterials.__nodeTexCoord(group.nodes, -300, 240) | |
node_voronoi = BlenderMaterials.__nodeVoronoi(group.nodes, 3.0/globalScaleFactor, -100, 155) | |
node_bump = BlenderMaterials.__nodeBumpShader(group.nodes, 0.3, 0.08, 90, 50) | |
node_bump.invert = True | |
# link nodes together | |
group.links.new(node_texture_coordinate.outputs['Object'], node_voronoi.inputs['Vector']) | |
group.links.new(node_voronoi.outputs['Distance'], node_bump.inputs['Height']) | |
group.links.new(node_input.outputs['Strength'], node_bump.inputs['Strength']) | |
group.links.new(node_input.outputs['Normal'], node_bump.inputs['Normal']) | |
group.links.new(node_bump.outputs['Normal'], node_output.inputs['Normal']) | |
# ********************************************************************************** | |
def __createBlenderFresnelNodeGroup(): | |
if bpy.data.node_groups.get('PBR-Fresnel-Roughness') is None: | |
debugPrint("createBlenderFresnelNodeGroup #create") | |
# create a group | |
group, node_input, node_output = BlenderMaterials.__createGroup('PBR-Fresnel-Roughness', -530, 0, 300, 0, False) | |
BlenderMaterials.addInputSocket(group, 'NodeSocketFloatFactor', 'Roughness') | |
BlenderMaterials.addInputSocket(group, 'NodeSocketFloat', 'IOR') | |
BlenderMaterials.addInputSocket(group, 'NodeSocketVectorDirection', 'Normal') | |
BlenderMaterials.addOutputSocket(group, 'NodeSocketFloatFactor', 'Fresnel Factor') | |
# create nodes | |
node_fres = group.nodes.new('ShaderNodeFresnel') | |
node_fres.location = (110,0) | |
node_mix = group.nodes.new('ShaderNodeMixRGB') | |
node_mix.location = (-80,-75) | |
node_bump = group.nodes.new('ShaderNodeBump') | |
node_bump.location = (-320,-172) | |
# node_bump.hide = True | |
node_geom = group.nodes.new('ShaderNodeNewGeometry') | |
node_geom.location = (-320,-360) | |
# node_geom.hide = True | |
# link nodes together | |
group.links.new(node_input.outputs['Roughness'], node_mix.inputs['Fac']) # Input Roughness -> Mix Fac | |
group.links.new(node_input.outputs['IOR'], node_fres.inputs['IOR']) # Input IOR -> Fres IOR | |
group.links.new(node_input.outputs['Normal'], node_bump.inputs['Normal']) # Input Normal -> Bump Normal | |
group.links.new(node_bump.outputs['Normal'], node_mix.inputs['Color1']) # Bump Normal -> Mix Color1 | |
group.links.new(node_geom.outputs['Incoming'], node_mix.inputs['Color2']) # Geom Incoming -> Mix Colour2 | |
group.links.new(node_mix.outputs['Color'], node_fres.inputs['Normal']) # Mix Color -> Fres Normal | |
group.links.new(node_fres.outputs['Fac'], node_output.inputs['Fresnel Factor']) # Fres Fac -> Group Output Fresnel Factor | |
# ********************************************************************************** | |
def __createBlenderReflectionNodeGroup(): | |
if bpy.data.node_groups.get('PBR-Reflection') is None: | |
debugPrint("createBlenderReflectionNodeGroup #create") | |
# create a group | |
group, node_input, node_output = BlenderMaterials.__createGroup('PBR-Reflection', -530, 0, 300, 0, True) | |
BlenderMaterials.addInputSocket(group, 'NodeSocketShader', 'Shader') | |
BlenderMaterials.addInputSocket(group, 'NodeSocketFloatFactor', 'Roughness') | |
BlenderMaterials.addInputSocket(group, 'NodeSocketFloatFactor', 'Reflection') | |
BlenderMaterials.addInputSocket(group, 'NodeSocketFloat', 'IOR') | |
BlenderMaterials.addInputSocket(group, 'NodeSocketVectorDirection', 'Normal') | |
node_fresnel_roughness = group.nodes.new('ShaderNodeGroup') | |
node_fresnel_roughness.node_tree = bpy.data.node_groups['PBR-Fresnel-Roughness'] | |
node_fresnel_roughness.location = (-290,145) | |
node_mixrgb = group.nodes.new('ShaderNodeMixRGB') | |
node_mixrgb.location = (-80,115) | |
node_mixrgb.inputs['Color2'].default_value = (0.0, 0.0, 0.0, 1.0) | |
node_mix_shader = group.nodes.new('ShaderNodeMixShader') | |
node_mix_shader.location = (100,0) | |
node_glossy = group.nodes.new('ShaderNodeBsdfGlossy') | |
node_glossy.inputs['Color'].default_value = (1.0, 1.0, 1.0, 1.0) | |
node_glossy.location = (-290,-95) | |
# link nodes together | |
group.links.new(node_input.outputs['Shader'], node_mix_shader.inputs[1]) | |
group.links.new(node_input.outputs['Roughness'], node_fresnel_roughness.inputs['Roughness']) | |
group.links.new(node_input.outputs['Roughness'], node_glossy.inputs['Roughness']) | |
group.links.new(node_input.outputs['Reflection'], node_mixrgb.inputs['Color1']) | |
group.links.new(node_input.outputs['IOR'], node_fresnel_roughness.inputs['IOR']) | |
group.links.new(node_input.outputs['Normal'], node_fresnel_roughness.inputs['Normal']) | |
group.links.new(node_input.outputs['Normal'], node_glossy.inputs['Normal']) | |
group.links.new(node_fresnel_roughness.outputs[0], node_mixrgb.inputs[0]) | |
group.links.new(node_mixrgb.outputs[0], node_mix_shader.inputs[0]) | |
group.links.new(node_glossy.outputs[0], node_mix_shader.inputs[2]) | |
group.links.new(node_mix_shader.outputs[0], node_output.inputs['Shader']) | |
# ********************************************************************************** | |
def __createBlenderDielectricNodeGroup(): | |
if bpy.data.node_groups.get('PBR-Dielectric') is None: | |
debugPrint("createBlenderDielectricNodeGroup #create") | |
# create a group | |
group, node_input, node_output = BlenderMaterials.__createGroup('PBR-Dielectric', -530, 70, 500, 0, True) | |
BlenderMaterials.addInputSocket(group, 'NodeSocketColor','Color') | |
BlenderMaterials.addInputSocket(group, 'NodeSocketFloatFactor','Roughness') | |
BlenderMaterials.addInputSocket(group, 'NodeSocketFloatFactor','Reflection') | |
BlenderMaterials.addInputSocket(group, 'NodeSocketFloatFactor','Transparency') | |
BlenderMaterials.addInputSocket(group, 'NodeSocketFloat','IOR') | |
BlenderMaterials.addInputSocket(group, 'NodeSocketVectorDirection','Normal') | |
BlenderMaterials.setDefaults(group, 'IOR', 1.46, 0.0, 100.0) | |
BlenderMaterials.setDefaults(group, 'Roughness', 0.2, 0.0, 1.0) | |
BlenderMaterials.setDefaults(group, 'Reflection', 0.1, 0.0, 1.0) | |
BlenderMaterials.setDefaults(group, 'Transparency', 0.0, 0.0, 1.0) | |
node_diffuse = group.nodes.new('ShaderNodeBsdfDiffuse') | |
node_diffuse.location = (-110,145) | |
node_reflection = group.nodes.new('ShaderNodeGroup') | |
node_reflection.node_tree = bpy.data.node_groups['PBR-Reflection'] | |
node_reflection.location = (100,115) | |
node_power = BlenderMaterials.__nodeMath(group.nodes, 'POWER', -330, -105) | |
node_power.inputs[1].default_value = 2.0 | |
node_glass = group.nodes.new('ShaderNodeBsdfGlass') | |
node_glass.location = (100,-105) | |
node_mix_shader = group.nodes.new('ShaderNodeMixShader') | |
node_mix_shader.location = (300,5) | |
# link nodes together | |
group.links.new(node_input.outputs['Color'], node_diffuse.inputs['Color']) | |
group.links.new(node_input.outputs['Roughness'], node_power.inputs[0]) | |
group.links.new(node_input.outputs['Reflection'], node_reflection.inputs['Reflection']) | |
group.links.new(node_input.outputs['IOR'], node_reflection.inputs['IOR']) | |
group.links.new(node_input.outputs['Normal'], node_diffuse.inputs['Normal']) | |
group.links.new(node_input.outputs['Normal'], node_reflection.inputs['Normal']) | |
group.links.new(node_power.outputs[0], node_diffuse.inputs['Roughness']) | |
group.links.new(node_power.outputs[0], node_reflection.inputs['Roughness']) | |
group.links.new(node_diffuse.outputs[0], node_reflection.inputs['Shader']) | |
group.links.new(node_reflection.outputs['Shader'], node_mix_shader.inputs['Shader']) | |
group.links.new(node_input.outputs['Color'], node_glass.inputs['Color']) | |
group.links.new(node_input.outputs['IOR'], node_glass.inputs['IOR']) | |
group.links.new(node_input.outputs['Normal'], node_glass.inputs['Normal']) | |
group.links.new(node_power.outputs[0], node_glass.inputs['Roughness']) | |
group.links.new(node_input.outputs['Transparency'], node_mix_shader.inputs[0]) | |
group.links.new(node_glass.outputs[0], node_mix_shader.inputs[2]) | |
group.links.new(node_mix_shader.outputs['Shader'], node_output.inputs['Shader']) | |
# ********************************************************************************** | |
def __getSubsurfaceColor(node): | |
if 'Subsurface Color' in node.inputs: | |
# Blender 3 | |
return node.inputs['Subsurface Color'] | |
# Blender 4 - Subsurface Colour has been removed, so just use the base colour instead | |
return node.inputs['Base Color'] | |
# ********************************************************************************** | |
def __createBlenderLegoStandardNodeGroup(): | |
groupName = BlenderMaterials.__getGroupName('Lego Standard') | |
if bpy.data.node_groups.get(groupName) is None: | |
debugPrint("createBlenderLegoStandardNodeGroup #create") | |
# create a group | |
group, node_input, node_output = BlenderMaterials.__createGroup(groupName, -250, 0, 300, 0, True) | |
BlenderMaterials.addInputSocket(group,'NodeSocketColor','Color') | |
BlenderMaterials.addInputSocket(group,'NodeSocketVectorDirection','Normal') | |
if Options.instructionsLook: | |
node_emission = BlenderMaterials.__nodeEmission(group.nodes, 0, 0) | |
group.links.new(node_input.outputs['Color'], node_emission.inputs['Color']) | |
group.links.new(node_emission.outputs['Emission'], node_output.inputs['Shader']) | |
else: | |
if BlenderMaterials.usePrincipledShader: | |
node_main = BlenderMaterials.__nodePrincipled(group.nodes, 5 * globalScaleFactor, 0.05, 0.0, 0.1, 0.0, 0.0, 1.45, 0.0, 0, 0) | |
output_name = 'BSDF' | |
color_name = 'Base Color' | |
group.links.new(node_input.outputs['Color'], BlenderMaterials.__getSubsurfaceColor(node_main)) | |
else: | |
node_main = BlenderMaterials.__nodeDielectric(group.nodes, 0.2, 0.1, 0.0, 1.46, 0, 0) | |
output_name = 'Shader' | |
color_name = 'Color' | |
# link nodes together | |
group.links.new(node_input.outputs['Color'], node_main.inputs[color_name]) | |
group.links.new(node_input.outputs['Normal'], node_main.inputs['Normal']) | |
group.links.new(node_main.outputs[output_name], node_output.inputs['Shader']) | |
# ********************************************************************************** | |
def __createBlenderLegoTransparentNodeGroup(): | |
groupName = BlenderMaterials.__getGroupName('Lego Transparent') | |
if bpy.data.node_groups.get(groupName) is None: | |
debugPrint("createBlenderLegoTransparentNodeGroup #create") | |
# create a group | |
group, node_input, node_output = BlenderMaterials.__createGroup(groupName, -250, 0, 300, 0, True) | |
BlenderMaterials.addInputSocket(group,'NodeSocketColor','Color') | |
BlenderMaterials.addInputSocket(group,'NodeSocketVectorDirection','Normal') | |
if Options.instructionsLook: | |
node_emission = BlenderMaterials.__nodeEmission(group.nodes, 0, 0) | |
node_transparent = BlenderMaterials.__nodeTransparent(group.nodes, 0, 100) | |
node_mix1 = BlenderMaterials.__nodeMix(group.nodes, 0.5, 400, 100) | |
node_light = BlenderMaterials.__nodeLightPath(group.nodes, 200, 400) | |
node_less = BlenderMaterials.__nodeMath(group.nodes, 'LESS_THAN', 400, 400) | |
node_mix2 = BlenderMaterials.__nodeMix(group.nodes, 0.5, 600, 300) | |
node_output.location = (800,0) | |
group.links.new(node_input.outputs['Color'], node_emission.inputs['Color']) | |
group.links.new(node_transparent.outputs[0], node_mix1.inputs[1]) | |
group.links.new(node_emission.outputs['Emission'], node_mix1.inputs[2]) | |
group.links.new(node_transparent.outputs[0], node_mix2.inputs[1]) | |
group.links.new(node_mix1.outputs[0], node_mix2.inputs[2]) | |
group.links.new(node_light.outputs['Transparent Depth'], node_less.inputs[0]) | |
group.links.new(node_less.outputs[0], node_mix2.inputs['Fac']) | |
group.links.new(node_mix2.outputs[0], node_output.inputs['Shader']) | |
else: | |
if BlenderMaterials.usePrincipledShader: | |
node_principled = BlenderMaterials.__nodePrincipled(group.nodes, 0.0, 0.0, 0.0, 0.05, 0.0, 0.0, 1.585, 1.0, 45, 340) | |
# link nodes together | |
group.links.new(node_input.outputs['Color'], node_principled.inputs['Base Color']) | |
group.links.new(node_input.outputs['Normal'], node_principled.inputs['Normal']) | |
group.links.new(node_principled.outputs['BSDF'], node_output.inputs['Shader']) | |
else: | |
node_main = BlenderMaterials.__nodeDielectric(group.nodes, 0.15, 0.1, 0.97, 1.46, 0, 0) | |
# link nodes together | |
group.links.new(node_input.outputs['Color'], node_main.inputs['Color']) | |
group.links.new(node_input.outputs['Normal'], node_main.inputs['Normal']) | |
group.links.new(node_main.outputs['Shader'], node_output.inputs['Shader']) | |
# ********************************************************************************** | |
def __createBlenderLegoTransparentFluorescentNodeGroup(): | |
groupName = BlenderMaterials.__getGroupName('Lego Transparent Fluorescent') | |
if bpy.data.node_groups.get(groupName) is None: | |
debugPrint("createBlenderLegoTransparentFluorescentNodeGroup #create") | |
# create a group | |
group, node_input, node_output = BlenderMaterials.__createGroup(groupName, -250, 0, 300, 0, True) | |
BlenderMaterials.addInputSocket(group,'NodeSocketColor','Color') | |
BlenderMaterials.addInputSocket(group,'NodeSocketVectorDirection','Normal') | |
if Options.instructionsLook: | |
node_emission = BlenderMaterials.__nodeEmission(group.nodes, 0, 0) | |
node_transparent = BlenderMaterials.__nodeTransparent(group.nodes, 0, 100) | |
node_mix1 = BlenderMaterials.__nodeMix(group.nodes, 0.5, 400, 100) | |
node_light = BlenderMaterials.__nodeLightPath(group.nodes, 200, 400) | |
node_less = BlenderMaterials.__nodeMath(group.nodes, 'LESS_THAN', 400, 400) | |
node_mix2 = BlenderMaterials.__nodeMix(group.nodes, 0.5, 600, 300) | |
node_output.location = (800,0) | |
group.links.new(node_input.outputs['Color'], node_emission.inputs['Color']) | |
group.links.new(node_transparent.outputs[0], node_mix1.inputs[1]) | |
group.links.new(node_emission.outputs['Emission'], node_mix1.inputs[2]) | |
group.links.new(node_transparent.outputs[0], node_mix2.inputs[1]) | |
group.links.new(node_mix1.outputs[0], node_mix2.inputs[2]) | |
group.links.new(node_light.outputs['Transparent Depth'], node_less.inputs[0]) | |
group.links.new(node_less.outputs[0], node_mix2.inputs['Fac']) | |
group.links.new(node_mix2.outputs[0], node_output.inputs['Shader']) | |
else: | |
if BlenderMaterials.usePrincipledShader: | |
node_principled = BlenderMaterials.__nodePrincipled(group.nodes, 0.0, 0.0, 0.0, 0.05, 0.0, 0.0, 1.585, 1.0, 45, 340) | |
node_emission = BlenderMaterials.__nodeEmission(group.nodes, 45, -160) | |
node_mix = BlenderMaterials.__nodeMix(group.nodes, 0.03, 300, 290) | |
node_output.location = 500, 290 | |
# link nodes together | |
group.links.new(node_input.outputs['Color'], node_principled.inputs['Base Color']) | |
group.links.new(node_input.outputs['Color'], node_emission.inputs['Color']) | |
group.links.new(node_input.outputs['Normal'], node_principled.inputs['Normal']) | |
group.links.new(node_principled.outputs['BSDF'], node_mix.inputs[1]) | |
group.links.new(node_emission.outputs['Emission'], node_mix.inputs[2]) | |
group.links.new(node_mix.outputs[0], node_output.inputs['Shader']) | |
else: | |
node_main = BlenderMaterials.__nodeDielectric(group.nodes, 0.15, 0.1, 0.97, 1.46, 0, 0) | |
# link nodes together | |
group.links.new(node_input.outputs['Color'], node_main.inputs['Color']) | |
group.links.new(node_input.outputs['Normal'], node_main.inputs['Normal']) | |
group.links.new(node_main.outputs['Shader'], node_output.inputs['Shader']) | |
# ********************************************************************************** | |
def __createBlenderLegoRubberNodeGroup(): | |
groupName = BlenderMaterials.__getGroupName('Lego Rubber Solid') | |
if bpy.data.node_groups.get(groupName) is None: | |
debugPrint("createBlenderLegoRubberNodeGroup #create") | |
# create a group | |
group, node_input, node_output = BlenderMaterials.__createGroup(groupName, 45-950, 340-50, 45+200, 340-5, True) | |
BlenderMaterials.addInputSocket(group,'NodeSocketColor','Color') | |
BlenderMaterials.addInputSocket(group,'NodeSocketVectorDirection','Normal') | |
if BlenderMaterials.usePrincipledShader: | |
node_noise = BlenderMaterials.__nodeNoiseTexture(group.nodes, 250, 2, 0.0, 45-770, 340-200) | |
node_bump1 = BlenderMaterials.__nodeBumpShader(group.nodes, 1.0, 0.3, 45-366, 340-200) | |
node_bump2 = BlenderMaterials.__nodeBumpShader(group.nodes, 1.0, 0.1, 45-184, 340-115) | |
node_subtract = BlenderMaterials.__nodeMath(group.nodes, 'SUBTRACT', 45-570, 340-216) | |
node_principled = BlenderMaterials.__nodePrincipled(group.nodes, 0.0, 0.0, 0.0, 0.4, 0.03, 0.0, 1.45, 0.0, 45, 340) | |
node_subtract.inputs[1].default_value = 0.4 | |
group.links.new(node_input.outputs['Color'], node_principled.inputs['Base Color']) | |
group.links.new(node_principled.outputs['BSDF'], node_output.inputs[0]) | |
group.links.new(node_noise.outputs['Color'], node_subtract.inputs[0]) | |
group.links.new(node_subtract.outputs[0], node_bump1.inputs['Height']) | |
group.links.new(node_bump1.outputs['Normal'], node_bump2.inputs['Normal']) | |
group.links.new(node_bump2.outputs['Normal'], node_principled.inputs['Normal']) | |
else: | |
node_dielectric = BlenderMaterials.__nodeDielectric(group.nodes, 0.5, 0.07, 0.0, 1.52, 0, 0) | |
# link nodes together | |
group.links.new(node_input.outputs['Color'], node_dielectric.inputs['Color']) | |
group.links.new(node_input.outputs['Normal'], node_dielectric.inputs['Normal']) | |
group.links.new(node_dielectric.outputs['Shader'], node_output.inputs['Shader']) | |
# ********************************************************************************** | |
def __createBlenderLegoRubberTranslucentNodeGroup(): | |
groupName = BlenderMaterials.__getGroupName('Lego Rubber Translucent') | |
if bpy.data.node_groups.get(groupName) is None: | |
debugPrint("createBlenderLegoRubberTranslucentNodeGroup #create") | |
# create a group | |
group, node_input, node_output = BlenderMaterials.__createGroup(groupName, -250, 0, 250, 0, True) | |
BlenderMaterials.addInputSocket(group,'NodeSocketColor','Color') | |
BlenderMaterials.addInputSocket(group,'NodeSocketVectorDirection','Normal') | |
if BlenderMaterials.usePrincipledShader: | |
node_noise = BlenderMaterials.__nodeNoiseTexture(group.nodes, 250, 2, 0.0, 45-770, 340-200) | |
node_bump1 = BlenderMaterials.__nodeBumpShader(group.nodes, 1.0, 0.3, 45-366, 340-200) | |
node_bump2 = BlenderMaterials.__nodeBumpShader(group.nodes, 1.0, 0.1, 45-184, 340-115) | |
node_subtract = BlenderMaterials.__nodeMath(group.nodes, 'SUBTRACT', 45-570, 340-216) | |
node_principled = BlenderMaterials.__nodePrincipled(group.nodes, 0.0, 0.0, 0.0, 0.4, 0.03, 0.0, 1.45, 0.0, 45, 340) | |
node_mix = BlenderMaterials.__nodeMix(group.nodes, 0.8, 300, 290) | |
node_refraction = BlenderMaterials.__nodeRefraction(group.nodes, 0.0, 1.45, 290-242, 154-330) | |
node_input.location = -320, 290 | |
node_output.location = 530, 285 | |
node_subtract.inputs[1].default_value = 0.4 | |
group.links.new(node_input.outputs['Normal'], node_refraction.inputs['Normal']) | |
group.links.new(node_refraction.outputs[0], node_mix.inputs[2]) | |
group.links.new(node_principled.outputs[0], node_mix.inputs[1]) | |
group.links.new(node_mix.outputs[0], node_output.inputs[0]) | |
group.links.new(node_input.outputs['Color'], node_principled.inputs['Base Color']) | |
group.links.new(node_noise.outputs['Color'], node_subtract.inputs[0]) | |
group.links.new(node_subtract.outputs[0], node_bump1.inputs['Height']) | |
group.links.new(node_bump1.outputs['Normal'], node_bump2.inputs['Normal']) | |
group.links.new(node_bump2.outputs['Normal'], node_principled.inputs['Normal']) | |
group.links.new(node_mix.outputs[0], node_output.inputs[0]) | |
else: | |
node_dielectric = BlenderMaterials.__nodeDielectric(group.nodes, 0.15, 0.1, 0.97, 1.46, 0, 0) | |
# link nodes together | |
group.links.new(node_input.outputs['Color'], node_dielectric.inputs['Color']) | |
group.links.new(node_input.outputs['Normal'], node_dielectric.inputs['Normal']) | |
group.links.new(node_dielectric.outputs['Shader'], node_output.inputs['Shader']) | |
# ************************************************************************************** | |
def __createBlenderLegoEmissionNodeGroup(): | |
groupName = BlenderMaterials.__getGroupName('Lego Emission') | |
if bpy.data.node_groups.get(groupName) is None: | |
debugPrint("createBlenderLegoEmissionNodeGroup #create") | |
# create a group | |
group, node_input, node_output = BlenderMaterials.__createGroup(groupName, -450, 90, 250, 0, True) | |
BlenderMaterials.addInputSocket(group,'NodeSocketColor','Color') | |
BlenderMaterials.addInputSocket(group,'NodeSocketFloatFactor','Luminance') | |
BlenderMaterials.addInputSocket(group,'NodeSocketVectorDirection','Normal') | |
node_emit = BlenderMaterials.__nodeEmission(group.nodes, -242, -123) | |
node_mix = BlenderMaterials.__nodeMix(group.nodes, 0.5, 0, 90) | |
if BlenderMaterials.usePrincipledShader: | |
node_main = BlenderMaterials.__nodePrincipled(group.nodes, 1.0, 0.05, 0.0, 0.5, 0.0, 0.03, 1.45, 0.0, -242, 154+240) | |
group.links.new(node_input.outputs['Color'], BlenderMaterials.__getSubsurfaceColor(node_main)) | |
group.links.new(node_input.outputs['Color'], node_emit.inputs['Color']) | |
main_colour = 'Base Color' | |
else: | |
node_main = BlenderMaterials.__nodeTranslucent(group.nodes, -242, 154) | |
main_colour = 'Color' | |
# link nodes together | |
group.links.new(node_input.outputs['Color'], node_main.inputs[main_colour]) | |
group.links.new(node_input.outputs['Normal'], node_main.inputs['Normal']) | |
group.links.new(node_input.outputs['Luminance'], node_mix.inputs[0]) | |
group.links.new(node_main.outputs[0], node_mix.inputs[1]) | |
group.links.new(node_emit.outputs[0], node_mix.inputs[2]) | |
group.links.new(node_mix.outputs[0], node_output.inputs[0]) | |
# ********************************************************************************** | |
def __createBlenderLegoChromeNodeGroup(): | |
groupName = BlenderMaterials.__getGroupName('Lego Chrome') | |
if bpy.data.node_groups.get(groupName) is None: | |
debugPrint("createBlenderLegoChromeNodeGroup #create") | |
# create a group | |
group, node_input, node_output = BlenderMaterials.__createGroup(groupName, -450, 90, 250, 0, True) | |
BlenderMaterials.addInputSocket(group,'NodeSocketColor','Color') | |
BlenderMaterials.addInputSocket(group,'NodeSocketVectorDirection','Normal') | |
if BlenderMaterials.usePrincipledShader: | |
node_hsv = BlenderMaterials.__nodeHSV(group.nodes, 0.5, 0.9, 2.0, -90, 0) | |
node_principled = BlenderMaterials.__nodePrincipled(group.nodes, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 2.4, 0.0, 100, 0) | |
node_output.location = (575, -140) | |
# link nodes together | |
group.links.new(node_input.outputs['Color'], node_hsv.inputs['Color']) | |
group.links.new(node_input.outputs['Normal'], node_principled.inputs['Normal']) | |
group.links.new(node_hsv.outputs['Color'], node_principled.inputs['Base Color']) | |
group.links.new(node_principled.outputs['BSDF'], node_output.inputs[0]) | |
else: | |
node_glossyOne = BlenderMaterials.__nodeGlossy(group.nodes, (1,1,1,1), 0.03, 'GGX', -242, 154) | |
node_glossyTwo = BlenderMaterials.__nodeGlossy(group.nodes, (1.0, 1.0, 1.0, 1.0), 0.03, 'BECKMANN', -242, -23) | |
node_mix = BlenderMaterials.__nodeMix(group.nodes, 0.01, 0, 90) | |
# link nodes together | |
group.links.new(node_input.outputs['Color'], node_glossyOne.inputs['Color']) | |
group.links.new(node_input.outputs['Normal'], node_glossyOne.inputs['Normal']) | |
group.links.new(node_input.outputs['Normal'], node_glossyTwo.inputs['Normal']) | |
group.links.new(node_glossyOne.outputs[0], node_mix.inputs[1]) | |
group.links.new(node_glossyTwo.outputs[0], node_mix.inputs[2]) | |
group.links.new(node_mix.outputs[0], node_output.inputs[0]) | |
# ********************************************************************************** | |
def __createBlenderLegoPearlescentNodeGroup(): | |
groupName = BlenderMaterials.__getGroupName('Lego Pearlescent') | |
if bpy.data.node_groups.get(groupName) is None: | |
debugPrint("createBlenderLegoPearlescentNodeGroup #create") | |
# create a group | |
group, node_input, node_output = BlenderMaterials.__createGroup(groupName, -450, 90, 630, 95, True) | |
BlenderMaterials.addInputSocket(group,'NodeSocketColor','Color') | |
BlenderMaterials.addInputSocket(group,'NodeSocketVectorDirection','Normal') | |
if BlenderMaterials.usePrincipledShader: | |
node_principled = BlenderMaterials.__nodePrincipled(group.nodes, 1.0, 0.25, 0.5, 0.2, 1.0, 0.2, 1.6, 0.0, 310, 95) | |
node_sep_hsv = BlenderMaterials.__nodeSeparateHSV(group.nodes, -240, 75) | |
node_multiply = BlenderMaterials.__nodeMath(group.nodes, 'MULTIPLY', -60, 0) | |
node_com_hsv = BlenderMaterials.__nodeCombineHSV(group.nodes, 110, 95) | |
node_tex_coord = BlenderMaterials.__nodeTexCoord(group.nodes, -730, -223) | |
node_tex_wave = BlenderMaterials.__nodeTexWave(group.nodes, 'BANDS', 'SIN', 0.5, 40, 1, 1.5, -520, -190) | |
node_color_ramp = BlenderMaterials.__nodeColorRamp(group.nodes, 0.329, (0.89, 0.89, 0.89, 1), 0.820, (1, 1, 1, 1), -340, -70) | |
element = node_color_ramp.color_ramp.elements.new(1.0) | |
element.color = (1.118, 1.118, 1.118, 1) | |
# link nodes together | |
group.links.new(node_input.outputs['Color'], node_sep_hsv.inputs['Color']) | |
group.links.new(node_input.outputs['Normal'], node_principled.inputs['Normal']) | |
group.links.new(node_sep_hsv.outputs['H'], node_com_hsv.inputs['H']) | |
group.links.new(node_sep_hsv.outputs['S'], node_com_hsv.inputs['S']) | |
group.links.new(node_sep_hsv.outputs['V'], node_multiply.inputs[0]) | |
group.links.new(node_com_hsv.outputs['Color'], node_principled.inputs['Base Color']) | |
group.links.new(node_com_hsv.outputs['Color'], BlenderMaterials.__getSubsurfaceColor(node_principled)) | |
group.links.new(node_tex_coord.outputs['Object'], node_tex_wave.inputs['Vector']) | |
group.links.new(node_tex_wave.outputs['Fac'], node_color_ramp.inputs['Fac']) | |
group.links.new(node_color_ramp.outputs['Color'], node_multiply.inputs[1]) | |
group.links.new(node_multiply.outputs[0], node_com_hsv.inputs['V']) | |
group.links.new(node_principled.outputs['BSDF'], node_output.inputs[0]) | |
else: | |
node_diffuse = BlenderMaterials.__nodeDiffuse(group.nodes, 0.0, -242, -23) | |
node_glossy = BlenderMaterials.__nodeGlossy(group.nodes, (1,1,1,1), 0.05, 'BECKMANN', -242, 154) | |
node_mix = BlenderMaterials.__nodeMix(group.nodes, 0.4, 0, 90) | |
# link nodes together | |
group.links.new(node_input.outputs['Color'], node_diffuse.inputs['Color']) | |
group.links.new(node_input.outputs['Color'], node_glossy.inputs['Color']) | |
group.links.new(node_input.outputs['Normal'], node_diffuse.inputs['Normal']) | |
group.links.new(node_input.outputs['Normal'], node_glossy.inputs['Normal']) | |
group.links.new(node_glossy.outputs[0], node_mix.inputs[1]) | |
group.links.new(node_diffuse.outputs[0], node_mix.inputs[2]) | |
group.links.new(node_mix.outputs[0], node_output.inputs[0]) | |
# ********************************************************************************** | |
def __createBlenderLegoMetalNodeGroup(): | |
groupName = BlenderMaterials.__getGroupName('Lego Metal') | |
if bpy.data.node_groups.get(groupName) is None: | |
debugPrint("createBlenderLegoMetalNodeGroup #create") | |
# create a group | |
group, node_input, node_output = BlenderMaterials.__createGroup(groupName, -450, 90, 250, 0, True) | |
BlenderMaterials.addInputSocket(group,'NodeSocketColor','Color') | |
BlenderMaterials.addInputSocket(group,'NodeSocketVectorDirection','Normal') | |
if BlenderMaterials.usePrincipledShader: | |
node_principled = BlenderMaterials.__nodePrincipled(group.nodes, 0.0, 0.0, 0.8, 0.2, 0.0, 0.03, 1.45, 0.0, 310, 95) | |
group.links.new(node_input.outputs['Color'], node_principled.inputs['Base Color']) | |
group.links.new(node_input.outputs['Normal'], node_principled.inputs['Normal']) | |
group.links.new(node_principled.outputs[0], node_output.inputs['Shader']) | |
else: | |
node_dielectric = BlenderMaterials.__nodeDielectric(group.nodes, 0.05, 0.2, 0.0, 1.46, -242, 0) | |
node_glossy = BlenderMaterials.__nodeGlossy(group.nodes, (1,1,1,1), 0.2, 'BECKMANN', -242, 154) | |
node_mix = BlenderMaterials.__nodeMix(group.nodes, 0.4, 0, 90) | |
# link nodes together | |
group.links.new(node_input.outputs['Color'], node_glossy.inputs['Color']) | |
group.links.new(node_input.outputs['Color'], node_dielectric.inputs['Color']) | |
group.links.new(node_input.outputs['Normal'], node_glossy.inputs['Normal']) | |
group.links.new(node_input.outputs['Normal'], node_dielectric.inputs['Normal']) | |
group.links.new(node_glossy.outputs[0], node_mix.inputs[1]) | |
group.links.new(node_dielectric.outputs[0], node_mix.inputs[2]) | |
group.links.new(node_mix.outputs[0], node_output.inputs[0]) | |
# ********************************************************************************** | |
def __createBlenderLegoGlitterNodeGroup(): | |
groupName = BlenderMaterials.__getGroupName('Lego Glitter') | |
if bpy.data.node_groups.get(groupName) is None: | |
debugPrint("createBlenderLegoGlitterNodeGroup #create") | |
# create a group | |
group, node_input, node_output = BlenderMaterials.__createGroup(groupName, -450, 0, 410, 0, True) | |
BlenderMaterials.addInputSocket(group,'NodeSocketColor','Color') | |
BlenderMaterials.addInputSocket(group,'NodeSocketColor','Glitter Color') | |
BlenderMaterials.addInputSocket(group,'NodeSocketVectorDirection','Normal') | |
if BlenderMaterials.usePrincipledShader: | |
node_voronoi = BlenderMaterials.__nodeVoronoi(group.nodes, 100, -222, 310) | |
node_gamma = BlenderMaterials.__nodeGamma(group.nodes, 50, 0, 200) | |
node_mix = BlenderMaterials.__nodeMix(group.nodes, 0.05, 210, 90+25) | |
node_principled1 = BlenderMaterials.__nodePrincipled(group.nodes, 0.0, 0.0, 0.0, 0.2, 0.0, 0.03, 1.585, 1.0, 45-270, 340-210) | |
node_principled2 = BlenderMaterials.__nodePrincipled(group.nodes, 0.0, 0.0, 0.0, 0.5, 0.0, 0.03, 1.45, 0.0, 45-270, 340-750) | |
group.links.new(node_input.outputs['Color'], node_principled1.inputs['Base Color']) | |
group.links.new(node_input.outputs['Glitter Color'], node_principled2.inputs['Base Color']) | |
group.links.new(node_input.outputs['Normal'], node_principled1.inputs['Normal']) | |
group.links.new(node_input.outputs['Normal'], node_principled2.inputs['Normal']) | |
group.links.new(node_voronoi.outputs['Color'], node_gamma.inputs['Color']) | |
group.links.new(node_gamma.outputs[0], node_mix.inputs[0]) | |
group.links.new(node_principled1.outputs['BSDF'], node_mix.inputs[1]) | |
group.links.new(node_principled2.outputs['BSDF'], node_mix.inputs[2]) | |
group.links.new(node_mix.outputs[0], node_output.inputs[0]) | |
else: | |
node_glass = BlenderMaterials.__nodeGlass(group.nodes, 0.05, 1.46, 'BECKMANN', -242, 154) | |
node_glossy = BlenderMaterials.__nodeGlossy(group.nodes, (1,1,1,1), 0.05, 'BECKMANN', -242, -23) | |
node_diffuse = BlenderMaterials.__nodeDiffuse(group.nodes, 0.0, -12, -49) | |
node_voronoi = BlenderMaterials.__nodeVoronoi(group.nodes, 100, -232, 310) | |
node_gamma = BlenderMaterials.__nodeGamma(group.nodes, 50, 0, 200) | |
node_mixOne = BlenderMaterials.__nodeMix(group.nodes, 0.05, 0, 90) | |
node_mixTwo = BlenderMaterials.__nodeMix(group.nodes, 0.5, 200, 90) | |
# link nodes together | |
group.links.new(node_input.outputs['Color'], node_glass.inputs['Color']) | |
group.links.new(node_input.outputs['Glitter Color'], node_diffuse.inputs['Color']) | |
group.links.new(node_input.outputs['Normal'], node_glass.inputs['Normal']) | |
group.links.new(node_input.outputs['Normal'], node_glossy.inputs['Normal']) | |
group.links.new(node_input.outputs['Normal'], node_diffuse.inputs['Normal']) | |
group.links.new(node_glass.outputs[0], node_mixOne.inputs[1]) | |
group.links.new(node_glossy.outputs[0], node_mixOne.inputs[2]) | |
group.links.new(node_voronoi.outputs[0], node_gamma.inputs[0]) | |
group.links.new(node_gamma.outputs[0], node_mixTwo.inputs[0]) | |
group.links.new(node_mixOne.outputs[0], node_mixTwo.inputs[1]) | |
group.links.new(node_diffuse.outputs[0], node_mixTwo.inputs[2]) | |
group.links.new(node_mixTwo.outputs[0], node_output.inputs[0]) | |
# ********************************************************************************** | |
def __createBlenderLegoSpeckleNodeGroup(): | |
groupName = BlenderMaterials.__getGroupName('Lego Speckle') | |
if bpy.data.node_groups.get(groupName) is None: | |
debugPrint("createBlenderLegoSpeckleNodeGroup #create") | |
# create a group | |
group, node_input, node_output = BlenderMaterials.__createGroup(groupName, -450, 0, 410, 0, True) | |
BlenderMaterials.addInputSocket(group,'NodeSocketColor','Color') | |
BlenderMaterials.addInputSocket(group,'NodeSocketColor','Speckle Color') | |
BlenderMaterials.addInputSocket(group,'NodeSocketVectorDirection','Normal') | |
if BlenderMaterials.usePrincipledShader: | |
node_voronoi = BlenderMaterials.__nodeVoronoi(group.nodes, 50, -222, 310) | |
node_gamma = BlenderMaterials.__nodeGamma(group.nodes, 3.5, 0, 200) | |
node_mix = BlenderMaterials.__nodeMix(group.nodes, 0.05, 210, 90+25) | |
node_principled1 = BlenderMaterials.__nodePrincipled(group.nodes, 0.0, 0.0, 0.0, 0.1, 0.0, 0.03, 1.45, 0.0, 45-270, 340-210) | |
node_principled2 = BlenderMaterials.__nodePrincipled(group.nodes, 0.0, 0.0, 1.0, 0.5, 0.0, 0.03, 1.45, 0.0, 45-270, 340-750) | |
group.links.new(node_input.outputs['Color'], node_principled1.inputs['Base Color']) | |
group.links.new(node_input.outputs['Speckle Color'], node_principled2.inputs['Base Color']) | |
group.links.new(node_input.outputs['Normal'], node_principled1.inputs['Normal']) | |
group.links.new(node_input.outputs['Normal'], node_principled2.inputs['Normal']) | |
group.links.new(node_voronoi.outputs['Color'], node_gamma.inputs['Color']) | |
group.links.new(node_gamma.outputs[0], node_mix.inputs[0]) | |
group.links.new(node_principled1.outputs['BSDF'], node_mix.inputs[1]) | |
group.links.new(node_principled2.outputs['BSDF'], node_mix.inputs[2]) | |
group.links.new(node_mix.outputs[0], node_output.inputs[0]) | |
else: | |
node_diffuseOne = BlenderMaterials.__nodeDiffuse(group.nodes, 0.0, -242, 131) | |
node_glossy = BlenderMaterials.__nodeGlossy(group.nodes, (0.333, 0.333, 0.333, 1.0), 0.2, 'BECKMANN', -242, -23) | |
node_diffuseTwo = BlenderMaterials.__nodeDiffuse(group.nodes, 0.0, -12, -49) | |
node_voronoi = BlenderMaterials.__nodeVoronoi(group.nodes, 100, -232, 310) | |
node_gamma = BlenderMaterials.__nodeGamma(group.nodes, 20, 0, 200) | |
node_mixOne = BlenderMaterials.__nodeMix(group.nodes, 0.2, 0, 90) | |
node_mixTwo = BlenderMaterials.__nodeMix(group.nodes, 0.5, 200, 90) | |
# link nodes together | |
group.links.new(node_input.outputs['Color'], node_diffuseOne.inputs['Color']) | |
group.links.new(node_input.outputs['Speckle Color'], node_diffuseTwo.inputs['Color']) | |
group.links.new(node_input.outputs['Normal'], node_diffuseOne.inputs['Normal']) | |
group.links.new(node_input.outputs['Normal'], node_glossy.inputs['Normal']) | |
group.links.new(node_input.outputs['Normal'], node_diffuseTwo.inputs['Normal']) | |
group.links.new(node_voronoi.outputs[0], node_gamma.inputs[0]) | |
group.links.new(node_diffuseOne.outputs[0], node_mixOne.inputs[1]) | |
group.links.new(node_glossy.outputs[0], node_mixOne.inputs[2]) | |
group.links.new(node_gamma.outputs[0], node_mixTwo.inputs[0]) | |
group.links.new(node_mixOne.outputs[0], node_mixTwo.inputs[1]) | |
group.links.new(node_diffuseTwo.outputs[0], node_mixTwo.inputs[2]) | |
group.links.new(node_mixTwo.outputs[0], node_output.inputs[0]) | |
# ********************************************************************************** | |
def __createBlenderLegoMilkyWhiteNodeGroup(): | |
groupName = BlenderMaterials.__getGroupName('Lego Milky White') | |
if bpy.data.node_groups.get(groupName) is None: | |
debugPrint("createBlenderLegoMilkyWhiteNodeGroup #create") | |
# create a group | |
group, node_input, node_output = BlenderMaterials.__createGroup(groupName, -450, 0, 350, 0, True) | |
BlenderMaterials.addInputSocket(group,'NodeSocketColor','Color') | |
BlenderMaterials.addInputSocket(group,'NodeSocketVectorDirection','Normal') | |
if BlenderMaterials.usePrincipledShader: | |
node_principled = BlenderMaterials.__nodePrincipled(group.nodes, 1.0, 0.05, 0.0, 0.5, 0.0, 0.03, 1.45, 0.0, 45-270, 340-210) | |
node_translucent = BlenderMaterials.__nodeTranslucent(group.nodes, -225, -382) | |
node_mix = BlenderMaterials.__nodeMix(group.nodes, 0.5, 65, -40) | |
group.links.new(node_input.outputs['Color'], node_principled.inputs['Base Color']) | |
group.links.new(node_input.outputs['Color'], BlenderMaterials.__getSubsurfaceColor(node_principled)) | |
group.links.new(node_input.outputs['Normal'], node_principled.inputs['Normal']) | |
group.links.new(node_input.outputs['Normal'], node_translucent.inputs['Normal']) | |
group.links.new(node_principled.outputs[0], node_mix.inputs[1]) | |
group.links.new(node_translucent.outputs[0], node_mix.inputs[2]) | |
group.links.new(node_mix.outputs[0], node_output.inputs[0]) | |
else: | |
node_diffuse = BlenderMaterials.__nodeDiffuse(group.nodes, 0.0, -242, 90) | |
node_trans = BlenderMaterials.__nodeTranslucent(group.nodes, -242, -46) | |
node_glossy = BlenderMaterials.__nodeGlossy(group.nodes, (1,1,1,1), 0.5, 'BECKMANN', -42, -54) | |
node_mixOne = BlenderMaterials.__nodeMix(group.nodes, 0.4, -35, 90) | |
node_mixTwo = BlenderMaterials.__nodeMix(group.nodes, 0.2, 175, 90) | |
# link nodes together | |
group.links.new(node_input.outputs['Color'], node_diffuse.inputs['Color']) | |
group.links.new(node_input.outputs['Color'], node_trans.inputs['Color']) | |
group.links.new(node_input.outputs['Color'], node_glossy.inputs['Color']) | |
group.links.new(node_input.outputs['Normal'], node_diffuse.inputs['Normal']) | |
group.links.new(node_input.outputs['Normal'], node_trans.inputs['Normal']) | |
group.links.new(node_input.outputs['Normal'], node_glossy.inputs['Normal']) | |
group.links.new(node_diffuse.outputs[0], node_mixOne.inputs[1]) | |
group.links.new(node_trans.outputs[0], node_mixOne.inputs[2]) | |
group.links.new(node_mixOne.outputs[0], node_mixTwo.inputs[1]) | |
group.links.new(node_glossy.outputs[0], node_mixTwo.inputs[2]) | |
group.links.new(node_mixTwo.outputs[0], node_output.inputs[0]) | |
# ********************************************************************************** | |
def createBlenderNodeGroups(): | |
BlenderMaterials.usePrincipledShader = BlenderMaterials.__hasPrincipledShader and Options.usePrincipledShaderWhenAvailable | |
BlenderMaterials.__createBlenderDistanceToCenterNodeGroup() | |
BlenderMaterials.__createBlenderVectorElementPowerNodeGroup() | |
BlenderMaterials.__createBlenderConvertToNormalsNodeGroup() | |
BlenderMaterials.__createBlenderConcaveWallsNodeGroup() | |
BlenderMaterials.__createBlenderSlopeTextureNodeGroup() | |
# Originally based on ideas from https://www.youtube.com/watch?v=V3wghbZ-Vh4 | |
# "Create your own PBR Material [Fixed!]" by BlenderGuru | |
# Updated with Principled Shader, if available | |
BlenderMaterials.__createBlenderFresnelNodeGroup() | |
BlenderMaterials.__createBlenderReflectionNodeGroup() | |
BlenderMaterials.__createBlenderDielectricNodeGroup() | |
BlenderMaterials.__createBlenderLegoStandardNodeGroup() | |
BlenderMaterials.__createBlenderLegoTransparentNodeGroup() | |
BlenderMaterials.__createBlenderLegoTransparentFluorescentNodeGroup() | |
BlenderMaterials.__createBlenderLegoRubberNodeGroup() | |
BlenderMaterials.__createBlenderLegoRubberTranslucentNodeGroup() | |
BlenderMaterials.__createBlenderLegoEmissionNodeGroup() | |
BlenderMaterials.__createBlenderLegoChromeNodeGroup() | |
BlenderMaterials.__createBlenderLegoPearlescentNodeGroup() | |
BlenderMaterials.__createBlenderLegoMetalNodeGroup() | |
BlenderMaterials.__createBlenderLegoGlitterNodeGroup() | |
BlenderMaterials.__createBlenderLegoSpeckleNodeGroup() | |
BlenderMaterials.__createBlenderLegoMilkyWhiteNodeGroup() | |
# ************************************************************************************** | |
def addSharpEdges(bm, geometry, filename): | |
if geometry.edges: | |
global globalWeldDistance | |
epsilon = globalWeldDistance | |
bm.faces.ensure_lookup_table() | |
bm.verts.ensure_lookup_table() | |
bm.edges.ensure_lookup_table() | |
# Create kd tree for fast "find nearest points" calculation | |
kd = mathutils.kdtree.KDTree(len(bm.verts)) | |
for i, v in enumerate(bm.verts): | |
kd.insert(v.co, i) | |
kd.balance() | |
# Create edgeIndices dictionary, which is the list of edges as pairs of indicies into our bm.verts array | |
edgeIndices = {} | |
for ind, geomEdge in enumerate(geometry.edges): | |
# Find index of nearest points in bm.verts to geomEdge[0] and geomEdge[1] | |
edges0 = [index for (co, index, dist) in kd.find_range(geomEdge[0], epsilon)] | |
edges1 = [index for (co, index, dist) in kd.find_range(geomEdge[1], epsilon)] | |
#if (len(edges0) > 2): | |
# printWarningOnce("Found {1} vertices near {0} in file {2}".format(geomEdge[0], len(edges0), filename)) | |
#if (len(edges1) > 2): | |
# printWarningOnce("Found {1} vertices near {0} in file {2}".format(geomEdge[1], len(edges1), filename)) | |
for e0 in edges0: | |
for e1 in edges1: | |
edgeIndices[(e0, e1)] = True | |
edgeIndices[(e1, e0)] = True | |
# Find the appropriate mesh edges and make them sharp (i.e. not smooth) | |
for meshEdge in bm.edges: | |
v0 = meshEdge.verts[0].index | |
v1 = meshEdge.verts[1].index | |
if (v0, v1) in edgeIndices: | |
# Make edge sharp | |
meshEdge.smooth = False | |
# Set bevel weights | |
if bpy.app.version < (4, 0, 0): | |
# Blender 3 | |
# Find layer for bevel weights | |
if 'BevelWeight' in bm.edges.layers.bevel_weight: | |
bwLayer = bm.edges.layers.bevel_weight['BevelWeight'] | |
elif '' in bm.edges.layers.bevel_weight: | |
bwLayer = bm.edges.layers.bevel_weight[''] | |
else: | |
bwLayer = None | |
for meshEdge in bm.edges: | |
v0 = meshEdge.verts[0].index | |
v1 = meshEdge.verts[1].index | |
if (v0, v1) in edgeIndices: | |
# Add bevel weight | |
if bwLayer is not None: | |
meshEdge[bwLayer] = 1.0 | |
return edgeIndices | |
# Commented this next section out as it fails for certain pieces. | |
# Look for any pair of colinear edges emanating from a single vertex, where each edge is connected to exactly one face. | |
# Subdivide the longer edge to include the shorter edge's vertex. | |
# Repeat until there's nothing left to subdivide. | |
# This helps create better (more manifold) geometry in general, and in particular solves issues with technic pieces with holes. | |
# verts = set(bm.verts) | |
# | |
# while(verts): | |
# v = verts.pop() | |
# edges = [e for e in v.link_edges if len(e.link_faces) == 1] | |
# for e1, e2 in itertools.combinations(edges, 2): | |
# | |
# # ensure e1 is always the longer edge | |
# if e1.calc_length() < e2.calc_length(): | |
# e1, e2 = e2, e1 | |
# | |
# v1 = e1.other_vert(v) | |
# v2 = e2.other_vert(v) | |
# vec1 = v1.co - v.co | |
# vec2 = v2.co - v.co | |
# | |
# # test for colinear | |
# if vec1.angle(vec2) < 0.02: | |
# old_face = e1.link_faces[0] | |
# new_verts = old_face.verts[:] | |
# | |
# e2.smooth &= e1.smooth | |
# if bwLayer is not None: | |
# e2[bwLayer] = max(e1[bwLayer], e2[bwLayer]) | |
# | |
# # insert the shorter edge's vertex | |
# i = new_verts.index(v) | |
# i1 = new_verts.index(v1) | |
# if i1 - i in [1, -1]: | |
# new_verts.insert(max(i,i1), v2) | |
# else: | |
# new_verts.insert(0, v2) | |
# | |
# # create a new face that includes the newly inserted vertex | |
# new_face = bm.faces.new(new_verts) | |
# | |
# # copy material to new face | |
# new_face.material_index = old_face.material_index | |
# | |
# # copy metadata to the new edge | |
# for e in v2.link_edges: | |
# if e.other_vert(v2) is v1: | |
# e.smooth &= e1.smooth | |
# if bwLayer is not None: | |
# e[bwLayer] = max(e1[bwLayer], e[bwLayer]) | |
# | |
# # delete the old edge | |
# deleteEdge(bm, [e1]) | |
# | |
# # re-check the vertices we modified | |
# verts.add(v) | |
# verts.add(v2) | |
# break | |
bm.faces.ensure_lookup_table() | |
bm.verts.ensure_lookup_table() | |
bm.edges.ensure_lookup_table() | |
# ************************************************************************************** | |
def meshIsReusable(meshName, geometry): | |
meshExists = meshName in bpy.data.meshes | |
#debugPrint("meshIsReusable says {0} exists = {1}.".format(meshName, meshExists)) | |
if meshExists and not Options.overwriteExistingMeshes: | |
mesh = bpy.data.meshes[meshName] | |
#debugPrint("meshIsReusable testing") | |
# A mesh loses it's materials information when it is no longer in use. | |
# We must check the number of faces matches, otherwise we can't re-set the | |
# materials. | |
if mesh.users == 0 and (len(mesh.polygons) != len(geometry.faces)): | |
#debugPrint("meshIsReusable says no users and num faces changed.") | |
return False | |
# If options have changed (e.g. scale) we should not reuse the same mesh. | |
if 'customMeshOptions' in mesh.keys(): | |
#debugPrint("meshIsReusable found custom options.") | |
#debugPrint("mesh['customMeshOptions'] = {0}".format(mesh['customMeshOptions'])) | |
#debugPrint("Options.meshOptionsString() = {0}".format(Options.meshOptionsString())) | |
if mesh['customMeshOptions'] == Options.meshOptionsString(): | |
#debugPrint("meshIsReusable found custom options - match OK.") | |
return True | |
#debugPrint("meshIsReusable found custom options - DON'T match.") | |
return False | |
# ************************************************************************************** | |
def addNodeToParentWithGroups(parentObject, groupNames, newObject): | |
if not Options.flattenGroups: | |
# Create groups as needed | |
for groupName in groupNames: | |
# The max length of a Blender node name appears to be 63 bytes when encoded as UTF-8. We make sure it fits. | |
while len(groupName.encode("utf8")) > 63: | |
groupName = groupName[:-1] | |
# Check if we already have this node name, or if we need to create a new node | |
groupObj = None | |
for obj in bpy.data.objects: | |
if (obj.name == groupName): | |
groupObj = obj | |
if (groupObj is None): | |
groupObj = bpy.data.objects.new(groupName, None) | |
groupObj.parent = parentObject | |
globalObjectsToAdd.append(groupObj) | |
parentObject = groupObj | |
newObject.parent = parentObject | |
globalObjectsToAdd.append(newObject) | |
# ************************************************************************************** | |
parent = None | |
attach_points = [] | |
children = [] | |
partsHierarchy = {} | |
macro_name = None | |
macros = {} | |
# ************************************************************************************** | |
def parseParentsFile(file): | |
global parent | |
global attach_points | |
global children | |
global partsHierarchy | |
global macro_name | |
global macros | |
# See https://stackoverflow.com/a/53870514 | |
number_pattern = "[+-]?((\d+(\.\d*)?)|(\.\d+))" | |
pattern = "(" + number_pattern + ")(.*)" | |
compiled = re.compile(pattern) | |
def number_split(s): | |
match = compiled.match(s) | |
if match is None: | |
return None, s | |
groups = match.groups() | |
return groups[0], groups[-1].strip() | |
parent = None | |
attach_points = [] | |
children = [] | |
partsHierarchy = {} | |
macro_name = None | |
macros = {} | |
def finishParent(): | |
global parent | |
global attach_points | |
global children | |
global partsHierarchy | |
global macro_name | |
if macro_name: | |
macros[macro_name] = children | |
# print("Adding macro ", macro_name) | |
parent = None | |
attach_points = [] | |
children = [] | |
macro_name = None | |
if parent: | |
partsHierarchy[parent] = (attach_points, children) | |
parent = None | |
attach_points = [] | |
children = [] | |
macro_name = None | |
with open(file) as f: | |
lines = f.readlines() # list containing lines of file | |
line_number = 0 | |
for line in lines: | |
line_number += 1 | |
line = line.strip() # remove leading/trailing white spaces | |
line = line.split("#")[0] | |
if line: | |
line = line.strip() | |
original_line = line | |
if line.startswith("Group "): | |
# Found group definition | |
finishParent() | |
macro_name = line[6:].strip().strip(":") | |
# print("Found group definition ", macro_name) | |
continue | |
if line.startswith("Parent "): | |
# Found parent definition | |
finishParent() | |
parent = line[7:].strip().strip(":") | |
# print("Found parent definition ", parent) | |
continue | |
if line in macros: | |
# found instance of a macro | |
# add children to definition | |
children += macros[line] | |
continue | |
# check for three floating point numbers of an attach point | |
number1, line = number_split(line) | |
if number1: | |
number3 = None | |
number2, line = number_split(line) | |
if number2: | |
number3, line = number_split(line) | |
if number3: | |
# Got three numbers for an attach point | |
try: | |
attachPoint = (float(number1), float(number2), float(number3)) | |
except: | |
attachPoint = None | |
if attachPoint: | |
# Attach point | |
attach_points.append(attachPoint) | |
continue | |
else: | |
debugPrint("ERROR: Bad attach point found on line %d" % (line_number,)) | |
partsHierarchy = None | |
return | |
# child part number? | |
children.append(original_line) | |
finishParent() | |
# print("Macros:") | |
# pprint(macros) | |
# print("End of Macros") | |
return | |
# ************************************************************************************** | |
def setupImplicitParents(): | |
global globalScaleFactor | |
if not Options.minifigHierarchy: | |
return | |
parseParentsFile(Options.scriptDirectory + '/parents.txt') | |
# print(partsHierarchy) | |
if not partsHierarchy: | |
return | |
bpy.context.view_layer.update() | |
# create a set of the parent parts and a set of child parts from the partsHierarchy | |
parentParts = set() | |
childParts = set() | |
for parent, childrenData in partsHierarchy.items(): | |
parentParts.add(parent) | |
childParts.update(childrenData[1]) | |
# create a flat set of all interesting parts (parents and children together) | |
interestingParts = set() | |
interestingParts.update(parentParts) | |
interestingParts.update(childParts) | |
# print('Parent parts: %s' % (parentParts,)) | |
# print('Child parts: %s' % (childParts,)) | |
# print('Interesting parts: %s' % (interestingParts,)) | |
tolerance = globalScaleFactor * 5 # in LDraw units | |
squaredTolerance = tolerance * tolerance | |
# print(" Squared tolerance: %s" % (squaredTolerance,)) | |
# For each interesting mesh in the scene, remember the bare part number and the children | |
parentMeshParts = {} # bare part numbers of the parents | |
childMeshParts = {} # bare part numbers of the children | |
parentableMeshes = {} # interesting children | |
lego_part_pattern = "([A-Za-z]?\d+)($|\D)" | |
# for each object in the scene | |
for obj in bpy.data.objects: | |
if obj.type != 'MESH': | |
continue | |
name = obj.data.name | |
if not name.startswith('Mesh_'): | |
continue | |
# skip 'Mesh_' and get part of name that is just digits (possibly with a letter in front) | |
test_name = name[5:] | |
if " - " in test_name: | |
test_name = test_name.split(" - ",1)[1] | |
partName = '' | |
m = re.match(lego_part_pattern, test_name) | |
if m: | |
partName = m.group(1) | |
# For each interesting parent mesh in the scene, remember the bare part number and the children | |
if partName in parentParts: | |
# remember the bare part number for each interesting mesh in the scene | |
parentMeshParts[name] = partName | |
# remember possible children of the mesh in the scene | |
children = partsHierarchy.get(partName) | |
if children: | |
parentableMeshes[name] = children | |
# For each interesting child mesh in the scene, remember the bare part number | |
if partName in childParts: | |
# remember the bare part number for each interesting mesh in the scene | |
childMeshParts[name] = partName | |
# Now, iterate through the objects in the scene and gather the interesting ones | |
parentObjects = [] | |
childObjects = [] | |
for obj in bpy.data.objects: | |
if obj.type != 'MESH': | |
continue | |
meshName = obj.data.name | |
if meshName in parentMeshParts: | |
parentObjects.append(obj) | |
# print("Possible parent object %s has matrix %s" % (obj.name, obj.matrix_world)) | |
if meshName in childMeshParts: | |
childObjects.append(obj) | |
# for each interesting parent object | |
for obj in parentObjects: | |
meshName = obj.data.name | |
childrenData = parentableMeshes.get(meshName) | |
if not childrenData: | |
continue | |
# parentLocation = obj.matrix_world @ mathutils.Vector((0, 0, 0)) | |
# parentMatrixInverted = obj.matrix_world.inverted() | |
# print("Looking for children of %s (at %s)" % (obj.name, parentLocation)) | |
slotLocations = [] | |
for slot in childrenData[0]: | |
loc = obj.matrix_world @ (mathutils.Vector(slot) * globalScaleFactor) | |
slotLocations.append(loc) | |
# print(" Slot locations: %s" % (slotLocations,)) | |
# for each interesting child object | |
for childObj in childObjects: | |
childMeshName = childObj.data.name | |
childPartName = childMeshParts[childMeshName] | |
if childPartName not in childrenData[1]: | |
continue | |
childLocation = childObj.matrix_world.to_translation() | |
# print(" Found possible child %s" % (childObj.name,)) | |
for slotLocation in slotLocations: | |
# print(" Slot location:%s Child Location:%s" % (slotLocation, childLocation)) | |
diff = slotLocation - childLocation | |
squaredDistance = diff.length_squared | |
# print(" location: %s (squared distance: %s)" % (childLocation, squaredDistance)) | |
if squaredDistance <= squaredTolerance: | |
temp = childObj.matrix_world | |
childObj.parent = obj | |
# childObj.matrix_parent_inverse = parentMatrixInverted | |
childObj.matrix_world = temp | |
# print(" Got it! Parent '%s' now has child '%s'" % (obj.name, childObj.name)) | |
# ************************************************************************************** | |
def slopeAnglesForPart(partName): | |
""" | |
Gets the allowable slope angles for a given part. | |
""" | |
global globalSlopeAngles | |
# Check for a part number with or without a subsequent letter | |
match = re.match(r'\D*(\d+)([A-Za-z]?)', partName) | |
if match: | |
partNumberWithoutLetter = match.group(1) | |
partNumberWithLetter = partNumberWithoutLetter + match.group(2) | |
if partNumberWithLetter in globalSlopeAngles: | |
return globalSlopeAngles[partNumberWithLetter] | |
if partNumberWithoutLetter in globalSlopeAngles: | |
return globalSlopeAngles[partNumberWithoutLetter] | |
return None | |
# ************************************************************************************** | |
def isSlopeFace(slopeAngles, isGrainySlopeAllowed, faceVertices): | |
""" | |
Checks whether a given face should receive a grainy slope material. | |
""" | |
# Step 1: Ignore some faces (studs) when checking for a grainy face | |
if not isGrainySlopeAllowed: | |
return False | |
# Step 2: Calculate angle of face normal to the ground | |
faceNormal = (faceVertices[1] - faceVertices[0]).cross(faceVertices[2]-faceVertices[0]) | |
faceNormal.normalize() | |
# Clamp value to range -1 to 1 (ensure we are in the strict range of the acos function, taking account of rounding errors) | |
cosine = min(max(faceNormal.y, -1.0), 1.0) | |
# Calculate angle of face normal to the ground (-90 to 90 degrees) | |
angleToGroundDegrees = math.degrees(math.acos(cosine)) - 90 | |
# debugPrint("Angle to ground {0}".format(angleToGroundDegrees)) | |
# Step 3: Check angle of normal to ground is within one of the acceptable ranges for this part | |
return any(c[0] <= angleToGroundDegrees <= c[1] for c in slopeAngles) | |
# ************************************************************************************** | |
def createMesh(name, meshName, geometry): | |
# Are there any points? | |
if not geometry.points: | |
return (None, False) | |
newMeshCreated = False | |
# Have we already cached this mesh? | |
if Options.createInstances and hasattr(geometry, 'mesh'): | |
mesh = geometry.mesh | |
else: | |
# Does this mesh already exist in Blender? | |
if meshIsReusable(meshName, geometry): | |
mesh = bpy.data.meshes[meshName] | |
else: | |
# Create new mesh | |
# debugPrint("Creating Mesh for node {0}".format(node.filename)) | |
mesh = bpy.data.meshes.new(meshName) | |
points = [p.to_tuple() for p in geometry.points] | |
mesh.from_pydata(points, [], geometry.faces) | |
mesh.validate() | |
mesh.update() | |
# Set a custom parameter to record the options used to create this mesh | |
# Used for caching. | |
mesh['customMeshOptions'] = Options.meshOptionsString() | |
newMeshCreated = True | |
# Create materials and assign material to each polygon | |
if mesh.users == 0: | |
assert len(mesh.polygons) == len(geometry.faces) | |
assert len(geometry.faces) == len(geometry.faceInfo) | |
slopeAngles = slopeAnglesForPart(name) | |
isSloped = slopeAngles is not None | |
for i, f in enumerate(mesh.polygons): | |
faceInfo = geometry.faceInfo[i] | |
isSlopeMaterial = isSloped and isSlopeFace(slopeAngles, faceInfo.isGrainySlopeAllowed, [geometry.points[j] for j in geometry.faces[i]]) | |
faceColour = faceInfo.faceColour | |
# For debugging purposes, we can make sloped faces blue: | |
# if isSlopeMaterial: | |
# faceColour = "1" | |
material = BlenderMaterials.getMaterial(faceColour, isSlopeMaterial) | |
if material is not None: | |
if mesh.materials.get(material.name) is None: | |
mesh.materials.append(material) | |
f.material_index = mesh.materials.find(material.name) | |
else: | |
printWarningOnce("Could not find material '{0}' in mesh '{1}'.".format(faceColour, name)) | |
# Cache mesh | |
if newMeshCreated: | |
geometry.mesh = mesh | |
return (mesh, newMeshCreated) | |
# ************************************************************************************** | |
def addModifiers(ob): | |
global globalScaleFactor | |
# Add Bevel modifier to each instance | |
if Options.addBevelModifier: | |
bevelModifier = ob.modifiers.new("Bevel", type='BEVEL') | |
bevelModifier.width = Options.bevelWidth * globalScaleFactor | |
bevelModifier.segments = 4 | |
bevelModifier.profile = 0.5 | |
bevelModifier.limit_method = 'WEIGHT' | |
bevelModifier.use_clamp_overlap = True | |
# Add edge split modifier to each instance | |
if Options.edgeSplit: | |
edgeModifier = ob.modifiers.new("Edge Split", type='EDGE_SPLIT') | |
edgeModifier.use_edge_sharp = True | |
edgeModifier.split_angle = math.radians(30.0) | |
# ************************************************************************************** | |
def smoothShadingAndFreestyleEdges(ob): | |
# We would like to avoid using bpy.ops functions altogether since it | |
# slows down progressively as more objects are added to the scene, but | |
# we have no choice but to use it here (a) for smoothing and (b) for | |
# marking freestyle edges (no bmesh options exist currently). To minimise | |
# the performance drop, we add one object only to the scene, smooth it, | |
# then remove it again. Only at the end of the import process are all the | |
# objects properly added to the scene. | |
# Temporarily add object to scene | |
linkToScene(ob) | |
# Select object | |
selectObject(ob) | |
# Smooth shading | |
if Options.smoothShading: | |
# Smooth the mesh | |
bpy.ops.object.shade_smooth() | |
if Options.instructionsLook: | |
# Mark all sharp edges as freestyle edges | |
me = bpy.context.object.data | |
for e in me.edges: | |
e.use_freestyle_mark = e.use_edge_sharp | |
# Deselect object | |
deselectObject(ob) | |
# Remove object from scene | |
unlinkFromScene(ob) | |
# ************************************************************************************** | |
def createBlenderObjectsFromNode(node, | |
localMatrix, | |
name, | |
realColourName=Options.defaultColour, | |
blenderParentTransform=Math.identityMatrix, | |
localToWorldSpaceMatrix=Math.identityMatrix, | |
blenderNodeParent=None): | |
""" | |
Creates a Blender Object for the node given and (recursively) for all it's children as required. | |
Creates and optimises the mesh for each object too. | |
""" | |
global globalBrickCount | |
global globalObjectsToAdd | |
global globalWeldDistance | |
global globalPoints | |
ob = None | |
if node.isBlenderObjectNode(): | |
ourColourName = LDrawNode.resolveColour(node.colourName, realColourName) | |
meshName, geometry = node.getBlenderGeometry(ourColourName, name) | |
mesh, newMeshCreated = createMesh(name, meshName, geometry) | |
# Format a name for the Blender Object | |
if Options.numberNodes: | |
blenderName = str(globalBrickCount).zfill(5) + "_" + name | |
else: | |
blenderName = name | |
globalBrickCount = globalBrickCount + 1 | |
# Create Blender Object | |
ob = bpy.data.objects.new(blenderName, mesh) | |
ob.matrix_local = blenderParentTransform @ localMatrix | |
if newMeshCreated: | |
# For performance reasons we try to avoid using bpy.ops.* methods | |
# (e.g. we use bmesh.* operations instead). | |
# See discussion: http://blender.stackexchange.com/questions/7358/python-performance-with-blender-operators | |
# Use bevel weights (added to sharp edges) - Only available for Blender version < 3.4 | |
if hasattr(ob.data, "use_customdata_edge_bevel"): | |
ob.data.use_customdata_edge_bevel = True | |
else: | |
if bpy.app.version < (4, 0, 0): | |
# Add to scene | |
linkToScene(ob) | |
# Blender 3.4 removed 'ob.data.use_customdata_edge_bevel', so this seems to be the alternative: | |
# See https://blender.stackexchange.com/a/270716 | |
area_type = 'VIEW_3D' # change this to use the correct Area Type context you want to process in | |
areas = [area for area in bpy.context.window.screen.areas if area.type == area_type] | |
if len(areas) <= 0: | |
raise Exception(f"Make sure an Area of type {area_type} is open or visible on your screen!") | |
selectObject(ob) | |
bpy.ops.object.mode_set(mode='EDIT') | |
with bpy.context.temp_override( | |
window=bpy.context.window, | |
area=areas[0], | |
regions=[region for region in areas[0].regions if region.type == 'WINDOW'][0], | |
screen=bpy.context.window.screen): | |
bpy.ops.mesh.customdata_bevel_weight_edge_add() | |
bpy.ops.object.mode_set(mode='OBJECT') | |
unlinkFromScene(ob) | |
# The lines out of an empty shown in the viewport are scaled to a reasonable size | |
ob.empty_display_size = 250.0 * globalScaleFactor | |
# Mark object as transparent if any polygon is transparent | |
ob["Lego.isTransparent"] = False | |
if mesh is not None: | |
for faceInfo in geometry.faceInfo: | |
material = BlenderMaterials.getMaterial(faceInfo.faceColour, False) | |
if material is not None: | |
if "Lego.isTransparent" in material: | |
if material["Lego.isTransparent"]: | |
ob["Lego.isTransparent"] = True | |
break | |
# Add any (LeoCAD) group nodes as parents of 'ob' (the new node), and as children of 'blenderNodeParent'. | |
# Also add all objects to 'globalObjectsToAdd'. | |
addNodeToParentWithGroups(blenderNodeParent, node.groupNames, ob) | |
# Node to which our children will be attached | |
blenderNodeParent = ob | |
blenderParentTransform = Math.identityMatrix | |
# debugPrint("NAME = {0}".format(name)) | |
# Add light to light bricks | |
if (name in globalLightBricks): | |
lights = bpy.data.lights | |
lamp_data = lights.new(name="LightLamp", type='POINT') | |
lamp_data.shadow_soft_size = 0.05 | |
lamp_data.use_nodes = True | |
emission_node = lamp_data.node_tree.nodes.get('Emission') | |
if emission_node: | |
emission_node.inputs['Color'].default_value = globalLightBricks[name] | |
emission_node.inputs['Strength'].default_value = 100.0 | |
lamp_object = bpy.data.objects.new(name="LightLamp", object_data=lamp_data) | |
lamp_object.location = (-0.27, 0.0, -0.18) | |
addNodeToParentWithGroups(blenderNodeParent, [], lamp_object) | |
if newMeshCreated: | |
# Calculate what we need to do next | |
recalculateNormals = node.file.isDoubleSided and (Options.resolveAmbiguousNormals == "guess") | |
keepDoubleSided = node.file.isDoubleSided and (Options.resolveAmbiguousNormals == "double") | |
removeDoubles = Options.removeDoubles and not keepDoubleSided | |
bm = bmesh.new() | |
bm.from_mesh(ob.data) | |
bm.faces.ensure_lookup_table() | |
bm.verts.ensure_lookup_table() | |
bm.edges.ensure_lookup_table() | |
# Remove doubles | |
# Note: This doesn't work properly with a low distance value | |
# So we scale up the vertices beforehand and scale them down afterwards | |
for v in bm.verts: | |
v.co = v.co * 1000 | |
if removeDoubles: | |
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=globalWeldDistance) | |
for v in bm.verts: | |
v.co = v.co / 1000 | |
# Recalculate normals | |
if recalculateNormals: | |
bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:]) | |
# Add sharp edges (and edge weights in Blender 3) | |
edgeIndices = addSharpEdges(bm, geometry, name) | |
bm.to_mesh(ob.data) | |
# In Blender 4, set the edge weights (on ob.data rather than bm these days) | |
if (bpy.app.version >= (4, 0, 0)) and edgeIndices: | |
# Blender 4 | |
bevel_weight_attr = ob.data.attributes.new("bevel_weight_edge", "FLOAT", "EDGE") | |
for idx, meshEdge in enumerate(bm.edges): | |
v0 = meshEdge.verts[0].index | |
v1 = meshEdge.verts[1].index | |
if (v0, v1) in edgeIndices: | |
bevel_weight_attr.data[idx].value = 1.0 | |
bm.clear() | |
bm.free() | |
# Show the sharp edges in Edit Mode | |
for area in bpy.context.screen.areas: # iterate through areas in current screen | |
if area.type == 'VIEW_3D': | |
for space in area.spaces: # iterate through spaces in current VIEW_3D area | |
if space.type == 'VIEW_3D': # check if space is a 3D view | |
space.overlay.show_edge_sharp = True | |
# Scale for Gaps | |
if Options.gaps and node.file.isPart: | |
# Distance between gaps is controlled by Options.realGapWidth | |
# Gap height is set smaller than realGapWidth since empirically, stacked bricks tend | |
# to be pressed more tightly together | |
gapHeight = 0.33 * Options.realGapWidth | |
objScale = ob.scale | |
dim = ob.dimensions | |
# Checks whether the object isn't flat in a certain direction | |
# to avoid division by zero. | |
# Else, the scale factor is set proportional to the inverse of | |
# the dimension so that the mesh shrinks a fixed distance | |
# (determined by the gap_width and the scale of the object) | |
# in every direction, creating a uniform gap. | |
scaleFac = mathutils.Vector( (1.0, 1.0, 1.0) ) | |
if dim.x != 0: | |
scaleFac.x = 1 - Options.realGapWidth * abs(objScale.x) / dim.x | |
if dim.y != 0: | |
scaleFac.y = 1 - gapHeight * abs(objScale.y) / dim.y | |
if dim.z != 0: | |
scaleFac.z = 1 - Options.realGapWidth * abs(objScale.z) / dim.z | |
# A safety net: Don't distort the part too much (e.g. -ve scale would not look good) | |
if scaleFac.x < 0.95: | |
scaleFac.x = 0.95 | |
if scaleFac.y < 0.95: | |
scaleFac.y = 0.95 | |
if scaleFac.z < 0.95: | |
scaleFac.z = 0.95 | |
# Scale all vertices in the mesh | |
gapsScaleMatrix = mathutils.Matrix(( | |
(scaleFac.x, 0.0, 0.0, 0.0), | |
(0.0, scaleFac.y, 0.0, 0.0), | |
(0.0, 0.0, scaleFac.z, 0.0), | |
(0.0, 0.0, 0.0, 1.0) | |
)) | |
mesh.transform(gapsScaleMatrix) | |
smoothShadingAndFreestyleEdges(ob) | |
# Keep track of all vertices in global space, for positioning the camera and/or root object at the end | |
# Notice that we do this after scaling for Options.gaps | |
if Options.positionObjectOnGroundAtOrigin or Options.positionCamera: | |
if mesh and mesh.vertices: | |
localTransform = localToWorldSpaceMatrix @ localMatrix | |
points = [localTransform @ p.co for p in mesh.vertices] | |
# Remember all the points | |
globalPoints.extend(points) | |
# Hide selection of studs | |
if node.file.isStud: | |
ob.hide_select = True | |
# Add bevel and edge split modifiers as needed | |
if mesh: | |
addModifiers(ob) | |
else: | |
blenderParentTransform = blenderParentTransform @ localMatrix | |
# Create children and parent them | |
for childNode in node.file.childNodes: | |
# Create sub-objects recursively | |
childColourName = LDrawNode.resolveColour(childNode.colourName, realColourName) | |
createBlenderObjectsFromNode(childNode, childNode.matrix, childNode.filename, childColourName, blenderParentTransform, localToWorldSpaceMatrix @ localMatrix, blenderNodeParent) | |
return ob | |
# ************************************************************************************** | |
def addFileToCache(relativePath, name): | |
"""Loads and caches an LDraw file in the cache of files""" | |
file = LDrawFile(relativePath, False, "", None, True) | |
CachedFiles.addToCache(name, file) | |
return True | |
# ************************************************************************************** | |
def setupLineset(lineset, thickness, group): | |
lineset.select_silhouette = True | |
lineset.select_border = False | |
lineset.select_contour = False | |
lineset.select_suggestive_contour = False | |
lineset.select_ridge_valley = False | |
lineset.select_crease = False | |
lineset.select_edge_mark = True | |
lineset.select_external_contour = False | |
lineset.select_material_boundary = False | |
lineset.edge_type_combination = 'OR' | |
lineset.edge_type_negation = 'INCLUSIVE' | |
lineset.select_by_collection = True | |
lineset.collection = bpy.data.collections[bpy.data.collections.find(group)] | |
# Set line color | |
lineset.linestyle.color = (0.0, 0.0, 0.0) | |
# Set material to override color | |
if 'LegoMaterial' not in lineset.linestyle.color_modifiers: | |
lineset.linestyle.color_modifiers.new('LegoMaterial', 'MATERIAL') | |
# Use square caps | |
lineset.linestyle.caps = 'SQUARE' # Can be 'ROUND', 'BUTT', or 'SQUARE' | |
# Draw inside the edge of the object | |
lineset.linestyle.thickness_position = 'INSIDE' | |
# Set Thickness | |
lineset.linestyle.thickness = thickness | |
# ************************************************************************************** | |
def setupRealisticLook(): | |
scene = bpy.context.scene | |
render = scene.render | |
# Use cycles render | |
scene.render.engine = 'CYCLES' | |
# Add environment texture for world | |
if Options.addWorldEnvironmentTexture: | |
scene.world.use_nodes = True | |
nodes = scene.world.node_tree.nodes | |
links = scene.world.node_tree.links | |
worldNodeNames = [node.name for node in scene.world.node_tree.nodes] | |
if "LegoEnvMap" in worldNodeNames: | |
env_tex = nodes["LegoEnvMap"] | |
else: | |
env_tex = nodes.new('ShaderNodeTexEnvironment') | |
env_tex.location = (-250, 300) | |
env_tex.name = "LegoEnvMap" | |
env_tex.image = bpy.data.images.load(Options.scriptDirectory + "/background.exr", check_existing=True) | |
if "Background" in worldNodeNames: | |
background = nodes["Background"] | |
links.new(env_tex.outputs[0],background.inputs[0]) | |
else: | |
scene.world.color = (1.0, 1.0, 1.0) | |
if Options.setRenderSettings: | |
useDenoising(scene, True) | |
if (scene.cycles.samples < 400): | |
scene.cycles.samples = 400 | |
if (scene.cycles.diffuse_bounces < 20): | |
scene.cycles.diffuse_bounces = 20 | |
if (scene.cycles.glossy_bounces < 20): | |
scene.cycles.glossy_bounces = 20 | |
# Check layer names to see if we were previously rendering instructions and change settings back. | |
layerNames = getLayerNames(scene) | |
if ("SolidBricks" in layerNames) or ("TransparentBricks" in layerNames): | |
render.use_freestyle = False | |
# Change camera back to Perspective | |
if scene.camera is not None: | |
scene.camera.data.type = 'PERSP' | |
# For Blender Render, reset to opaque background (Not available in Blender 3.5.1 or higher.) | |
if hasattr(render, "alpha_mode"): | |
render.alpha_mode = 'SKY' | |
# Turn off cycles transparency | |
scene.cycles.film_transparent = False | |
# Get the render/view layers we are interested in: | |
layers = getLayers(scene) | |
# If we have previously added render layers for instructions look, re-enable any disabled render layers | |
for i in range(len(layers)): | |
layers[i].use = True | |
# Un-name SolidBricks and TransparentBricks layers | |
if "SolidBricks" in layerNames: | |
layers.remove(layers["SolidBricks"]) | |
if "TransparentBricks" in layerNames: | |
layers.remove(layers["TransparentBricks"]) | |
# Re-enable all layers | |
for i in range(len(layers)): | |
layers[i].use = True | |
# Create Compositing Nodes | |
scene.use_nodes = True | |
# If scene nodes exist for compositing instructions look, remove them | |
nodeNames = [node.name for node in scene.node_tree.nodes] | |
if "Solid" in nodeNames: | |
scene.node_tree.nodes.remove(scene.node_tree.nodes["Solid"]) | |
if "Trans" in nodeNames: | |
scene.node_tree.nodes.remove(scene.node_tree.nodes["Trans"]) | |
if "Z Combine" in nodeNames: | |
scene.node_tree.nodes.remove(scene.node_tree.nodes["Z Combine"]) | |
# Set up standard link from Render Layers to Composite | |
if "Render Layers" in nodeNames: | |
if "Composite" in nodeNames: | |
rl = scene.node_tree.nodes["Render Layers"] | |
zCombine = scene.node_tree.nodes["Composite"] | |
links = scene.node_tree.links | |
links.new(rl.outputs[0], zCombine.inputs[0]) | |
removeCollection('Black Edged Bricks Collection') | |
removeCollection('White Edged Bricks Collection') | |
removeCollection('Solid Bricks Collection') | |
removeCollection('Transparent Bricks Collection') | |
# ************************************************************************************** | |
def removeCollection(name, remove_collection_objects=False): | |
coll = bpy.data.collections.get(name) | |
if coll: | |
if remove_collection_objects: | |
obs = [o for o in coll.objects if o.users == 1] | |
while obs: | |
bpy.data.objects.remove(obs.pop()) | |
bpy.data.collections.remove(coll) | |
# ************************************************************************************** | |
def createCollection(scene, name): | |
if bpy.data.collections.find(name) < 0: | |
# Create collection | |
bpy.data.collections.new(name) | |
# Add collection to scene | |
scene.collection.children.link(bpy.data.collections[name]) | |
# ************************************************************************************** | |
def setupInstructionsLook(): | |
scene = bpy.context.scene | |
render = scene.render | |
render.use_freestyle = True | |
# Use Blender Eevee (or Eevee Next) for instructions look | |
try: | |
render.engine = 'BLENDER_EEVEE' | |
except: | |
render.engine = 'BLENDER_EEVEE_NEXT' | |
# Change camera to Orthographic | |
if scene.camera is not None: | |
scene.camera.data.type = 'ORTHO' | |
# For Blender Render, set transparent background. (Not available in Blender 3.5.1 or higher.) | |
if hasattr(render, "alpha_mode"): | |
render.alpha_mode = 'TRANSPARENT' | |
# Turn on cycles transparency | |
scene.cycles.film_transparent = True | |
# Increase max number of transparency bounces to at least 80 | |
# This avoids artefacts when multiple transparent objects are behind each other | |
if scene.cycles.transparent_max_bounces < 80: | |
scene.cycles.transparent_max_bounces = 80 | |
# Add collections / groups, if not already present | |
if hasCollections: | |
createCollection(scene, 'Black Edged Bricks Collection') | |
createCollection(scene, 'White Edged Bricks Collection') | |
createCollection(scene, 'Solid Bricks Collection') | |
createCollection(scene, 'Transparent Bricks Collection') | |
else: | |
if bpy.data.groups.find('Black Edged Bricks Collection') < 0: | |
bpy.data.groups.new('Black Edged Bricks Collection') | |
if bpy.data.groups.find('White Edged Bricks Collection') < 0: | |
bpy.data.groups.new('White Edged Bricks Collection') | |
# Find or create the render/view layers we are interested in: | |
layers = getLayers(scene) | |
# Remember current view layer | |
current_view_layer = bpy.context.view_layer | |
# Add layers as needed | |
layerNames = list(map((lambda x: x.name), layers)) | |
if "SolidBricks" not in layerNames: | |
bpy.ops.scene.view_layer_add() | |
layers[-1].name = "SolidBricks" | |
layers[-1].use = True | |
layerNames.append("SolidBricks") | |
solidLayer = layerNames.index("SolidBricks") | |
if "TransparentBricks" not in layerNames: | |
bpy.ops.scene.view_layer_add() | |
layers[-1].name = "TransparentBricks" | |
layers[-1].use = True | |
layerNames.append("TransparentBricks") | |
transLayer = layerNames.index("TransparentBricks") | |
# Restore current view layer | |
bpy.context.window.view_layer = current_view_layer | |
# Use Z layer (defaults to off in Blender 3.5.1) | |
if hasattr(layers[transLayer], "use_pass_z"): | |
layers[transLayer].use_pass_z = True | |
if hasattr(layers[solidLayer], "use_pass_z"): | |
layers[solidLayer].use_pass_z = True | |
# Disable any render/view layers that are not needed | |
for i in range(len(layers)): | |
if i not in [solidLayer, transLayer]: | |
layers[i].use = False | |
layers[solidLayer].use = True | |
layers[transLayer].use = True | |
# Include or exclude collections for each layer | |
for collection in layers[solidLayer].layer_collection.children: | |
collection.exclude = collection.name != 'Solid Bricks Collection' | |
for collection in layers[transLayer].layer_collection.children: | |
collection.exclude = collection.name != 'Transparent Bricks Collection' | |
#layers[solidLayer].layer_collection.children['Black Edged Bricks Collection'].exclude = True | |
#layers[solidLayer].layer_collection.children['White Edged Bricks Collection'].exclude = True | |
#layers[solidLayer].layer_collection.children['Solid Bricks Collection'].exclude = False | |
#layers[solidLayer].layer_collection.children['Transparent Bricks Collection'].exclude = True | |
#layers[transLayer].layer_collection.children['Black Edged Bricks Collection'].exclude = True | |
#layers[transLayer].layer_collection.children['White Edged Bricks Collection'].exclude = True | |
#layers[transLayer].layer_collection.children['Solid Bricks Collection'].exclude = True | |
#layers[transLayer].layer_collection.children['Transparent Bricks Collection'].exclude = False | |
# Move each part to appropriate collection | |
for object in scene.objects: | |
isTransparent = False | |
if "Lego.isTransparent" in object: | |
isTransparent = object["Lego.isTransparent"] | |
# Add objects to the appropriate layers | |
if isTransparent: | |
linkToCollection('Transparent Bricks Collection', object) | |
else: | |
linkToCollection('Solid Bricks Collection', object) | |
# Add object to the appropriate group | |
if object.data != None: | |
colour = object.data.materials[0].diffuse_color | |
# Dark colours have white lines | |
if LegoColours.isDark(colour): | |
linkToCollection('White Edged Bricks Collection', object) | |
else: | |
linkToCollection('Black Edged Bricks Collection', object) | |
# Find or create linesets | |
solidBlackLineset = None | |
solidWhiteLineset = None | |
transBlackLineset = None | |
transWhiteLineset = None | |
for lineset in layers[solidLayer].freestyle_settings.linesets: | |
if lineset.name == "LegoSolidBlackLines": | |
solidBlackLineset = lineset | |
if lineset.name == "LegoSolidWhiteLines": | |
solidWhiteLineset = lineset | |
for lineset in layers[transLayer].freestyle_settings.linesets: | |
if lineset.name == "LegoTransBlackLines": | |
transBlackLineset = lineset | |
if lineset.name == "LegoTransWhiteLines": | |
transWhiteLineset = lineset | |
if solidBlackLineset == None: | |
layers[solidLayer].freestyle_settings.linesets.new("LegoSolidBlackLines") | |
solidBlackLineset = layers[solidLayer].freestyle_settings.linesets[-1] | |
setupLineset(solidBlackLineset, 2.25, 'Black Edged Bricks Collection') | |
if solidWhiteLineset == None: | |
layers[solidLayer].freestyle_settings.linesets.new("LegoSolidWhiteLines") | |
solidWhiteLineset = layers[solidLayer].freestyle_settings.linesets[-1] | |
setupLineset(solidWhiteLineset, 2, 'White Edged Bricks Collection') | |
if transBlackLineset == None: | |
layers[transLayer].freestyle_settings.linesets.new("LegoTransBlackLines") | |
transBlackLineset = layers[transLayer].freestyle_settings.linesets[-1] | |
setupLineset(transBlackLineset, 2.25, 'Black Edged Bricks Collection') | |
if transWhiteLineset == None: | |
layers[transLayer].freestyle_settings.linesets.new("LegoTransWhiteLines") | |
transWhiteLineset = layers[transLayer].freestyle_settings.linesets[-1] | |
setupLineset(transWhiteLineset, 2, 'White Edged Bricks Collection') | |
# Create Compositing Nodes | |
scene.use_nodes = True | |
if "Solid" in scene.node_tree.nodes: | |
solidLayer = scene.node_tree.nodes["Solid"] | |
else: | |
solidLayer = scene.node_tree.nodes.new('CompositorNodeRLayers') | |
solidLayer.name = "Solid" | |
solidLayer.layer = 'SolidBricks' | |
if "Trans" in scene.node_tree.nodes: | |
transLayer = scene.node_tree.nodes["Trans"] | |
else: | |
transLayer = scene.node_tree.nodes.new('CompositorNodeRLayers') | |
transLayer.name = "Trans" | |
transLayer.layer = 'TransparentBricks' | |
if "Z Combine" in scene.node_tree.nodes: | |
zCombine = scene.node_tree.nodes["Z Combine"] | |
else: | |
zCombine = scene.node_tree.nodes.new('CompositorNodeZcombine') | |
zCombine.use_alpha = True | |
zCombine.use_antialias_z = True | |
if "Set Alpha" in scene.node_tree.nodes: | |
setAlpha = scene.node_tree.nodes["Set Alpha"] | |
else: | |
setAlpha = scene.node_tree.nodes.new('CompositorNodeSetAlpha') | |
setAlpha.inputs[1].default_value = 0.75 | |
composite = scene.node_tree.nodes["Composite"] | |
composite.location = (950, 400) | |
zCombine.location = (750, 500) | |
transLayer.location = (300, 300) | |
solidLayer.location = (300, 600) | |
setAlpha.location = (580, 370) | |
links = scene.node_tree.links | |
links.new(solidLayer.outputs[0], zCombine.inputs[0]) | |
links.new(solidLayer.outputs[2], zCombine.inputs[1]) | |
links.new(transLayer.outputs[0], setAlpha.inputs[0]) | |
links.new(setAlpha.outputs[0], zCombine.inputs[2]) | |
links.new(transLayer.outputs[2], zCombine.inputs[3]) | |
links.new(zCombine.outputs[0], composite.inputs[0]) | |
# Blender 3 only: link the Z from the Z Combine to the composite. This is not present in Blender 4. | |
if bpy.app.version < (4, 0, 0): | |
links.new(zCombine.outputs[1], composite.inputs[2]) | |
# ************************************************************************************** | |
def iterateCameraPosition(camera, render, vcentre3d, moveCamera): | |
global globalPoints | |
bpy.context.view_layer.update() | |
minX = sys.float_info.max | |
maxX = -sys.float_info.max | |
minY = sys.float_info.max | |
maxY = -sys.float_info.max | |
# Calculate matrix to take 3d points into normalised camera space | |
modelview_matrix = camera.matrix_world.inverted() | |
get_depsgraph_method = getattr(bpy.context, "evaluated_depsgraph_get", None) | |
if callable(get_depsgraph_method): | |
depsgraph = get_depsgraph_method() | |
else: | |
depsgraph = bpy.context.depsgraph | |
projection_matrix = camera.calc_matrix_camera( | |
depsgraph, | |
x=render.resolution_x, | |
y=render.resolution_y, | |
scale_x=render.pixel_aspect_x, | |
scale_y=render.pixel_aspect_y) | |
mp_matrix = projection_matrix @ modelview_matrix | |
mpinv_matrix = mp_matrix.copy() | |
mpinv_matrix.invert() | |
isOrtho = bpy.context.scene.camera.data.type == 'ORTHO' | |
# Convert 3d points to camera space, calculating the min and max extents in 2d normalised camera space. | |
minDistToCamera = sys.float_info.max | |
for point in globalPoints: | |
p1 = mp_matrix @ mathutils.Vector((point.x, point.y, point.z, 1)) | |
if isOrtho: | |
point2d = (p1.x, p1.y) | |
elif abs(p1.w)<1e-8: | |
continue | |
else: | |
point2d = (p1.x/p1.w, p1.y/p1.w) | |
minX = min(point2d[0], minX) | |
minY = min(point2d[1], minY) | |
maxX = max(point2d[0], maxX) | |
maxY = max(point2d[1], maxY) | |
disttocamera = (point - camera.location).length | |
minDistToCamera = min(minDistToCamera, disttocamera) | |
#debugPrint("minX,maxX: " + ('%.5f' % minX) + "," + ('%.5f' % maxX)) | |
#debugPrint("minY,maxY: " + ('%.5f' % minY) + "," + ('%.5f' % maxY)) | |
# Calculate distance d from camera to centre of the model | |
d = (vcentre3d - camera.location).length | |
# Which axis is filling most of the display? | |
largestSpan = max(maxX-minX, maxY-minY) | |
# Force option to be in range | |
if Options.cameraBorderPercent > 0.99999: | |
Options.cameraBorderPercent = 0.99999 | |
# How far should the camera be away from the object? | |
# Zoom in or out to make the coverage close to 1 (or 1-border if theres a border amount specified) | |
scale = largestSpan/(2 - 2 * Options.cameraBorderPercent) | |
desiredMinDistToCamera = scale * minDistToCamera | |
# Adjust d to be the change in distance from the centre of the object | |
offsetD = minDistToCamera - desiredMinDistToCamera | |
# Calculate centre of object on screen | |
centre2d = mathutils.Vector(((minX + maxX)*0.5, (minY+maxY)*0.5)) | |
# Get the forward vector of the camera | |
tempMatrix = camera.matrix_world.copy() | |
tempMatrix.invert() | |
forwards4d = -tempMatrix[2] | |
forwards3d = mathutils.Vector((forwards4d.x, forwards4d.y, forwards4d.z)) | |
# Transform the 2d centre of object back into 3d space | |
if isOrtho: | |
centre3d = mpinv_matrix @ mathutils.Vector((centre2d.x, centre2d.y, 0, 1)) | |
centre3d = mathutils.Vector((centre3d.x, centre3d.y, centre3d.z)) | |
# Move centre3d a distance d from the camera plane | |
v = centre3d - camera.location | |
dist = v.dot(forwards3d) | |
centre3d = centre3d + (d - dist) * forwards3d | |
else: | |
centre3d = mpinv_matrix @ mathutils.Vector((centre2d.x, centre2d.y, -1, 1)) | |
centre3d = mathutils.Vector((centre3d.x / centre3d.w, centre3d.y / centre3d.w, centre3d.z / centre3d.w)) | |
# Make sure the 3d centre of the object is distance d from the camera location | |
forwards = centre3d - camera.location | |
forwards.normalize() | |
centre3d = camera.location + d * forwards | |
# Get the centre of the viewing area in 3d space at distance d from the camera | |
# This is where we want to move the object to | |
origin3d = camera.location + d * forwards3d | |
#debugPrint("d: " + ('%.5f' % d)) | |
#debugPrint("camloc: " + ('%.5f' % camera.location.x) + "," + ('%.5f' % camera.location.y) + "," + ('%.5f' % camera.location.z)) | |
#debugPrint("forwards3d: " + ('%.5f' % forwards3d.x) + "," + ('%.5f' % forwards3d.y) + "," + ('%.5f' % forwards3d.z)) | |
#debugPrint("Origin3d: " + ('%.5f' % origin3d.x) + "," + ('%.5f' % origin3d.y) + "," + ('%.5f' % origin3d.z)) | |
#debugPrint("Centre3d: " + ('%.5f' % centre3d.x) + "," + ('%.5f' % centre3d.y) + "," + ('%.5f' % centre3d.z)) | |
# bpy.context.scene.cursor_location = centre3d | |
# bpy.context.scene.cursor_location = origin3d | |
if moveCamera: | |
if isOrtho: | |
offset3d = (centre3d - origin3d) | |
camera.data.ortho_scale *= scale | |
else: | |
# How much do we want to move the camera? | |
# We want to move the camera by the same amount as if we moved the centre of the object to the centre of the viewing area. | |
# In practice, this is not completely accurate, since the perspective projection changes the objects silhouette in 2d space | |
# when we move the camera, but it's close in practice. We choose to move it conservatively by 93% of our calculated amount, | |
# a figure obtained by some quick practical observations of the convergence on a few test models. | |
offset3d = 0.93 * (centre3d - origin3d) + offsetD * forwards3d | |
# debugPrint("offset3d: " + ('%.5f' % offset3d.x) + "," + ('%.5f' % offset3d.y) + "," + ('%.5f' % offset3d.z) + " length:" + ('%.5f' % offset3d.length)) | |
# debugPrint("move by: " + ('%.5f' % offset3d.length)) | |
camera.location += mathutils.Vector((offset3d.x, offset3d.y, offset3d.z)) | |
return offset3d.length | |
return 0.0 | |
# ************************************************************************************** | |
def getConvexHull(minPoints = 3): | |
global globalPoints | |
if len(globalPoints) >= minPoints: | |
bm = bmesh.new() | |
[bm.verts.new(v) for v in globalPoints] | |
bm.verts.ensure_lookup_table() | |
ret = bmesh.ops.convex_hull(bm, input=bm.verts, use_existing_faces=False) | |
globalPoints = [vert.co.copy() for vert in ret["geom"] if isinstance(vert, bmesh.types.BMVert)] | |
del ret | |
bm.clear() | |
bm.free() | |
# ************************************************************************************** | |
def loadFromFile(context, filename, isFullFilepath=True): | |
global globalCamerasToAdd | |
global globalContext | |
global globalScaleFactor | |
# Set global scale factor | |
# ----------------------- | |
# | |
# 1. The size of Lego pieces: | |
# | |
# Lego scale: https://www.lugnet.com/~330/FAQ/Build/dimensions | |
# | |
# 1 Lego draw unit = 0.4 mm, in an idealised world. | |
# | |
# In real life, actual Lego pieces have been measured as 0.3993 mm +/- 0.0002, | |
# which makes 0.4mm accurate enough for all practical purposes (The difference | |
# being just 7 microns). | |
# | |
# 2. Blender coordinates: | |
# | |
# Blender reports coordinates in metres by default. So the | |
# scale factor to convert from Lego units to Blender coordinates | |
# is 0.0004. | |
# | |
# This calculation does not adjust for any gap between the pieces. | |
# This is (optionally) done later in the calculations, where we | |
# reduce the size of each piece by 0.2mm (default amount) to allow | |
# for a small gap between pieces. This matches real piece sizes. | |
# | |
# 3. Blender Scene Unit Scale: | |
# | |
# Blender has a 'Scene Unit Scale' value which by default is set | |
# to 1.0. By changing the 'Unit Scale' after import the size of | |
# everything in the scene can be adjusted. | |
globalScaleFactor = 0.0004 * Options.realScale | |
globalWeldDistance = 0.01 * globalScaleFactor | |
globalCamerasToAdd = [] | |
globalContext = context | |
# Make sure we have the latest configuration, including the latest ldraw directory | |
# and the colours derived from that. | |
Configure() | |
LegoColours() | |
Math() | |
if Configure.ldrawInstallDirectory == "": | |
printError("Could not find LDraw Part Library") | |
return None | |
# Clear caches | |
CachedDirectoryFilenames.clearCache() | |
CachedFiles.clearCache() | |
CachedGeometry.clearCache() | |
BlenderMaterials.clearCache() | |
Configure.warningSuppression = {} | |
if Options.useLogoStuds: | |
debugPrint("Loading stud files") | |
# Load stud logo files into cache | |
addFileToCache("stud-logo" + Options.logoStudVersion + ".dat", "stud.dat") | |
addFileToCache("stud2-logo" + Options.logoStudVersion + ".dat", "stud2.dat") | |
addFileToCache("stud6-logo" + Options.logoStudVersion + ".dat", "stud6.dat") | |
addFileToCache("stud6a-logo" + Options.logoStudVersion + ".dat", "stud6a.dat") | |
addFileToCache("stud7-logo" + Options.logoStudVersion + ".dat", "stud7.dat") | |
addFileToCache("stud10-logo" + Options.logoStudVersion + ".dat", "stud10.dat") | |
addFileToCache("stud13-logo" + Options.logoStudVersion + ".dat", "stud13.dat") | |
addFileToCache("stud15-logo" + Options.logoStudVersion + ".dat", "stud15.dat") | |
addFileToCache("stud20-logo" + Options.logoStudVersion + ".dat", "stud20.dat") | |
addFileToCache("studa-logo" + Options.logoStudVersion + ".dat", "studa.dat") | |
addFileToCache("studtente-logo.dat", "s\\teton.dat") # TENTE | |
# Load and parse file to create geometry | |
filename = os.path.expanduser(filename) | |
debugPrint("Loading files") | |
node = LDrawNode(filename, isFullFilepath, os.path.dirname(filename)) | |
node.load() | |
# node.printBFC() | |
if node.file.isModel: | |
# Fix top level rotation from LDraw coordinate space to Blender coordinate space | |
node.file.geometry.points = [Math.rotationMatrix * p for p in node.file.geometry.points] | |
node.file.geometry.edges = [(Math.rotationMatrix @ e[0], Math.rotationMatrix @ e[1]) for e in node.file.geometry.edges] | |
for childNode in node.file.childNodes: | |
childNode.matrix = Math.rotationMatrix @ childNode.matrix | |
# Switch to Object mode and deselect all | |
if bpy.ops.object.mode_set.poll(): | |
bpy.ops.object.mode_set(mode='OBJECT') | |
bpy.ops.object.select_all(action='DESELECT') | |
name = os.path.basename(filename) | |
global globalBrickCount | |
global globalObjectsToAdd | |
global globalPoints | |
globalBrickCount = 0 | |
globalObjectsToAdd = [] | |
globalPoints = [] | |
debugPrint("Creating NodeGroups") | |
BlenderMaterials.createBlenderNodeGroups() | |
# Create Blender objects from the loaded file | |
debugPrint("Creating Blender objects") | |
rootOb = createBlenderObjectsFromNode(node, node.matrix, name) | |
if not node.file.isModel: | |
if rootOb.data: | |
rootOb.data.transform(Math.rotationMatrix) | |
scene = bpy.context.scene | |
camera = scene.camera | |
render = scene.render | |
debugPrint("Number of vertices: " + str(len(globalPoints))) | |
# Take the convex hull of all the points in the scene (operation must have at least three vertices) | |
# This results in far fewer points to consider when adjusting the object and/or camera position. | |
getConvexHull() | |
debugPrint("Number of convex hull vertices: " + str(len(globalPoints))) | |
# Set camera type | |
if scene.camera is not None: | |
if Options.instructionsLook: | |
scene.camera.data.type = 'ORTHO' | |
else: | |
scene.camera.data.type = 'PERSP' | |
# Centre object only if root node is a model | |
if node.file.isModel and globalPoints: | |
# Calculate our bounding box in global coordinate space | |
boundingBoxMin = mathutils.Vector((0, 0, 0)) | |
boundingBoxMax = mathutils.Vector((0, 0, 0)) | |
boundingBoxMin[0] = min(p[0] for p in globalPoints) | |
boundingBoxMin[1] = min(p[1] for p in globalPoints) | |
boundingBoxMin[2] = min(p[2] for p in globalPoints) | |
boundingBoxMax[0] = max(p[0] for p in globalPoints) | |
boundingBoxMax[1] = max(p[1] for p in globalPoints) | |
boundingBoxMax[2] = max(p[2] for p in globalPoints) | |
# Length of bounding box diagonal | |
boundingBoxDistance = (boundingBoxMax - boundingBoxMin).length | |
boundingBoxCentre = (boundingBoxMax + boundingBoxMin) * 0.5 | |
vcentre = (boundingBoxMin + boundingBoxMax) * 0.5 | |
offsetToCentreModel = mathutils.Vector((-vcentre.x, -vcentre.y, -boundingBoxMin.z)) | |
if Options.positionObjectOnGroundAtOrigin: | |
debugPrint("Centre object") | |
rootOb.location += offsetToCentreModel | |
# Offset bounding box | |
boundingBoxMin += offsetToCentreModel | |
boundingBoxMax += offsetToCentreModel | |
boundingBoxCentre += offsetToCentreModel | |
# Offset all points | |
globalPoints = [p + offsetToCentreModel for p in globalPoints] | |
offsetToCentreModel = mathutils.Vector((0, 0, 0)) | |
if camera is not None: | |
if Options.positionCamera: | |
debugPrint("Positioning Camera") | |
camera.data.clip_start = 25 * globalScaleFactor # 0.01 at normal scale | |
camera.data.clip_end = 250000 * globalScaleFactor # 100 at normal scale | |
# Set up a default camera position and rotation | |
camera.location = mathutils.Vector((6.5, -6.5, 4.75)) | |
camera.location.normalize() | |
camera.location = camera.location * boundingBoxDistance | |
camera.rotation_mode = 'XYZ' | |
camera.rotation_euler = mathutils.Euler((1.0471975803375244, 0.0, 0.7853981852531433), 'XYZ') | |
# Must have at least three vertices to move the camera | |
if len(globalPoints) >= 3: | |
isOrtho = camera.data.type == 'ORTHO' | |
if isOrtho: | |
iterateCameraPosition(camera, render, vcentre, True) | |
else: | |
for i in range(20): | |
error = iterateCameraPosition(camera, render, vcentre, True) | |
if (error < 0.001): | |
break | |
# Find the (first) 3D View, then set the view's 'look at' and 'distance' | |
# Note: Not a camera object, but the point of view in the UI. | |
areas = [area for area in bpy.context.window.screen.areas if area.type == 'VIEW_3D'] | |
if len(areas) > 0: | |
area = areas[0] | |
with bpy.context.temp_override(area=area): | |
view3d = bpy.context.space_data | |
view3d.region_3d.view_location = boundingBoxCentre # Where to look at | |
view3d.region_3d.view_distance = boundingBoxDistance # How far from target | |
# Get existing object names | |
sceneObjectNames = [x.name for x in scene.objects] | |
# Remove default objects | |
if Options.removeDefaultObjects: | |
if "Cube" in sceneObjectNames: | |
cube = scene.objects['Cube'] | |
if (cube.location.length < 0.001): | |
unlinkFromScene(cube) | |
if lightName in sceneObjectNames: | |
light = scene.objects[lightName] | |
lampVector = light.location - mathutils.Vector((4.076245307922363, 1.0054539442062378, 5.903861999511719)) | |
if (lampVector.length < 0.001): | |
unlinkFromScene(light) | |
# Finally add each object to the scene | |
debugPrint("Adding {0} objects to scene".format(len(globalObjectsToAdd))) | |
for ob in globalObjectsToAdd: | |
linkToScene(ob) | |
# Parent only once everything has been added to the scene, otherwise the matrix_world's are | |
# sometimes not updated properly - some are erroneously still the identity matrix. | |
setupImplicitParents() | |
# Add cameras to the scene | |
for ob in globalCamerasToAdd: | |
cam = ob.createCameraNode() | |
cam.parent = rootOb | |
globalObjectsToAdd = [] | |
globalCamerasToAdd = [] | |
# Select the newly created root object | |
selectObject(rootOb) | |
# Get existing object names | |
sceneObjectNames = [x.name for x in scene.objects] | |
# Add ground plane with white material | |
if Options.addGroundPlane and not Options.instructionsLook: | |
if "LegoGroundPlane" not in sceneObjectNames: | |
addPlane((0,0,0), 100000 * globalScaleFactor) | |
blenderName = "Mat_LegoGroundPlane" | |
# Reuse current material if it exists, otherwise create a new material | |
if bpy.data.materials.get(blenderName) is None: | |
material = bpy.data.materials.new(blenderName) | |
else: | |
material = bpy.data.materials[blenderName] | |
# Use nodes | |
material.use_nodes = True | |
nodes = material.node_tree.nodes | |
links = material.node_tree.links | |
# Remove any existing nodes | |
for n in nodes: | |
nodes.remove(n) | |
node = nodes.new('ShaderNodeBsdfDiffuse') | |
node.location = 0, 5 | |
node.inputs['Color'].default_value = (1,1,1,1) | |
node.inputs['Roughness'].default_value = 1.0 | |
out = nodes.new('ShaderNodeOutputMaterial') | |
out.location = 200, 0 | |
links.new(node.outputs[0], out.inputs[0]) | |
for obj in bpy.context.selected_objects: | |
obj.name = "LegoGroundPlane" | |
if obj.data.materials: | |
obj.data.materials[0] = material | |
else: | |
obj.data.materials.append(material) | |
# Set to render at full resolution | |
if Options.setRenderSettings: | |
scene.render.resolution_percentage = 100 | |
# Setup scene as appropriate | |
if Options.instructionsLook: | |
setupInstructionsLook() | |
else: | |
setupRealisticLook() | |
# Delete the temporary directory if there was one | |
if Configure.tempDir: | |
Configure.tempDir.cleanup() | |
debugPrint("Load Done") | |
return rootOb |