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.
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
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:
vfiles
: dictionary to file representations. Each file will be represented
twice. Once with just the file name and once with parent folder prepended
parent/file.dat
idefs
: dictionary holding all block definitions. The block definiton name is
the key. Block definition names are part file names without the extension. The
block definitions are the values. The corresponding RhinoCommon
class is
InstanceDefinition
.materials
: dictionary containing LEGO colors as RenderMaterial
s. The color
code is the key. Colors are materialized as RenderMaterial
only when
actually needed.
vfiles: Mapping[str, LDrawFile]= dict()
idefs : Mapping[str, InstanceDefinition]= dict()
materials : Mapping[str, LDrawMaterial]= dict()
vertidx = 0
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>>
.
<<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.
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.
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.
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.
ldraw_material = LDrawMaterial(properties)
materials[properties["CODE"]] = ldraw_material
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)
pbr_guid = ContentUuids.PhysicallyBasedMaterialType
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.
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>>
.
class LDrawFile:
<<initialization of LDrawFile>>
<<get commands from LDrawFile>>
def __repr__(self):
if self.is_3dm():
return f"LDrawFile '{self.name}', a Rhino 3d file."
else:
return f"LDrawFile '{self.name}', {len(self.get_commands())} commands."
def is_ccw_winding(self):
if self.is_3dm():
return
cmds = self.get_commands()
for cmd in cmds:
if cmd.startswith('0') and 'BFC' in cmd and 'CCW' in cmd:
return True
return False
def is_3dm(self):
return self.suffix == '.3dm'
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.
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}"
self.ppname = f"{path.parent.parent.name}\\{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.
def get_commands(self):
if len(self.commands)==0 and not self.is_3dm():
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
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 add polygon methods, triangles and quads respectively.
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.
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.
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.
vidxs = list()
for i in range(0, elements, stride):
V = apply_transforms(d[i:i+stride], xforms)
m.Vertices.Add(*V)
vidxs.append(vertidx)
vertidx += 1
if len(vidxs) == 4 and from_winding == winding:
rev = vidxs[1:]
rev.reverse()
vidxs = [vidxs[0]] + rev
With all vertices added and vertidx
adjusted we can now add the face definition for either the triangle or the quad.
if vertices == 4:
mf = MeshFace(vidxs[0], vidxs[1], vidxs[2], vidxs[3])
elif vertices == 3:
mf = MeshFace(vidxs[0], vidxs[1], vidxs[2])
if from_winding != winding and False:
mf = mf.Flip()
m.Faces.AddFace(mf)
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>>
.
def add_poly(m : Mesh, cmd : str, xforms : list, from_winding = Winding.CCW, winding = Winding.CCW):
global vertidx
stride = 3
start = 2
<<parse vertex count>>
<<parse vertices>>
<<add vertices to mesh>>
<<add face to mesh>>
flowchart A[Model] B[Assembly] C[Part] A --> B B --> B B --> C C --> C
import traceback
import sys
import scriptcontext as sc
import math
from typing import Mapping, List
import Rhino
from Rhino.Geometry import Transform, Mesh, Vector3f, Point3f, MeshFace
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
from enum import Enum
class Winding(Enum):
CW = 1
CCW = 2
def is_ccw(self):
return self == Winding.CCW
def is_cw(self):
return self == Winding.CW
def flip(self):
return Winding.CW if self == Winding.CCW else Winding.CCW
<<LDrawFile class>>
<<LDraw material class>>
<<initialize global variables>>
def refresh():
sc.doc.Views.Redraw()
Rhino.RhinoApp.Wait()
class LegoXform:
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 = LegoXform("")
rhino_orient.set_xform(
Transform.Rotation(
rhmath.ToRadians(-90.0),
Vector3f.XAxis,
Point3f.Origin
)
)
id_xform = LegoXform("")
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
def prepare_parts_dictionary():
global lib_path, vfiles
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)
if fn.suffix.lower() in ('.txt', '.zip', '.exe', '.DS_Store'):
continue
if len(fn.suffix) > 0 and fn.suffix.lower() in ('.3dm'):
print(f'Adding {fn}')
ldrawfile = LDrawFile(fn)
vfiles[ldrawfile.name] = ldrawfile
vfiles[ldrawfile.pname] = ldrawfile
if ldrawfile.is_3dm():
print(f'\t{ldrawfile} .. [{ldrawfile.name}] :: {vfiles[ldrawfile.name]}')
def prepare_idefs_dictionary():
for idef in sc.doc.InstanceDefinitions:
idefs[idef.Name] = idef
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
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
<<add polygon method>>
first_time = True
def get_ldraw_file(part_name : str) -> LDrawFile:
global vfiles, first_time
part_name = part_name.replace('/', '\\')
part_3dm = Path(part_name).with_suffix('.3dm').name
print(f"...searching for [{part_3dm}]")
if "springMesh" in part_3dm:
pass
if part_3dm in vfiles.keys():
print(f"\t found {part_3dm}")
return vfiles[part_3dm]
if part_name in vfiles.keys():
return vfiles[part_name]
raise Exception(f"Part file not found: {part_name}")
def load_part(part : LDrawFile, m : Mesh, xforms : list, override_invert_faces = False):
cmds = part.get_commands()
invert_faces = False
direction = Winding.CCW if part.is_ccw_winding() else Winding.CW
if part.is_ccw_winding() and not override_invert_faces:
invert_faces = True
elif part.is_ccw_winding() and override_invert_faces:
invert_faces = False
elif override_invert_faces and not part.is_ccw_winding():
invert_faces = True
invert_next = False
for cmd in cmds:
if is_invert_cmd(cmd):
invert_next = True
continue
if cmd.startswith('1'):
d = cmd.split()
xform = LegoXform(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_part(part_file, m, _xforms, invert_next)
elif cmd.startswith('3') or cmd.startswith('4'):
direction_to_use = direction
if override_invert_faces:
direction_to_use = direction.flip()
add_poly(m, cmd, xforms, direction, direction.flip() if override_invert_faces else direction)
invert_next = False
def get_part_idef(prt):
p = Path(prt)
pname = clean_name(p.name)
if pname in idefs:
return idefs[pname]
return None
def is_invert_cmd(cmd):
return cmd.startswith('0') and 'BFC' in cmd and 'INVERTNEXT' in cmd
def contains_poly_commands(cmds):
for cmd in cmds:
if len(cmd) == 0: continue
if cmd[0] in ("2", "3", "4", "5"):
return True
return False
def blockinstance_for_idef(prt, _xforms, obattr, override_invert_faces=False):
idef = get_part_idef(prt)
xform = collate_transforms(_xforms)
if idef == None:
add_part(prt, override_invert_faces)
idef = update_idefs_dictionary(prt)
if idef != None:
sc.doc.Objects.AddInstanceObject(idef.Index, xform, obattr)
else:
print(f"Failed to add part {prt}")
def load_model_part(part : LDrawFile, xforms : list, override_invert_faces = False):
cmds = []
cmds = part.get_commands()
direction = Winding.CW
flipped = False
cmds = [l for l in cmds if len(l)>0]
if part.is_ccw_winding():
direction = Winding.CCW
for cmd in cmds:
if is_invert_cmd(cmd):
direction = not direction
override_invert_faces = True
flipped = True
continue
if cmd.startswith('1'):
d = cmd.split()
color_code = d[1]
materials[color_code].create_render_material()
rm = materials[color_code].render_material
xform = LegoXform(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 contains_poly_commands(ldr_file.get_commands()):
add_part(prt, override_invert_faces)
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}")
elif ldr_file.is_3dm():
blockinstance_for_idef(prt, _xforms, obattr, override_invert_faces)
else:
load_model_part(ldr_file, _xforms, override_invert_faces)
else:
blockinstance_for_idef(prt, _xforms, obattr, override_invert_faces)
"""
idef = get_part_idef(prt)
xform = collate_transforms(_xforms)
if idef == None:
add_part(prt, override_invert_faces)
idef = update_idefs_dictionary(prt)
if idef != None:
sc.doc.Objects.AddInstanceObject(idef.Index, xform, obattr)
else:
print(f"Failed to add part {prt}")
"""
if flipped:
direction = not direction
flipped = False
refresh()
def add_virtual_file(model : Path, filename : str, data : List[str]):
global vfiles
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
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)
elif model.suffix.lower() == '.ldr':
first_file = model.name
start_part = get_ldraw_file(first_file)
load_model_part(start_part, [rhino_orient])
def add_part(part_name : str, invert_faces = False):
global vertidx
vertidx = 0
name = clean_name(part_name)
print(f"adding part {part_name} ({name})")
existing_idef = sc.doc.InstanceDefinitions.Find(name)
if existing_idef:
print(f"\tSkipping {part_name}, instance already created")
return
else:
pass
ldraw_file = get_ldraw_file(part_name)
if ldraw_file.is_3dm():
print(f"... reading 3dm file {ldraw_file.path}")
f3dm = Rhino.FileIO.File3dm.Read(f'{ldraw_file.path}')
obs_to_add = list()
attrs_to_add = list()
for ob in f3dm.Objects:
if ob.Geometry.ObjectType == Rhino.DocObjects.ObjectType.InstanceReference:
continue
obs_to_add.append(ob.Geometry)
attrs_to_add.append(ob.Attributes)
obattr = ObjectAttributes()
obattr.Name = name
obattr.Visible = True
obattr.MaterialSource = ObjectMaterialSource.MaterialFromParent
print(sc.doc.InstanceDefinitions.Add(
f'{name}',
f'High-definition Rhino version of {name}.',
Rhino.Geometry.Point3d.Origin,
obs_to_add,
attrs_to_add
))
f3dm.Dispose()
else:
mesh = Mesh()
obattr = ObjectAttributes()
obattr.Name = name
obattr.Visible = True
obattr.MaterialSource = ObjectMaterialSource.MaterialFromParent
load_part(ldraw_file, mesh, [id_xform], invert_faces)
mesh.Weld(math.radians(50))
mesh.Normals.ComputeNormals()
mesh.Compact()
if mesh.Vertices.Count > 0 and mesh.Faces.Count > 0:
sc.doc.InstanceDefinitions.Add(obattr.Name, "", Point3f.Origin, mesh, obattr)
<<initialize color definitions>>
lib_path = "/Users/jesterking/Documents/brickdat/ldraw"
prepare_parts_dictionary()
prepare_idefs_dictionary()
load_colors()
fl : Path = vfiles["42064-1.mpd"]
fl : Path = vfiles["8836-1.mpd"]
load_model(fl)
refresh()
sc.doc.Views.EnableRedraw(True, True, True)
Rhino.RhinoApp.RunScript("ZEA", False)
print("Done")