LDraw into Rhino 3D

LEGO models can be described in the LDraw format. The file format was developed by James Jessiman in 1995. Since then this has become a defacto standard for creating LEGO models on computers.

This program rhildraw is a simple importer of LDraw files. A model can be described by one or more files. We'll get to the details of these files as we go on our journey to implementing the importer.

By the time you get to the end you should have had a chance to read my thoughts on the implementation and see the code.

This literate document is still rough around the edges and needs some more Time Love and Care to massage it for better readability. I hope however that we all can enjoy reading this already.

The repository for this literate program is at https://github.com/jesterKing/rhildraw

TLDR

If you don't feel like reading documentation much you can skip ahead and just go straight to the generated script.

Related documents

Here a couple of links to the specifications on LDraw.org

Installation and Usage

TBD

Overview of the program

The following diagram shows the main structure of the script

flowchart TD
    subgraph init
    A[[Initialization]]
    B[Read file]
    end
    subgraph geom [Read Module]
    direction LR
    C[[Handle commands]]
    D[[Add part geometry]]
    E[[Create block definition]]
    end
    A --> B
    B --> geom
    C --> D
    D --> C
    D --> E

Initialization

We will be using a couple of dictionaries to keep track of files, materials and so on. These will make finding data easier as the command to import another file uses the file name as the key either without or with a parent folder prepended.

Dictionaries for the follow data are used:

<<initialize global variables>>=


vfiles: Mapping[str, LDrawFile]= dict()
idefs : Mapping[str, InstanceDefinition]= dict()
materials : Mapping[str, LDrawMaterial]= dict()
vertidx = 0
zoom_extents = False

Preparing the virtual file system

<<method to prepare virtual files>>=
def prepare_parts_dictionary():
    global lib_path
    library_path : Path = Path(lib_path)
    library_path_net : DirectoryInfo = DirectoryInfo(lib_path)
    all_parts_net = library_path_net.EnumerateFiles("*", SearchOption.AllDirectories)
    for p in all_parts_net:
        fn = Path(p.FullName)
        ldrawfile = LDrawFile(fn)
        vfiles[ldrawfile.name] = ldrawfile
        vfiles[ldrawfile.pname] = ldrawfile

Fetch from the virtual file system

<<method to get LDrawFile instance>>=
def get_ldraw_file(part_name : str) -> LDrawFile:
    global vfiles

    part_name = part_name.replace('/', '\\')

    if part_name in vfiles:
        return vfiles[part_name]

    raise Exception(f"Part file not found: {part_name}")

Add a virtual file to the virtual file system

<<method to add a virtual file>>=
def add_virtual_file(model : Path, filename : str, data : List[str]):
    virtual_file_path = model.parent / 'virtual' / filename
    virtual_file = LDrawFile(virtual_file_path, data)
    vfiles[virtual_file.name] = virtual_file
    vfiles[virtual_file.pname] = virtual_file

Preparing the instance definition map

<<method to prepare instance definitions>>=
def prepare_idefs_dictionary():
    for idef in sc.doc.InstanceDefinitions:
        idefs[idef.Name] = idef

Update the instance definition map

<<method to update instance definitions>>=
def update_idefs_dictionary(part_name):
    idef_part_name = clean_name(part_name)
    idef = sc.doc.InstanceDefinitions.Find(idef_part_name)
    if idef:
        idefs[idef_part_name] = idef
        return idef
    return None

Fetching instance definition

<<method to get InstanceDefinition instance>>=
def get_part_idef(prt):
    p = Path(prt)
    pname = clean_name(p.name)
    if pname in idefs:
        return idefs[pname]

    return None

Reading color definitions

Colors are defined just like parts and assemblies. Line type 0 is used through the Colour Definition Language Extension (specification).

For this script we'll be using the colors defined in LDConfig.ldr from the LDraw complete library, although there are alternatives available. Technically the file with standard color definitions contains the following line in its header:

0 !LDRAW_ORG Configuration UPDATE YYYY-MM-DD

