# -*- 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 /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 /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