# -*- coding: utf-8 -*- """LDR Importer 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 os import math import mathutils import traceback import bpy from bpy_extras.io_utils import ImportHelper from .src.ldcolors import Colors from .src.ldconsole import Console from .src.ldmaterials import Materials from .src.ldprefs import Preferences from .src.extras import cleanup as Extra_Cleanup from .src.extras import gaps as Extra_Part_Gaps from .src.extras import linked_parts as Extra_Part_Linked # Global variables objects = [] paths = [] class LDrawFile(object): """Scans LDraw files.""" # FIXME: rewrite - Rewrite entire class (#35) def __init__(self, context, filename, level, mat, colour=None, orientation=None): self.level = level self.points = [] self.faces = [] self.material_index = [] self.subparts = [] self.submodels = [] self.part_count = 0 # Orientation matrix to handle orientation separately # (top-level part only) self.orientation = orientation self.mat = mat self.colour = colour self.parse(filename) # Deselect all objects before import. # This prevents them from receiving any cleanup (if applicable). bpy.ops.object.select_all(action='DESELECT') if len(self.points) > 0 and len(self.faces) > 0: mesh = bpy.data.meshes.new("LDrawMesh") mesh.from_pydata(self.points, [], self.faces) mesh.validate() mesh.update() for i, f in enumerate(mesh.polygons): n = self.material_index[i] # Get the material depending on the current render engine material = ldMaterials.make(n) 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) # Naming of objects: filename of .dat-file, without extension self.ob = bpy.data.objects.new("LDrawObj", mesh) self.ob.name = os.path.basename(filename)[:-4] if LinkParts: # noqa # Set top-level part orientation using Blender's 'matrix_world' self.ob.matrix_world = self.orientation.normalized() else: self.ob.location = (0, 0, 0) objects.append(self.ob) # Link object to scene bpy.context.scene.objects.link(self.ob) for i in self.subparts: self.submodels.append(LDrawFile(context, i[0], i[1], i[2], i[3], i[4])) def parse_line(self, line): """Harvest the information from each line.""" verts = [] color = line[1] if color == '16': color = self.colour num_points = int((len(line) - 2) / 3) for i in range(num_points): self.points.append( (self.mat * mathutils.Vector((float(line[i * 3 + 2]), float(line[i * 3 + 3]), float(line[i * 3 + 4])))). to_tuple()) verts.append(len(self.points) - 1) self.faces.append(verts) self.material_index.append(color) def parse_quad(self, line): """Properly construct quads in each brick.""" color = line[1] verts = [] num_points = 4 v = [] if color == '16': color = self.colour v.append(self.mat * mathutils.Vector((float(line[0 * 3 + 2]), float(line[0 * 3 + 3]), float(line[0 * 3 + 4])))) v.append(self.mat * mathutils.Vector((float(line[1 * 3 + 2]), float(line[1 * 3 + 3]), float(line[1 * 3 + 4])))) v.append(self.mat * mathutils.Vector((float(line[2 * 3 + 2]), float(line[2 * 3 + 3]), float(line[2 * 3 + 4])))) v.append(self.mat * mathutils.Vector((float(line[3 * 3 + 2]), float(line[3 * 3 + 3]), float(line[3 * 3 + 4])))) nA = (v[1] - v[0]).cross(v[2] - v[0]) nB = (v[2] - v[1]).cross(v[3] - v[1]) for i in range(num_points): verts.append(len(self.points) + i) if nA.dot(nB) < 0: self.points.extend([v[0].to_tuple(), v[1].to_tuple(), v[3].to_tuple(), v[2].to_tuple()]) else: self.points.extend([v[0].to_tuple(), v[1].to_tuple(), v[2].to_tuple(), v[3].to_tuple()]) self.faces.append(verts) self.material_index.append(color) def parse(self, filename): """Construct tri's in each brick.""" # FIXME: rewrite - Rework function (#35) subfiles = [] while True: # Get the path to the part filename = (filename if os.path.exists(filename) else locatePart(filename)) # The part does not exist # TODO Do not halt on this condition (#11) if filename is None: return False # Read the located part with open(filename, "rt", encoding="utf_8") as f: lines = f.readlines() # Some models may not have headers or enough lines # to support a header. Handle this case to avoid # hitting an IndexError trying to extract the header line. partTypeLine = ("" if len(lines) <= 3 else lines[3]) # Check the part header for top-level part status is_top_part = is_top_level_part(partTypeLine) # Linked parts relies on the flawed is_top_part logic (#112) # TODO Correct linked parts to use proper logic # and remove this kludge if LinkParts: # noqa is_top_part = filename == fileName # noqa self.part_count += 1 if self.part_count > 1 and self.level == 0: self.subparts.append([filename, self.level + 1, self.mat, self.colour, self.orientation]) else: for retval in lines: tmpdate = retval.strip() if tmpdate != "": tmpdate = tmpdate.split() # Part content if tmpdate[0] == "1": new_file = tmpdate[14] ( x, y, z, a, b, c, d, e, f, g, h, i ) = map(float, tmpdate[2:14]) # Reset orientation of top-level part, # track original orientation # TODO Use corrected isPart logic if self.part_count == 1 and is_top_part and LinkParts: # noqa mat_new = self.mat * mathutils.Matrix(( (1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0), (0, 0, 0, 1) )) orientation = self.mat * mathutils.Matrix(( (a, b, c, x), (d, e, f, y), (g, h, i, z), (0, 0, 0, 1) )) * mathutils.Matrix.Rotation( math.radians(90), 4, 'X') else: mat_new = self.mat * mathutils.Matrix(( (a, b, c, x), (d, e, f, y), (g, h, i, z), (0, 0, 0, 1) )) orientation = None color = tmpdate[1] if color == '16': color = self.colour subfiles.append([new_file, mat_new, color]) # When top-level part, save orientation separately # TODO Use corrected is_top_part logic if self.part_count == 1 and is_top_part: subfiles.append(['orientation', orientation, '']) # Triangle (tri) if tmpdate[0] == "3": self.parse_line(tmpdate) # Quadrilateral (quad) if tmpdate[0] == "4": self.parse_quad(tmpdate) if len(subfiles) > 0: subfile = subfiles.pop() filename = subfile[0] # When top-level brick orientation information found, # save it in self.orientation if filename == 'orientation': self.orientation = subfile[1] subfile = subfiles.pop() filename = subfile[0] self.mat = subfile[1] self.colour = subfile[2] else: break def is_top_level_part(header_line): """Check if the given part is a top level part. @param {String} headerLine The header line stating the part level. @return {Boolean} True if a top level part, False otherwise or the header does not specify. """ # Make sure the file has the spec'd META command # If it does not, we cannot do easily determine the part type, # so we will simply say it is not top level header_line = header_line.lower().strip() if header_line == "": return False header_line = header_line.split() if header_line[0] != "0 !ldraw_org": return False # We can determine if this is top level or not return header_line[2] in ("part", "unofficial_part") def locatePart(partName): """Find the given part in the defined search paths. @param {String} partName The part to find. @return {!String} The absolute path to the part if found. """ # Use the OS's path separator to ensure the parts are found partName = partName.replace("\\", os.path.sep) for path in paths: # Find the part filename using the exact case in the file fname = os.path.join(path, partName) if os.path.exists(fname): return fname # Because case-sensitive file systems, if the first check fails # check again using a normalized part filename # See #112#issuecomment-136719763 else: fname = os.path.join(path, partName.lower()) if os.path.exists(fname): return fname Console.log("Could not find part {0}".format(fname)) return None def create_model(self, context, scale): """Create the actual model.""" # FIXME: rewrite - Rewrite entire function (#35) global objects global ldColors global ldMaterials global fileName fileName = self.filepath # Attempt to get the directory the file came from # and add it to the `paths` list paths[0] = os.path.dirname(fileName) Console.log("Attempting to import {0}".format(fileName)) # The file format as hinted to by # conventional file extensions is not supported. # Recommended: http://ghost.kirk.by/file-extensions-are-only-hints if fileName[-4:].lower() not in (".ldr", ".dat"): Console.log('''ERROR: Reason: Invalid File Type Must be a .ldr or .dat''') self.report({'ERROR'}, '''Error: Invalid File Type Must be a .ldr or .dat''') return {'ERROR'} # It has the proper file extension, continue with the import try: # Rotate and scale the parts # Scale factor is divided by 25 so we can use whole number # scale factors in the UI. For reference, # the default scale 1 = 0.04 to Blender trix = mathutils.Matrix(( (1.0, 0.0, 0.0, 0.0), # noqa (0.0, 0.0, 1.0, 0.0), # noqa (0.0, -1.0, 0.0, 0.0), (0.0, 0.0, 0.0, 1.0) # noqa )) * (scale / 25) # If LDrawDir does not exist, stop the import if not os.path.isdir(LDrawDir): # noqa Console.log(''''ERROR: Cannot find LDraw installation at {0}'''.format(LDrawDir)) # noqa self.report({'ERROR'}, '''Cannot find LDraw installation at {0}'''.format(LDrawDir)) # noqa return {'CANCELLED'} # Instance the colors module and # load the LDraw-defined color definitions ldColors = Colors(LDrawDir, AltColorsOpt) # noqa ldColors.load() ldMaterials = Materials(ldColors, context.scene.render.engine) LDrawFile(context, fileName, 0, trix) for cur_obj in objects: # The CleanUp import option was selected if CleanUpOpt: # noqa Extra_Cleanup.main(cur_obj, LinkParts) # noqa if GapsOpt: # noqa Extra_Part_Gaps.main(cur_obj, scale) # The link identical parts import option was selected if LinkParts: # noqa Extra_Part_Linked.main(objects) # Select all the mesh now that import is complete for cur_obj in objects: cur_obj.select = True # Update the scene with the changes context.scene.update() objects = [] # Always reset 3D cursor to <0,0,0> after import bpy.context.scene.cursor_location = (0.0, 0.0, 0.0) # Display success message Console.log("{0} successfully imported!".format(fileName)) return {'FINISHED'} except Exception as e: Console.log("ERROR: {0}\n{1}\n".format( type(e).__name__, traceback.format_exc())) Console.log("ERROR: Reason: {0}.".format( type(e).__name__)) self.report({'ERROR'}, '''File not imported ("{0}"). Check the console logs for more information.'''.format(type(e).__name__)) return {'CANCELLED'} # ------------ Operator ------------ # class LDRImporterOps(bpy.types.Operator, ImportHelper): """LDR Importer Import Operator.""" bl_idname = "import_scene.ldraw" bl_description = "Import an LDraw model (.ldr/.dat)" bl_label = "Import LDraw Model" bl_space_type = "PROPERTIES" bl_region_type = "WINDOW" bl_options = {'REGISTER', 'UNDO', 'PRESET'} # Instance the preferences system prefs = Preferences() # File type filter in file browser filename_ext = ".ldr" filter_glob = bpy.props.StringProperty( default="*.ldr;*.dat", options={'HIDDEN'} ) ldrawPath = bpy.props.StringProperty( name="", description="Path to the LDraw Parts Library", default=prefs.getLDraw() ) importScale = bpy.props.FloatProperty( name="Scale", description="Use a specific scale for each part", default=prefs.get("importScale", 1.00) ) resPrims = bpy.props.EnumProperty( name="Resolution of part primitives", description="Resolution of part primitives", default=prefs.get("resPrims", "StandardRes"), items=( ("HighRes", "High-Res Primitives", "Import using high resolution primitives. " "NOTE: This feature may create mesh errors"), ("StandardRes", "Standard Primitives", "Import using standard resolution primitives"), ("LowRes", "Low-Res Primitives", "Import using low resolution primitives. " "NOTE: This feature may create mesh errors") ) ) cleanUpParts = bpy.props.BoolProperty( name="Model Cleanup", description="Perform some basic model cleanup", default=prefs.get("cleanUpParts", True) ) altColors = bpy.props.BoolProperty( name="Use Alternate Colors", description="Use LDCfgalt.ldr for color definitions", default=prefs.get("altColors", False) ) addGaps = bpy.props.BoolProperty( name="Spaces Between Parts", description="Add small spaces between each part", default=prefs.get("addGaps", False) ) lsynthParts = bpy.props.BoolProperty( name="Use LSynth Parts", description="Use LSynth parts during import", default=prefs.get("lsynthParts", False) ) linkParts = bpy.props.BoolProperty( name="Link Identical Parts", description="Link identical parts by type and color (experimental)", default=prefs.get("linkParts", False) ) def draw(self, context): """Display import options.""" layout = self.layout box = layout.box() box.label("Import Options", icon="SCRIPTWIN") box.label("LDraw Parts Library", icon="FILESEL") box.prop(self, "ldrawPath") box.prop(self, "importScale") box.label("Primitives", icon="MOD_BUILD") box.prop(self, "resPrims", expand=True) box.label("Additional Options", icon="PREFERENCES") box.prop(self, "linkParts") box.prop(self, "cleanUpParts", expand=True) box.prop(self, "addGaps") box.prop(self, "altColors") box.prop(self, "lsynthParts") def execute(self, context): """Set import options and start the import process.""" global LDrawDir, CleanUpOpt, AltColorsOpt, GapsOpt, LinkParts LDrawDir = str(self.ldrawPath) CleanUpOpt = bool(self.cleanUpParts) AltColorsOpt = bool(self.altColors) GapsOpt = bool(self.addGaps) LinkParts = bool(self.linkParts) # Clear array before adding data if it contains data already # Not doing so duplicates the indexes if paths: del paths[:] # Create placeholder for index 0. # It will be filled with the location of the model later. paths.append("") # Always search for parts in the `models` folder paths.append(os.path.join(self.ldrawPath, "models")) # The unofficial folder exists, search the standard folders if os.path.exists(os.path.join(self.ldrawPath, "unofficial")): paths.append(os.path.join(self.ldrawPath, "unofficial", "parts")) # The user wants to use high-res unofficial primitives if self.resPrims == "HighRes": paths.append(os.path.join(self.ldrawPath, "unofficial", "p", "48")) # The user wants to use low-res unofficial primitives elif self.resPrims == "LowRes": paths.append(os.path.join(self.ldrawPath, "unofficial", "p", "8")) # Search in the `unofficial/p` folder paths.append(os.path.join(self.ldrawPath, "unofficial", "p")) # The user wants to use LSynth parts if self.lsynthParts: if os.path.exists(os.path.join(self.ldrawPath, "unofficial", "lsynth")): paths.append(os.path.join(self.ldrawPath, "unofficial", "lsynth")) Console.log("Use LSynth Parts selected") # Always search for parts in the `parts` folder paths.append(os.path.join(self.ldrawPath, "parts")) # The user wants to use high-res primitives if self.resPrims == "HighRes": paths.append(os.path.join(self.ldrawPath, "p", "48")) Console.log("High-res primitives substitution selected") # The user wants to use low-res primitives elif self.resPrims == "LowRes": paths.append(os.path.join(self.ldrawPath, "p", "8")) Console.log("Low-res primitives substitution selected") # The user wants to use normal-res primitives else: Console.log("Standard-res primitives substitution selected") # Finally, search in the `p` folder paths.append(os.path.join(self.ldrawPath, "p")) # Create the preferences dictionary importOpts = { "addGaps": self.addGaps, "altColors": self.altColors, "cleanUpParts": self.cleanUpParts, "importScale": self.importScale, "linkParts": self.linkParts, "lsynthParts": self.lsynthParts, "resPrims": self.resPrims } # Save the preferences and import the model self.prefs.setLDraw(self.ldrawPath) self.prefs.save(importOpts) create_model(self, context, self.importScale) return {'FINISHED'}