But we'll forgo this check and just assume the file we specify is the correct file.

Preparing the colors means preparing color data for materials so that is what we'll be doing.

We need just one simple method to set up the materials dictionary that will contain all the defined colors, and that is the <<method to load colors>>.

<<initialize color definitions>>=
<<method to load colors>>

To load the colors we just need to get the LDrawFile instance for LDConfig.ldr and pull out the commands. Then we loop over all the commands and handle those that start with the required 0 !COLOUR string. Again we assume that a command is well-formed. For each color command we <<read color properties from command>>, create an instance of LDrawMaterial and add it to the materials dictionary.

<<method to load colors>>=
def load_colors():
    <<load color commands>>
    COLOR_CMD = "0 !COLOUR "
    TO_REMOVE = "0 !"
    for cmd in cmds:
        if cmd.startswith(COLOR_CMD):
            <<read color properties from command>>
            <<create material and save to dictionary>>
    print("Colors read")

To <<load color commands>> we simple get the LDrawFile instance for LDConfig.ldr and use get_commands() on that.

<<load color commands>>=
colorldr = get_ldraw_file("LDConfig.ldr")
cmds = colorldr.get_commands()

Then for each line we clean up the part we're not using (0 !). Most keys have values, but for finishes there is a little snag. Most of the finishes, CHROME, PEARLESCENT, RUBBER, MATTE_METALLIC and METAL when specified make this an odd list. To even that out we will add a dummy value to the list, so we can simple iterate over the list in pairs and generate a dictionary out of it. Eventually we'll be able to properly handle finishes. Perhaps MATERIAL SPECKLE and MATERIAL GLITTER with the use of some good procedural texturing.

<<read color properties from command>>=
properties = dict()
cmd = cmd[len(TO_REMOVE):]
cmd_split = cmd.split()
if len(cmd_split) % 2 == 1:
    cmd_split.append('dummy')
keyvalue_count = len(cmd_split) // 2
for i in range(0, keyvalue_count*2, 2):
    properties[cmd_split[i]] = cmd_split[i+1]

With the properties now converted to a dictionary we can instantiate an LDrawMaterial and add it to the main materials dictionary with the CODE value as key. This key we can later use during handling of other commands that specify a key code to find the correct entry.

<<create material and save to dictionary>>=
ldraw_material = LDrawMaterial(properties)
materials[properties["CODE"]] = ldraw_material

LDrawMaterial class

<<LDraw material class>>=
class LDrawMaterial:
    def __init__(self, props):
        self.properties = props
        self.name = props["COLOUR"]
        self.render_material = None

    def _get_color4f(self, colstr):
        colstr = colstr[1:]
        r = int(colstr[0:2], 16) / 255.0
        g = int(colstr[2:4], 16) / 255.0
        b = int(colstr[4:6], 16) / 255.0
        return Color4f(r, g, b, 1.0)

    def _alpha(self, alphastr):
        alpha = 1.0 - (float(alphastr) / 255.0)
        return alpha

    def get_render_material(self):
        if self.render_material == None:

            raise Exception(f"Material non-existant: {self.name}")
        return self.render_material

    def create_render_material(self):
        for rm in sc.doc.RenderMaterials:
            if rm.Name == self.name:
                self.render_material = rm
                return
        pbr_rm = RenderContentType.NewContentFromTypeId(pbr_guid)

        _basecolor = self._get_color4f(self.properties["VALUE"])

        _roughness = 0.2
        _metallic = 0.0
        _opacity = 1.0

        if "ALPHA" in self.properties:
            _opacity = self._alpha(self.properties["ALPHA"])
            _roughness = 0.03

        if "METAL" in self.properties or "CHROME" in self.properties:
            _metallic = 1.0
            _roughness = 0.03
        if "MATTE_METALLIC" in self.properties:
            _metallic = 1.0
            _roughness = 0.3

        pbr_rm.SetParameter(PbrNames.BaseColor, _basecolor)
        pbr_rm.SetParameter(PbrNames.Opacity, _opacity)
        pbr_rm.SetParameter(PbrNames.Metallic, _metallic)
        pbr_rm.SetParameter(PbrNames.Roughness, _roughness)

        pbr_rm.Name = self.name
        self.render_material = pbr_rm
        sc.doc.RenderMaterials.Add(pbr_rm)

The content GUID used to create Physically Based Material instances is short-handed in pbr_guid.

<<initialize global variables>>=+
pbr_guid = ContentUuids.PhysicallyBasedMaterialType

Virtual file system

The importer uses a simple virtual file system. It is really not more than a mapping of file names to LDrawFile instances. For physical files on the actual file system they will be initialized with a full path to a file. But there are also virtual files that we insert into the system so we can make the access to either file type simple and look the same.

Reading files

All LDraw files consist of lines where each line is of a certain line type. Each line type denotes a command. All LDraw files have these line types, so it is easy to parse them.

LDraw files have the extenions .mpd, .ldr and .dat. These extensions are typically used for files with specific usage although from just the contents it doesn't really matter.

However, the following are well-established conventions. The .dat file is used to define geometry of parts and subparts. The .ldr files often contain commands to load parts, they express assemblies or models. The .mpd is a form of multi-part document, where in one file several files are combined.

LDrawFile class

In our script we represent LDraw files with the class LDrawFile. The class itself is very simple. It has only a method for <<initialization of LDrawFile>> and a method to <<get commands from LDrawFile>>.

<<LDrawFile class>>=
class LDrawFile:
    <<initialization of LDrawFile>>
    <<get commands from LDrawFile>>
    <<method to determine if LDrawFile contains poly commands>>

Instantiation of an LDrawFile instance takes a required parameter path. From this Path instance the file name, suffix and filename with parent are extracted.

If the contents of the file are known during instancing the data can be passed in as a list of strings.

<<initialization of LDrawFile>>=
def __init__(self, path : Path, data : List[str] = []):
    self.commands = data
    self.path = path
    self.name = path.name
    self.suffix = path.suffix
    self.pname = f"{path.parent.name}\\{self.name}"

For most files the contents will not be known in advance. When retrieving the contents the file gets lazily loaded and the contents cached in case this particular file is used again in a model.

Files are to be read in as UTF-8 files. At some point it would probably be good to perhaps support falling back to encodings of old files.

Reading of the file also strips already out empty lines.

<<get commands from LDrawFile>>=
def get_commands(self):
    if len(self.commands)==0:
        with self.path.open(encoding="utf-8") as f:
            cmds = [l.strip() for l in f.readlines()]
            cmds = [c for c in cmds if len(c) > 0]
            self.commands = cmds

    return self.commands
<<method to determine if LDrawFile contains poly commands>>=
def contains_poly_commands(self):
    cmds = self.get_commands()
    for cmd in cmds:
        if len(cmd) == 0: continue
        if cmd[0] in ("2", "3", "4", "5"):
            return True

    return False

Line types

The heart of the LDraw specification revolves around line types. The specification essentially is a collection of lines whereby the first character of the line tells us what kind of operation has to happen.

For now we'll keep the importer simple and go where the path is easiest. We'll implement mainly line types 3, 4 and 1, with a smidge of 0 through the color definition language. Eventually we may want to include also 2 and 5 lines, but for just importing complete models for set dressing and rendering purposes this should get us going.

Line types 3 and 4 method to add a polygons, triangles and quads respectively.

Adding polygons

Handling line types 3 and 4 means adding either a triangle or a quad. The form of line types 3 and 4 are similar. A line type three starts with the number 3 followed by a color code and three triplets of values that are either integers or floating point. For both line types we ignore the color code as we'll be using the color codes defined on line type 1 commands. But we'll get into that later.

The line type four has a similar format, starting with number 4, the color code and then four triplets of values.

From the start number we parse how many triplets - vertices we'll be handling. This is the first character of the command string.

<<parse vertex count>>=
vertices = int(cmd[0])
elements = vertices * stride
to = start + vertices * stride

Each triplet is a coordinate in the polygon. The order in which the triplets are presented is also the vertex order.

The format also has a facility to tell the winding for polygons, but we ignore that here. We'll be using simple CW winding.

Each line has all their elements separated by white space. A simple split will suffice. We'll take only all parts after the color code. Parse all elements as floats, since they form vertices. If any element fails to parse as a float we'll skip adding this as a polygon.

<<parse vertices>>=
d = cmd.split()[start:to]
try:
    d = [float(f) for f in d]
except Exception:
    return

With all numbers parsed we can add them as vertices to the mesh, increasing the vertidx count for each item.

<<add vertices to mesh>>=
for i in range(0, elements, stride):
    V = apply_transforms(d[i:i+stride], xforms)
    m.Vertices.Add(*V)
    vertidx = vertidx + 1

With all vertices added and vertidx adjusted we can now add the face definition for either the triangle or the quad.

<<add face to mesh>>=
if vertices == 4:
    m.Faces.AddFace(vertidx - 4, vertidx - 3, vertidx - 2, vertidx - 1)
elif vertices == 3:
    m.Faces.AddFace(vertidx - 3, vertidx - 2, vertidx - 1)

To bring it all together we can say that to add a polygon from a command string we first <<parse vertex count>> and <<parse vertices>>, then <<add vertices to mesh>> followed by the final step <<add face to mesh>>.

<<method to add a polygon>>=
def add_poly(m : Mesh, cmd : str, xforms : list):
    global vertidx
    stride = 3
    start = 2
    <<parse vertex count>>
    <<parse vertices>>
    <<add vertices to mesh>>
    <<add face to mesh>>

Loading a model

flowchart
    A[Model]
    B[Assembly]
    C[Geometry]

    A --> B
    B --> B
    B --> C
    C --> C

<<method to load a complete model>>=
def load_model(model : LDrawFile):
    lines = model.get_commands()

    FILE_START = '0 FILE '
    first_file = ''
    if model.suffix.lower() == '.mpd':
        
        cur_file = ''
        file_data = []
        for l in lines:
            if l.startswith(FILE_START):
                if cur_file != '':
                    add_virtual_file(model.path, cur_file, file_data)
                if cur_file == '':
                    first_file = l[len(FILE_START):]
                cur_file = l[len(FILE_START):]
                file_data = [l]
            else:
                file_data.append(l)
        add_virtual_file(model.path, cur_file, file_data) 
    start_part = get_ldraw_file(first_file)

    load_assembly(start_part, [rhino_orient])

Loading assembly

<<method to load an assembly>>=
def load_assembly(part : LDrawFile, xforms : list):
    cmds = []
    cmds = part.get_commands()

    cmds = [l for l in cmds if len(l)>0]
    for cmd in cmds:
        if cmd.startswith('1'):
            d = cmd.split()
            color_code = d[1]
            materials[color_code].create_render_material()
            rm = materials[color_code].render_material
            xform = LDrawXform(cmd)
            prt = ' '.join(d[14:])
            _xforms = xforms[:] + [xform]

            obattr = ObjectAttributes()
            obattr.Name = clean_name(prt)
            obattr.Visible = True
            obattr.MaterialSource = ObjectMaterialSource.MaterialFromObject
            obattr.RenderMaterial = rm

            if prt.lower().endswith(".ldr"):
                ldr_file = get_ldraw_file(prt)
                if ldr_file.contains_poly_commands():
                    add_geometry(prt)
                    idef = update_idefs_dictionary(prt)
                    if idef != None:
                        xform = collate_transforms(_xforms)
                        sc.doc.Objects.AddInstanceObject(idef.Index, xform, obattr)
                    else:
                        print(f"Couldn't add part {prt}")
                else:
                    load_assembly(ldr_file, _xforms)
            else:
                idef = get_part_idef(prt)
                xform = collate_transforms(_xforms)
                if idef != None:
                    sc.doc.Objects.AddInstanceObject(idef.Index, xform, obattr)
                else:
                    add_geometry(prt)
                    idef = update_idefs_dictionary(prt)
                    if idef != None:
                        sc.doc.Objects.AddInstanceObject(idef.Index, xform, obattr)
                    else:
                        print(f"Failed to add part {prt}")

            refresh(zoom_extents)

Loading geometry

Actual geometry data is recorded in the .dat files. As all files they can reference other files, the assumption is made that only .dat files are referenced from .dat files. Also the assumption is that no .dat file has been generated that references itself. That would currently lead to an infinite loop, so something to improve on in the future.

For .dat files we'll look only at line type 1, 3 and 4. When seeing a line type 1 we'll find the LDrawFile specified by the command and recurse into load_geometry_from_file to load that part.

Line types 3 and 4 will be passed on to the <<method to add a polygon>>.

All these methods will be adding to the same Mesh instance.

The entry-point for adding geometry is really the add_geometry method, which is given only the part name. If an existing instance definition with the cleaned name is found that will be used with an early exit out of the method.

<<methods to load geometry>>=
def load_geomety_from_file(part : LDrawFile, m : Mesh, xforms : list):
    cmds = part.get_commands()
    for cmd in cmds:
        if cmd.startswith('1'):
            d = cmd.split()
            xform = LDrawXform(cmd)
            prt = ' '.join(d[14:])
            _xforms = [xform] + xforms[:]
            try:
                part_file = get_ldraw_file(prt)
            except Exception:
                print(f"\tERR: Failed getting part {prt}, skipping")
                continue
            load_geomety_from_file(part_file, m, _xforms)
        elif cmd.startswith('3') or cmd.startswith('4'):
            add_poly(m, cmd, xforms)


def add_geometry(part_name : str):
    global vertidx
    vertidx = 0
    name = clean_name(part_name)

    existing_idef = sc.doc.InstanceDefinitions.Find(name)
    if existing_idef:
        print(f"\tSkipping {part_name}, instance already created")
        return
    tmesh = Mesh()
    mesh = Mesh()
    obattr = ObjectAttributes()

    obattr.Name = name
    obattr.Visible = True
    obattr.MaterialSource = ObjectMaterialSource.MaterialFromParent

    ldraw_file = get_ldraw_file(part_name)
    load_geomety_from_file(ldraw_file, tmesh, [id_xform])
    tmesh.Normals.ComputeNormals()
    tmesh.Compact()

    <<weld mesh data>>

    if mesh.Vertices.Count > 0 and mesh.Faces.Count > 0:
        sc.doc.InstanceDefinitions.Add(obattr.Name, "", Point3f.Origin, mesh, obattr)

Welding geometry

Next to actually reading in the geometry data and constructing a mesh out of it the add_geometry commanda also does <<weld mesh data>>. The geometry defined in .dat files is typically fairly coarse and does not include information about normals. Doing a double step of first welding disjoin mesh parts, then welding again with all the pieces put back together again gives relatively good looking pieces in most cases.

<<weld mesh data>>=
meshes = tmesh.SplitDisjointPieces()
for submesh in meshes:
    submesh.Weld(rhmath.ToRadians(60))
    submesh.UnifyNormals()
    mesh.Append(submesh)
mesh.Weld(rhmath.ToRadians(60))
mesh.Compact()

Be mindful though that this won't always work with every part geometry. For instance minifig heads won't necessarily look always great. It all depends on how the geometry was encoded in the .dat file.

LDrawXform class

The LDrawXform class is a simple helper class to handle conversion from the LDraw transformation encoding in line types to Rhino.Geometry.Transform and use those to apply the transforms expressed in a model and related files.

The main point is that the X, Y, Z coordinates of a command go in the right-most column of a Rhino transform.

<<LDrawXform class>>=
class LDrawXform:
    def __init__(self, data : str):
        data = data.strip()
        if len(data) > 0:
            try:
                d = [float(f) for f in data.split()[2:14]]
                self.x = d[0]
                self.y = d[1]
                self.z = d[2]
                self.a = d[3]
                self.b = d[4]
                self.c = d[5]
                self.d = d[6]
                self.e = d[7]
                self.f = d[8]
                self.g = d[9]
                self.h = d[10]
                self.i = d[11]
                xform : Transform = Transform.Identity
                xform.M00 = self.a
                xform.M01 = self.b
                xform.M02 = self.c
                xform.M03 = self.x
                xform.M10 = self.d
                xform.M11 = self.e
                xform.M12 = self.f
                xform.M13 = self.y
                xform.M20 = self.g
                xform.M21 = self.h
                xform.M22 = self.i
                xform.M23 = self.z
                self.xform = xform
            except Exception as e:
                self.xform = Transform.Identity
        else:
            self.xform = Transform.Identity

    def set_xform(self, xform):
        self.xform = xform

    def transform_point(self, u : float, v : float, w : float):
        p = Point3f(u, v, w)
        p.Transform(self.xform)
        return [p.X, p.Y, p.Z]

    def get_xform(self):
        return self.xform

rhino_orient = LDrawXform("")
rhino_orient.set_xform(
    Transform.Rotation(
        rhmath.ToRadians(-90.0),
        Vector3f.XAxis,
        Point3f.Origin
    )
)
id_xform = LDrawXform("")

def apply_transforms(v, xforms):
    for xform in xforms:
        v = xform.transform_point(*v)
    return v

def collate_transforms(xforms):
   xform = Transform.Identity
   for _xform in xforms:
       xform = xform * _xform.get_xform()

   return xform

LDraw files are created with the Y axis up, so we have a rhino_orient transform that will be given to the top <<method to load a complete model>> so that the end result will be rotated properly to the Rhino Z-axis up world.

Misc helper methods

<<misc helper methods>>=
def refresh(zoom_extents = False):
    sc.doc.Views.Redraw()
    if zoom_extents:
        Rhino.RhinoApp.RunScript("ZEA", False)
    Rhino.RhinoApp.Wait()

def clean_name(part_name):
    part_name = part_name.removesuffix(".dat")
    part_name = part_name.removesuffix(".DAT")
    part_name = part_name.removesuffix(".ldr")
    part_name = part_name.removesuffix(".LDR")
    return part_name

Pulling all parts together

<<the whole script.*>>= ./src/rhildraw.py $
import scriptcontext as sc
import math

from typing import Mapping, List

import Rhino

from Rhino.Geometry import Transform, Mesh, Vector3f, Point3f
from Rhino.Display import Color4f
from Rhino.DocObjects import ObjectAttributes, ObjectMaterialSource
from Rhino.DocObjects import InstanceDefinition
from Rhino.Render import ChildSlotNames, ContentUuids, RenderContentType

PbrNames = ChildSlotNames.PhysicallyBased
rhmath = Rhino.RhinoMath

from System.IO import DirectoryInfo, Directory, File, FileInfo
from System.IO import EnumerationOptions, SearchOption

from pathlib import Path

<<LDrawFile class>>
<<LDraw material class>>
<<LDrawXform class>>

<<initialize global variables>>

<<misc helper methods>>

<<method to prepare virtual files>>
<<method to add a virtual file>>

<<method to prepare instance definitions>>
<<method to update instance definitions>>
<<method to get InstanceDefinition instance>>
<<method to get LDrawFile instance>>

<<method to add a polygon>>
<<methods to load geometry>>
<<method to load an assembly>>
<<method to load a complete model>>

<<initialize color definitions>>














lib_path = "e:/dev/brickdat/ldraw"

prepare_parts_dictionary()
prepare_idefs_dictionary()
load_colors()

zoom_extents = True 





fl : Path = vfiles["10030-1.mpd"]
load_model(fl)

refresh(zoom_extents)

sc.doc.Views.EnableRedraw(True, True, True)

Rhino.RhinoApp.RunScript("ZEA", False)

print("Done")