Exposes the LDraw parts library as PartCAD parts,
published as //pub/universe/lego/ldraw. Every LDraw category becomes a
sub-package, and every part in it is a parametric :ldraw part that meshes the
LDraw .dat on demand.
No geometry is vendored. Every .dat is fetched from ldraw.org on demand
and cached on disk under ~/.cache/partcad-ldraw/.
What does ship with the package is an index of names: parts-index.zip,
which says which categories exist, which parts are in each, what every part is
called (with its author and licence), what it connects with, and which parts
LDraw draws as several of LEGO's (see Shortcuts).
It is 20,569 parts in about 1.25 MiB, built by
build_parts_index.py and committed.
That index is what makes the package usable at all. PartCAD asks for a whole
category in order to resolve any single part in it, and answering that from the
network meant one HTTP request per part — 1324 of them for Brick — which
ldraw.org rate-limits long before it finishes. Rendering a single brick took
over twenty minutes on a cold cache, when it finished at all. Now it is a file
read.
It is a zip with one member per category, and not one compressed document, because of how PartCAD asks. Every key is a separate run of the plugin — a separate interpreter, keeping nothing from the last one — and listing the library is hundreds of them: the metadata, the child list and one enumeration per object kind, for each of the 92 categories. So whatever a key costs to read is paid hundreds of times over. As one gzipped document that was 11.8 MB of JSON to parse before any key could be answered, 1.1 s of it, nine tenths of it the connection points of parts the key was not about. Split per category it is 1.4 ms for a key that names no category and about 20 ms for one that does.
Two mechanisms are combined:
-
An external repository plugin (
ldraw_repo.py). It serves the package contents over PartCAD's key/value repository protocol:- the categories as top-level sub-packages;
- within each category, the complete list of parts, each with its
description, author and license.
All of that is read from the shipped index. A part the index does not have —
an unofficial one, or one added to the library since the index was built —
still resolves: the plugin falls back to fetching its
.datheader, cached under~/.cache/partcad-ldraw/as before. SetPARTCAD_LDRAW_IGNORE_INDEX=1to bypass the index entirely and go to the network, which is how to check one against the other.
Each package's metadata also declares
objectKinds— that a category holds parts and a partType and none of PartCAD's other eight kinds of object — so that PartCAD stops asking after the ones that have never been there. That is four of the six keys a listing asks per category. A PartCAD too old to read it simply asks as it always did.Between the two — and PartCAD no longer loading the CAD kernel to serialize an answer that is a dict of strings —
pc list packages -rover the whole library went from 7m51s to 15s on a four-core machine, and from 558 runs of this plugin to 278. -
A
wrapperpartType (ldraw.py). Each part'stypeis:ldraw, which resolves to this partType. The wrapper fetches the part's.dat, recursively resolves its sub-parts, meshes the triangles/quads, and returns the shape.LDraw draws a part for a renderer, not for a kernel, so the surface it describes is not closed: a stud is a cylinder standing on a face nobody cut, the same corner is written to four decimals down two different reference paths, and a 16-sided wall meets a 48-sided floor. The wrapper closes that surface before it builds the shape — welding the near-coincident vertices, splitting an edge where another vertex sits on it, and filling each flat hole as the region its boundary loops enclose — and then makes one solid out of the closed shells, cutting the ones LDraw drew facing inward, which are its cavities, rather than fusing them.
Last, each run of faces lying in one plane is joined into one face. LDraw draws everything in triangles, so the flat side of a beam used to come out as hundreds of faces, every edge between two of them an edge of the part: a drawing drew them all, and OpenCASCADE's exact hidden-line removal, which sets memory aside for every pair of edges in the picture, runs out of it on models of a few hundred parts. Joined, the 31 distinct parts of the LEGO F1 car have 61% fewer faces and 42% fewer edges. Nothing moves: a joined face is bounded by the edges its pieces were, a face is joined only to one every vertex of which lies in its plane, and a part that comes out any other than valid solids enclosing the same volume is served as it was built.
A part the wrapper cannot close is refused, with the reason, rather than handed back as a shell: a shell renders like the part and makes every boolean taken against it meaningless. Where the reason is a defect in one particular LDraw file, the file is mended as it is read, by a patch from the maintained list in
patches/(see Patches).
Most of what keeps an LDraw surface from closing is general, and is mended by a rule that holds for every part. What is left is particular to one file: a face its author left out, or two faces that were meant to meet and miss by a tenth of an LDraw unit. No general rule can tell that from a part that really is open there - a rule that guessed would guess wrong somewhere else - so those are mended one file at a time, by a maintained list of patches applied as the file is read. The library's file itself, and the copy in the cache, are left as they are: a patch never changes the text it is applied to. (The cached copy is only ever replaced by the library's own newer text - see Pinning.)
A patch is one of two kinds, and says which in its manifest entry (kind):
- A correction (
"kind": "correction") mends what the file draws: a corner placed on the true circle where the primitive beside it ends on the polygon, a face that stops a tenth of a unit short of the face it was meant to meet. The file is the evidence for what was meant, and the patch invents nothing. - Authored geometry (
"kind": "authored") adds surface the file does not draw at all. It is not a mend, and it is never presented as one: the part that comes out is partly ours, and its manifest entry says so.
A correction is justified when, and only when:
- the part is refused (or wrong) because of a defect in that LDraw file, traced to its lines;
- the file is evidence for what was meant - the corner the next face starts
from, the edge lines (
2) it draws, the primitive that stops a step short of the one beside it - so the patch adds or corrects what the author evidently intended, and invents nothing; - a general rule cannot or should not do it, because it would have to guess;
- the part then builds a solid whose volume agrees with an independent estimate of what its surface encloses, and whose extent is the LDraw geometry's.
Authoring is acceptable only for a part whose LDraw file says the geometry is
missing (0 // Needs Work: Inner side not modelled), or that leaves surfaces
undrawn that no correction can recover. It is held to the same checks, and to
these as well:
- Determined by the file, as far as it can be. In order of preference: close
an opening with the surface its own rim defines, using a primitive where one
fits (a disc closing a cylinder, a cone, a flat cap); where the rim is not
flat, triangles spanning the drawn outline - the type
2edge lines and the open rims - fan-wise, or as a ruled surface between two drawn curves. Nothing nobody drew: no internal ribs, no bosses, no wall that no opening shows. - The filled envelope. Where the inside of a part is unknown, the authored surface closes it at the rim of each opening, so the solid is the part's external shape, exact, filled solid: its volume is the envelope's, not the plastic's. That is the conservative answer for an interference check, which is what a solid is mostly for. A wall thickness is inferred instead only where an opening shows the wall clearly, and the entry says which was done.
- Within the part. Authored geometry never reaches outside the LDraw geometry's bounding box.
- Said plainly. The entry's
reasonquotes the file's ownNeeds Workline where there is one; says what was modelled and on what evidence (the rims and edge lines the file does draw, the wall an opening shows); and says how the result differs from the real part ("the solid is the filled housing: its volume is the envelope's, not the plastic's").
If a part cannot be closed even by authoring without guessing at its external shape, it is not patched, and stays refused.
Prefer patching the part's own file or its own subpart over a primitive or a
subpart other parts share, and patch a shared one only if the change is right
for every part that uses it - and list every one of them in parts.
patches/manifest.json lists the patches, one entry per LDraw file:
{
"file": "64681.dat",
"sha256": "ae5a8002...",
"patch": "64681.dat.patch",
"kind": "correction",
"parts": ["64681"],
"reason": ["What LDraw gets wrong, with the lines and primitives that show it,", "and what the patch changes."]
}fileis the file as a part refers to it, lowercase with/:58121.dat,s/919s01.dat,48/1-4disc.dat.sha256pins the exact upstream text the patch was written against (the hash of its lines, so CRLF and LF line ends are the same file).patchis the patch file underpatches/.kindiscorrectionorauthored(see above).partsare the library parts whose geometry reads the file, which are the parts whose cache key the patch goes into (see below).reasonis for the reviewer: the defect, the evidence for it, and the change.
The patch file is LDraw lines under directives naming the upstream file's lines, counted from 1 as an editor counts them:
0 // Notes for the reader, before the first directive; not copied.
0 !PATCH REPLACE 43
4 16 3.44415 -48.31492 20 3.56 -47.5 20 3.56 -47.5 8 3.44415 -48.31492 8
0 !PATCH AFTER 21
4 16 6.5 -8 34 6.5 -8 86 5 -8 86 5 -8 34
0 !PATCH DELETE 105
0 !PATCH ADD
1 16 0 0 0 1 0 0 0 1 0 0 0 1 4-4disc.dat
AFTER n inserts after line n (AFTER 0 before the first), REPLACE n puts
the lines that follow in place of line n, DELETE n removes it, and ADD
appends to the end. Every number refers to the file as written, whatever an
earlier directive did, and everything after a directive is copied verbatim,
0 BFC statements included. Keep a patch to the lines it needs; where the file
evidently meant a primitive (a 4-4disc.dat closing a 4-4cyli.dat), add the
primitive rather than its triangles.
A patch is applied only to the text it was written against. When the library changes the file, the hash no longer matches, and the patch is not applied: the run says so on stderr and builds the file as the library has it, which may mean the part is refused again until the patch is rewritten (or found to be no longer needed). A patch that does not make sense for the text - a line it names that is not there - is skipped the same way. Neither ever fails a build.
The cache keeps a file as it was first fetched, so a machine can hold an older text than the one a patch is pinned to. A patched file whose cached copy is not the pinned text is fetched again, once per run, and replaces the copy when what the library serves now is the pinned text.
When the library changes a patched file, look at what changed before rewriting
anything. Its move to CC BY 4.0 gave every file a new license line and a blank
one: the geometry was the same, so each patch only had its lines renumbered and
its hash pinned again. A file that was re-wound (BFC CERTIFY CW to CCW, as
the XL motor's shells were) also has the faces its patch adds reversed with it,
so they face the way the faces around them do. And a file whose defect the
library has mended itself needs no patch at all: s/919s01.dat, the 9V battery
box's bracket, had one until its 2023 update put the flat face on the corner
the cylinder ends at.
- Trace the defect to its file and lines, and write the smallest patch that mends it.
- Add the manifest entry, with its
kind, the hash (ldraw._content_hash(text)of the upstream file) and the reason. - Fill in
parts:./build_parts_index.py --patch-users LIBRARYreads every patch's file and writes the parts that reach it, from an unpacked library. - Check that the part builds, and how its volume and extent compare with an
independent estimate; run the tests, which check the manifest (and, with
LDRAW_LIBRARYset to an unpacked library, each patch against its file). - Raise
CACHE_VERSIONinldraw_repo.py.
ldraw.py reads the patches/ directory beside it, which is what a checkout
and the tests have. PartCAD runs the wrapper elsewhere: it writes the file the
repository serves under files/ldraw.py into a directory of its own, with
nothing beside it. So the repository serves it with the patch list written into
it, in place of its _EMBEDDED_PATCHES = None line, and the copy that runs
carries every patch.
PartCAD keys a built part on its configuration, so a part built from a patched
file carries a patches parameter, a hash of every patch it reads: change a
patch and exactly the parts that read it are built again. A changed patch is
served only once the cache version is raised, since the repository's answers
and the wrapper are cached under it.
What a part is also depends on the code that builds it, ldraw.py, which is in
no part's configuration. That is what the cache version is for: PartCAD keys
every object a repository plugin serves on the CACHE_VERSION the plugin
states, so raising it - which a change to ldraw.py does anyway, to be served
at all - has every part built again. An older PartCAD keys the repository's
answers on it but not the parts built from them, and goes on handing back a
part it already built until its cache is cleared.
| File | Kind | Parts | What it does |
|---|---|---|---|
64681.dat |
correction | 64681, its three stickered versions, and 64393 (which mirrors it) | The top face of the lower body stops at x = 6 and the strip it turns up into stands at x = 6.075: a 0.075 LDU slit, 52 long. |
59155.dat |
correction | 58121 (PF XL motor) and the motor body 59154c01, with 59155 itself and the cabled 58121c01 | The front shell's corners at the top and bottom peg holes stand 0.03 LDU outside the boss they meet, and the side holes' channels stop 0.08 LDU short of their boss. The shell's inside is "not modelled"; nothing is authored for it, and the motor builds as its filled housing. |
59154.dat |
correction | the same motor parts, with 59154 itself | The back shell's side ridges peak on the true circle, up to 0.1 LDU outside the front shell's 48-gon where the two meet, and two of its side-window faces run 0.2 LDU past their neighbours, to a corner just outside the boss they meet. |
s/58134s01.dat |
correction | 58134 (PF IR remote, bottom half), and the remotes 58122 and 58135c01 | The inner wall that slopes down to the boss of the hole at the end has its corner on the true circle, 0.05 LDU outside the boss's 16-gon. |
58132.dat |
authored | 58132 (PF IR remote, top half), and the remotes 58122 and 58135c01 | "Inside not modelled": the skin is open underneath. Closed across its own rim - a ruled underside between the drawn rim's vertices, the glass window by the planes of its straight edges, and the selector slot's floor on the bottom edges of its drawn walls. Filled envelope: the top half is solid to its rim; in the remotes the space between it and 58134's drawn interior stays a closed cavity. |
s/58132s01.dat |
correction | the same three | The top in front of the selector's slot overlaps a ring drawn 0.002 LDU off its plane, and the slot's rounded ends are drawn twice by the two placements of the subpart. |
s/62531s01.dat |
correction | 62531 (Technic panel, curved) | At the beam's end, by the pin hole at z = -/+80, half the pin hole's boss is drawn twice, the roof over the hole is scaled 8.001 instead of 8, and two wall faces overlap 0.0003 off one plane. |
LDraw's up is -Y; PartCAD's is +Z. The wrapper reconciles them, so a part
served from here stands on the XY plane like every other PartCAD part, and the
stud grid is XY rather than XZ. In full, a point at LDraw (x, y, z) comes out
at (x, -z, -y) * 0.4 millimetres.
Ports make the same turn, in _port() — a port is a position and a roll, and
both are turned, so a port goes on landing on the geometry it names. Nothing
about a connection changes: studs still mate anti-studs, and an assembly built
out of connect: is untouched by this.
What does change is an assembly that placed an LDraw part by hand. A location:
written to stand a part upright — a quarter turn about X — is now exactly that
turn too many, and should be deleted. Any other location: that rotates wants
re-deriving, because it composes with a part that starts out somewhere new. One
that only translates still means what it meant.
Run it when LDraw publishes a library update, and commit the result:
./build_parts_index.py # downloads complete.zip
./build_parts_index.py --archive complete.zipA change to how connectors are read does not need all of that. --refresh
recomputes only the entries of the parts whose own files place one of the named
primitives, from any unpacked copy of the library, and takes a new entry only
where it keeps every port the old one had, instance names included; a part that
would lose or move a port is reported and left as it was, because that is a
change to the rules and belongs to a full rebuild. Nothing else in the index is
touched, so the listings and the archive are not needed:
./build_parts_index.py --refresh path/to/ldraw --reaching bush0.dat bush.datThat is how the bush's axle hole (below) went in.
What each shortcut is made of (see Shortcuts) is read again the same way, with nothing else in the index touched:
./build_parts_index.py --shortcuts path/to/ldrawIt takes names, authors and licences from
complete.zip — one
download carrying the whole official library, instead of ~25,000 per-part
requests — and takes which parts are in which category from ldraw.org's own
list pages, because that is what the package paths have always been built from
and the archive does not reproduce it. The archive holds 24,735 parts in
parts/; the site lists 20,569 of them. The difference is largely the 4,538
whose description carries a ~, = or _ marker — moved-to stubs, aliases
and colour variants — but not exactly, since some aliases are listed. Rather
than guess at that filter, the build reads the listings: ~1,000 requests at 25
rows a page, cached on disk between runs.
Twelve more categories appear in the site's filter dropdown (Quatro,
Minifig Arm, Mursten and nine others) but list no parts at all. They used to
become empty sub-packages; now they are simply absent.
The parts arrive knowing how they connect. The plugin attaches PartCAD
interfaces — declared in this package's partcad.yaml — to every part whose
LDraw name says exactly what it is, so an assembly can snap the original LDraw
parts together with connect: instead of placing them by hand:
| Interface | Attached to | One instance per |
|---|---|---|
stud / anti-stud |
every rectangular Brick, Plate and Tile, the Technic bricks below, and the top of a minifig head |
stud, on the top and the bottom plane |
technic-pin-hole |
Technic Brick 1 x N with Hole(s), Technic Beam N |
mouth of each round hole |
technic-axle-hole |
Technic Brick 1 x N with ... Axlehole |
mouth of the cross hole |
technic-pin |
Technic Pin, Technic Pin Long, Technic Pin 1/2, and their friction variants |
end of the pin |
technic-axle |
Technic Axle N |
end of the shaft |
gear-tooth / gear-gap |
Technic Gear N Tooth, including the double-bevel and clutch variants |
tooth, and each gap between two |
minifig-neck / -socket |
Minifig Torso / Minifig Head |
the one joint |
minifig-waist / -socket |
Minifig Hips / Minifig Torso |
the one joint |
duplo-stud / duplo-anti-stud |
Duplo Brick A x B |
stud, on the 16 mm grid |
wheel-rim / tyre-bore |
Wheel W x D / Tyre W/ A x D |
part, named for its fitting diameter |
rj12-plug / rj12-socket |
the Mindstorms cables, bricks, motors and sensors | plug, and each socket |
All but the last are derived analytically from the part's name — no geometry is
fetched — so attaching them costs nothing even when a whole category is
enumerated. What a connection leaves free is declared with it: a pin turns in a
round hole (turnZ), an axle slides through one (moveZ), and a minifig's head
and torso turn on their joints. An axle is also the one connector that carries
several parts on one end - the beams it runs through, bushes, gears and a wheel,
each at its own moveZ - so technic-axle says multiConnect: true, and
PartCAD's connectivity test does not report those parts as crowding one port. A pin
in a round hole is a snap fit - its slotted end is squeezed past the lip and
springs open behind it - so that mating says snapIn: true, and PartCAD's
interference test takes the ridge inside the lip for the joint it is. An axle
snaps past nothing, in a round hole or a cross one, so neither of its matings
says so: an axle that overlaps the part it goes through is reported.
Gears are the interesting case: a mesh is a port pair once each tooth and each gap is a port. LEGO gears are cut to one module, so a port on the pitch circle sits half a millimetre per tooth from the centre, and bringing a tooth port and a gap port together leaves the two gears the sum of their pitch radii apart — the centre distance the pair is cut for — with their teeth lined up.
Mindstorms is the one family read from geometry rather than from its name, because "Electric Mindstorms EV3 Large Motor" says nothing about where anything is. See Reading ports from geometry below.
Coverage is deliberately narrow. A name that says more than the rule knows — a bent beam, an axle with a stop, a brick with an open centre, a sculpted character head, a Duplo brick with a curved top — is left alone rather than guessed at, because its features are not where the plain name would put them. Headgear used to be left out for the same reason — no name rule gets past ~96% of the 371 parts — and is now served by name and geometry together: the name picks the family, and the socket has to be there in the geometry for a port to be emitted. LDraw draws it as a single open tube, flipped, whose far end is the part's own origin, which is the opposite reading of the same primitive from a brick's underside. 284 of the 371 are confirmed that way; the other 87, whose geometry shows no single socket, are still left alone.
Technic Pin 3/4 came back for the same reason. Its name does not say which end
is the short one, but 32002 places connect (Technic Pin 1.0) toward -X and
connect3 (Technic Pin 0.5) toward +X, so the instances can be named for it.
Brick | Plate | Tile A x B says how big a part is, not where its studs are.
The two agree for a plain brick and part company everywhere else, so the studs
are read from the part's geometry instead — by the same walk that reads the
Mindstorms connectors below. Counting the geometry of all 2476 parts the name
rule matches, 216 of them (8.7%, and 39.5% of the Plates) were being given a
grid that is not theirs:
2357"Brick 2 x 2 Corner" has three studs at(0,0) (0,20) (20,0), where the rule put four at(±10, ±10)— a corner brick does not use the centred origin a rectangular one does, so every position was wrong, not just the count;6177"Plate 8 x 8 Round with 2 x 2 Centre Studs" was given 64 where it has 4;Brick 1 x 1 with Studs on Four Sideshas five studs facing five ways, which noA x Brule can express at all.
Filtering the names cannot fix that: a denylist of the shape words — Corner,
Round, Bent, Curved, Wedge, Triangular, Octagonal, Headlight —
removes 73 of the 216, leaves 143, and takes 372 correct grids with it.
Reading the geometry works because LDraw draws every stud with a primitive and
says in that primitive's own description what it is: Stud, Stud Open, Stud Tube Solid, Stud Group 2 x 2, Stud Duplo Open. That is a closed vocabulary
LDraw maintains, so the plugin carries a transcription of it rather than a guess
about part names — and carries it as a table, so the walk still never fetches a
primitive. A group is expanded from its own name (stug-1x4 is four studs along
X), and a group is named after what it groups, so stug20-2x2 (Duplo) and
stug19-1x2 (Scala) leave themselves out.
Over the whole library that is:
| parts | |
|---|---|
| studs unchanged | 10,600 |
| studs gained — the name rule gave none | 3,466 |
| studs corrected — both had some, in different places | 540 |
| studs removed — the part has none | 15 |
| walk ran out of budget, name rule left to stand | 0 |
The 15 removals are all parts whose name says as much: "Brick 2 x 2 no Studs
with Pin Vertical", "Brick 2 x 4 with Curved Top", "Plate 1 x 1 with Swirl
on Top". A rectangular brick comes out byte-identical to what the name gave
it, instance names included, so an assembly that names c0r0 keeps working.
It costs 0.30 extra distinct fetches per part over the Brick/Plate/Tile
families and 1.09 over the whole library, because the subparts and primitives
beneath them are shared and cached.
_GEOMETRY_DEPTH and _GEOMETRY_FILES bound one part's walk so a cycle cannot
run away. They are a ceiling rather than a spend: a part whose walk finishes in
ten files reads ten of them whatever the ceiling is, so raising it costs
anything only on the parts that were being cut off. That is why they are set
high enough that nothing in the library is cut off at all — 8 and 1024, where
the original 4 and 64 left 198 parts unfinished. Over the whole library that
costs 45,500 reads instead of 45,022, 1.1% more, and 27 more distinct files.
Depth on its own buys almost nothing — 6 / 64 recovers 11 of the 198 — so it is the file count that binds, and 12 / 4096 measures identical to 8 / 1024, which is what says nothing is running away further out. There is no per-family table and there does not need to be one.
The "walk did not finish, so the name rule stands" fallback stays even though no part in the library reaches it today. A part added tomorrow could be deeper, and losing its studs quietly is the failure worth keeping a guard against.
The same walk, and the same reasoning, with one twist: LDraw's tubes do not sit
where the anti-studs are. Stud Tube Open sits at the centre of a 2 x 2 of them
and Stud Tube Solid between two, so the anti-studs are the corners around a
tube rather than the tube itself, on the plane the tube's far end reaches — 24
LDU down for a brick, 8 for a plate. Which two a solid tube separates is not in
its matrix, because LDraw places every one of them the same way up, so the
part's own studs say which axis and a tube whose neighbours are not both on that
lattice is not guessed at.
Over the library that leaves 12,705 parts unchanged, corrects 132, adds an underside to 1,784 that had none, and — the property that matters — takes one away from nobody. The corner brick gets three anti-studs under its three studs where the name put four in a square.
Where the tubes do not settle it, the name still stands. That is deliberate and not the same rule as for studs: the walk can see that a part has no studs, but an anti-stud that no tube happens to mark may still be there.
A tube is also a tube. An open one's bore has 6 LDU of radius, which is the
stud's own, and LDraw says so itself in the help text of the two variants drawn
without their outer cylinder — stud4o and stud4od call the primitive
a "antistud" to be used like a underside stud. For a cone that bore is an
anti-stud in its own right, named centre so that the four round it keep the
grid names they have always had. A cone is where it matters, because a cone
narrows going up: a Cone 2 x 2 x 2 has four anti-studs at the corners of its
base and one stud in the middle of its top, so without the bore two of them
cannot be joined by an interface at all — the four cells under the upper one and
the single stud on the lower one's top never coincide. Fourteen entries in the
index gain one, and it takes an anti-stud away from none of them.
It is claimed only where the tube stands on the part's own axis and opens into
the plane the cone's name puts its base in, since the walk reads stud
primitives and never sees how far down a part goes. That is what keeps
Cone 4 x 4 x 3 on Brick 2 x 2 Round out of it: the tube there is the round
brick's, one course below the top rather than three, up inside the cone's own
hollow where no stud reaches. A brick's centre tube has the very same bore and
is left unsaid, because a brick's top repeats its base and the bore would only
ever be a join half a stud out of step in both directions.
Which of the two an open tube is, is the base's to say. It is the spacer between
four cells under a part whose footprint really is 2 x 2 or bigger, and it is the
socket itself under a part one stud across, and no name tells the two apart.
Plate 1 x 1 Round stands on a ring 16 LDU across, and so does Brick 1 x 1 Round with Hollow Stud, and so does Cone 2 x 2 x 2 Inverted — whose 2 x 2 is
the rim it is inverted from — while the four cells the walk reads round the tube
are centred 14.1 LDU out from the axis, over nothing at all. A name gives a
part's bounding footprint and not which of its cells are solid: Rock 4 x 4 x 0.667 Octagonal Bottom stands on that same single ring, under four studs' worth
of rock.
So the base is measured: the plane a part stands on and how far it reaches from
its own axis there, out of the lines and faces its files draw themselves and the
size each primitive's name gives — 4-4cyli is the unit cylinder, 4-4ring3
runs from radius 3 to 4, box3u2p is inside the unit cube, a stud is 8 LDU
across and 4 along. A part whose base reaches over none of those four cells has
not got them, and what it has is the bore, on its own: the socket it is stacked
by, and the only join it has.
Sixty-eight entries change, four anti-studs down to one apiece, and every one of
them is a part that could not be stacked on itself before — the stud each offers
is in the middle of its top, where its own socket now is. Sixteen keep the name
c0r0, because the name rule had already given them one anti-stud in the middle
and the walk had been overruling it with four; the rest are centre, as the bore
is on a cone that has both.
Each of those bounds is an upper bound, and a primitive no bound reaches leaves the base unmeasured rather than guessed at, so the measurement's own error is always to find a part wider than it is — which leaves that part's anti-studs exactly as the walk read them. A tube that opens anywhere but the plane the part stands on settles nothing either.
The bore is still left unsaid under a brick two studs across or more, because a brick's top repeats its base and that join would be half a stud out of step in both directions. One stud across it is not out of step with anything: it is dead under the part's own stud, which is where the part below puts its own.
The Mindstorms parts have no dimensions in their names and put no connector in
their own .dat — every one is inside a subpart, one to four levels down. So
for these, and only these, the ports are read from the geometry.
That is affordable because of one observation: a connector is a primitive, and
a primitive is identified by the reference line that names it. Primitives are
therefore leaves and are never fetched — only part files and s\ subparts are.
Each file lists its own references, so the whole of the next level is known as
soon as the current one is parsed, and is fetched in parallel. Measured against
the LDraw library, that finds exactly the same connectors as an exhaustive walk:
| files fetched, exhaustive | pruned | connectors found | |
|---|---|---|---|
| EV3 brick (95646) | 86 | 27 | identical |
| NXT motor (53787) | 70 | 14 | identical |
| EV3 medium motor (99455) | 57 | 8 | identical |
| an ordinary brick (3001) | 10 | 2 | identical |
Every part's peg holes, axle holes, pins and axles are read the same way. The
name rules that used to be the only source reached 80 parts; the geometry
reaches every part that draws a connector, and adds 1,520 of them — 9,931
port instances in all. Not one part loses a port it had, and every one of the 80
comes out byte-identical, instance names included, so an assembly that says
left or h0-top keeps working.
That took the vocabulary being read rather than guessed at, because a Technic feature is not one primitive the way a stud is:
| primitive | is | ports |
|---|---|---|
peghole … peghole6 |
a mouth cap on the surface | 1 |
beamhole, connhole, connhol2 |
a hole right through | 2 |
connect*, confric* |
one end of a pin | 1 |
axle |
a shaft, stretched by its matrix | 2 |
confricrib*, connectcollar*, connectslit* |
pieces of a pin | 0 |
confric8, confric9 |
the middle of a long pin | 0 |
The last two rows are the trap. 6558, "Technic Pin Long with Friction and
Slot", places a "Middle Slotted" section at the same spot as its left end, and
counting it gives the pin a third port on top of the two it has.
Two conventions had to be matched rather than invented. A pin's port sits at the
primitive's own origin, not on the collar face 2 LDU along it — 43093 puts its
collar disc at the origin, and moving the port would shift every pin joint by
0.8 mm. And an axle's two end ports face inward, at each other, because an
axle is pushed into a hole; there is a test named for it.
connhol3 ("Connector Hole One-Sided", 355 uses) and axlehol8 ("Axle
Perimeter", 380 uses) are deliberately left out: which end of the first is a
mouth is not settled, and whether the second appears once per axle is not
established. Leaving them out costs coverage on the parts that use only those;
guessing would put ports in the wrong place, which is worse.
Those parts carry axle holes as well as peg holes, and an axle hole is not one
primitive. LDraw draws it as a profile that its placement matrix stretches
through the part — the two mouths are the two ends of that stretch — and most of
the family are faces of one hole rather than one each, with "Side Edges" and
"Tooth Surface" appearing four to a hole. Two spellings appear once per hole:
the whole forms, and the "Perimeter" face, which some parts use instead of a
whole form. 32064b, "Technic Brick 1 x 2 with Reduced Axlehole", draws its hole
out of faces alone and a whole-form-only reading misses it entirely. Over the
library 574 parts carry only whole forms, 128 only perimeters, and the 13 with
both never put the two in the same place, so taking either as a hole never
counts one twice.
One axle hole is drawn inside a primitive rather than by the part, and the walk
never opens a primitive: bush0, "Technic Bush without Collars", is an
axlehol5 stretched through it, and bush is the same bush with its collars.
Between them they are the axle hole of every Technic cross block and of the
bushes themselves, so the cross blocks used to be served with their pin holes
and not the axle hole they are for — 6536, "Cross Block 1 x 2 (Axle/Pin)", had
a pin hole beside nothing. Both are named in the vocabulary with the hole spelled
out, which gives 47 parts an axle hole each and changes no port anything had.
lego-demo/ builds seven assemblies out of all this, and its README.md
describes the interfaces in detail.
A LEGO part is something to order rather than to make, so every part here says
so: vendor: lego, and its LDraw number as its sku. (A part LDraw has moved
to another number is ordered as the one it moved to; the Helper category,
which holds a renderer's light source, is not for sale.) And every category
names two stores as its suppliers, in the order pc supply looks for a part at
them:
| Store | Provider | What it needs |
|---|---|---|
| LEGO's Pick a Brick | //pub/svc/commerce/lego-pick-a-brick:pick-a-brick |
Nothing |
| BrickLink | //pub/svc/commerce/bricklink:bricklink |
An account's four OAuth values |
The stores are not part of this package. Each is a repository of its own
(svc-commerce-lego-pick-a-brick, svc-commerce-bricklink), registered by the
PartCAD index at //pub/svc/commerce beside the other stores, and the parts
name them there - so they are found wherever this library is used through the
index. A PartCAD that cannot load one passes over it.
A store that has not been given what it needs says, quietly, that it has
nothing, which is what lets every part name both. Each store's README says
how to set it up; a package of models can name stores of its own as well (see
//pub/examples/lego), which a PartCAD that reads them asks first.
pc supply quote --provider //pub/svc/commerce/lego-pick-a-brick:pick-a-brick //pub/universe/lego/ldraw/Brick:3001#10
pc supply find //pub/examples/lego:castleNo store sells by an LDraw number alone, and each of them works out what to buy
for one from Rebrickable's catalogue downloads, which need no account: the LEGO
elements - a design in a colour - a part has been made as, and which parts are
moulds of one another. Where none of a part's own elements is on sale, the
mould LEGO makes in its place is bought instead: the friction pin LDraw draws as
2780 is sold as 61332, the 2L axle 3704 as 32062, the 2 x 2 cone 3942b
as 3942c. Only a mould - the same part made again - stands in for a part; one
Rebrickable merely calls an alternate does not, because the pin without
friction that is an alternate of 2780 does not hold an angle.
A part has no colour, because the geometry has none, so a store quotes the
cheapest colour it has unless it is told one (each store's color).
A shortcut in the LDraw library is a part drawn together with others, whose
file does nothing but place them: a wheel with its tyre on (41896c01), a
battery box as its case, two lids and switch (58119), a shock absorber with its
spring (76320-f2). LEGO makes those as the separate parts, and sells them so,
now and then assembled. So each of the 2,698 shortcuts is served here as what it
is - an assembly of its parts, each placed where the shortcut's file places
it - and is sold as one, with its LDraw number as its SKU:
pc info -a //pub/universe/lego/ldraw/Wheel:41896c01
pc supply quote //pub/universe/lego/ldraw/Wheel:41896c01 # whole, where a store sells it whole
pc supply quote -r //pub/universe/lego/ldraw/Wheel:41896c01 # as a rim and a tyrepc supply asks for an assembly that is for sale as itself, and -r asks for
its parts instead, which is how Pick a Brick sells a wheel. A model links a
shortcut as assembly: rather than part:, by the same name.
It is joined by the same ports. The assembly's map: gives each port of its
parts the name the shortcut, as the one part LDraw draws, gives the port in the
same place facing the same way: the battery box keeps the h0 its end holes
were always joined by, though the hole is now its case's, and the hole in its
switch, which comes out turned half round its own axis, is mapped with that
half turn undone - so that every port is the box's exactly, roll and all. A port the shortcut never had - the tyre's bore,
d43 - is named as its own part names it, or <part>-<name> where two of the
parts share a name.
Only a file that holds nothing but other parts of the library, each of them
turned and nothing else, is a shortcut here. One drawn with faces of its own, or
with a part in it mirrored, scaled or sheared - which no placement of that part
can reproduce - stays a part: the IR receiver's cable, u9190c01, stretches the
segment it is drawn with along its length. A shortcut made of shortcuts (the
plate with wheels and tyres, 122c03, made of the plate with wheels) is an
assembly of assemblies.
LEGO sells an element: a design in a colour. So a part here is named in the
colour it is wanted in, by the colours this package declares - LEGO's own, as
LDraw's colour table (LDConfig.ldr) records them, named as LEGO names them:
pc list colors //pub/universe/legoand a model links a part in one with params:, as an enrich names one:
links:
- part: //pub/universe/lego/ldraw/Brick:3001
params:
color: "//pub/universe/lego:Bright Red"A model would rather write :red than that, and declares an alias of its own
for each colour it uses:
colors:
red:
type: alias
source: "//pub/universe/lego:Bright Red"Each of LEGO's 185 colours is declared with ids for LEGO and LDraw
(ids: {lego: "194", ldraw: "71"} for Medium Stone Grey) and LDraw's value as
its display, and with LDraw's name as an alias where that names nothing else
(Light Bluish Grey). A colour LDraw draws and LEGO has no id for is declared
too, under LDraw's name, as manufacturable: false: it draws, and claims
nothing. The materials and finishes LDraw's codes imply are declared beside
them: ABS, Polycarbonate (the transparent colours), Rubber and Fabric;
Chrome, Pearlescent, Metallic, Matte Metallic, Glitter and Speckle. An LDraw
code is all three at once: 256 is Black in Rubber, 334 a chrome gold.
A part is drawn in the colours its file gives it. LDraw draws a triangle in
a code, and in 16 where it takes the colour of whatever places it - which is
nearly every triangle of nearly every part, and is the part's own color. A
part that draws some of itself in fixed colours - the XL motor (58121) in dark
grey, orange and black, a printed tile, a minifig's face - says so of each face
of its solid, and those faces are drawn in LEGO's colours however the part is
named; its 16 faces are drawn in its color. Faces in one plane are joined
into one only within one colour, so a print on a tile stays the faces it is.
What a part says is read by the ldraw convention this package declares
(cmfConventions:), which looks LDraw's codes up among the colours here by their
ids: {ldraw: ...}; build_colors.py writes the table the part type reads
(ldraw_colors.json) and the declarations from LDConfig.ldr, and is run again
when LDraw publishes a new one.
A shortcut draws its parts in the colours its file does. The wheel
41896c01 draws its rim in 16 and its tyre in 256, so the tyre is linked
as Black in Rubber whatever colour the wheel is, and the rim takes the wheel's
color: an assembly passes its colour on to every link that says none.
The parts here are named by their LDraw numbers. LEGO has numbers of its own, and Pick a Brick sells by them, so it is fair to ask whether LEGO packages in PartCAD should be named by LEGO's numbers instead. They should not, and the reason is what each numbering counts.
- An LDraw number names one shape LDraw draws. It is LEGO's design ID where
LDraw knows it, and says what it adds to it: a letter for a mould LEGO did not
number apart (
3942a,3942b,3942c),p/prfor a print,c01for a shortcut,-f1for a pose of a flexible part. 15,634 parts, leaving out the stickers, the obsolete parts and the files that only say a part moved. - A LEGO design ID names a mould, and LEGO gives the same shape new ones:
the 1 x 1 brick
3005has been sold as designs 3005, 30071 and 35382, and the friction pin is61332at Pick a Brick and2780everywhere else. A print gets a design ID of its own that says nothing of the part it is printed on. Of the 114,661 elements Rebrickable knows, 21,697 have no design ID at all. - A LEGO element ID, which is what Pick a Brick sells by, names a design in
a colour:
300121is the 2 x 4 brick in Bright Red. It is a SKU, not a part: the 1 x 1 brick has been 177 of them. - BrickLink, Brick Owl and Rebrickable number their catalogues their own way, each close to LDraw's and none the same as it; Rebrickable records how the others map to its own.
So no numbering but LDraw's is one name per shape, and a PartCAD part is a
shape. An element ID is a shape and a colour, and a colour belongs to a part as
a parameter rather than in its name. A design ID is not stable from one mould
to the next, and Pick a Brick knows only what is on sale today - about 17,000
elements in a shop - so a library named by it would lose every part LEGO has
stopped making: seven of the 31 parts of the LEGO F1 car in
//pub/examples/lego. LEGO publishes no catalogue of its numbers either; the
open record of them is Rebrickable's, which a library named by them would rest
on anyway. And where the two numberings could agree, they already do: of the
7,950 parts LDraw numbers without a suffix, 4,474 are exactly LEGO design IDs,
and 5,011 once a mould letter is dropped.
What is worth making standard instead is the other side: how a LEGO part says
it is for sale. Here that is vendor: lego with the LDraw number as the SKU,
and a store's job to translate - and any package that declares LEGO parts the
same way can be bought at every store here. Two things would take it further.
A colour parameter on every part, once PartCAD can key a shape's cache on its
geometry and not on its colour, would let a model say what colour each brick is,
and its quote stop being the cheapest colour. And PartCAD hands a store only a
part's vendor and SKU when it asks whether it has it; were it to hand over the
part's declaration too, the index could carry each part's Rebrickable, BrickLink
and LEGO numbers, and the stores would not have to work them out.
| Path | Purpose |
|---|---|
partcad.yaml |
//pub/universe/lego; the ldraw external dependency (the library), the ldraw_repo repository, the ldraw partType, and the LEGO interfaces. |
ldraw_repo.py |
Repository plugin: categories, paginated part lists, .dat-header metadata, the interfaces each part implements, the shortcuts as assemblies, the partType, and the wrapper file. |
ldraw.py |
The :ldraw partType wrapper: fetch + recursively mesh a .dat. |
patches/ |
The maintained list of patches to LDraw files (see Patches). |
lego-demo/ |
Assemblies built purely out of those interfaces. |
# Categories (top-level sub-packages)
pc list packages //pub/universe/lego/ldraw
# The complete part list of a category (first use fetches + caches it)
pc list parts //pub/universe/lego/ldraw/Brick
# Render any LDraw part to an image
pc inspect //pub/universe/lego/ldraw/Brick:3001
# The interfaces a part implements, and the assemblies built out of them
pc info //pub/universe/lego/ldraw/Technic:3701
pc inspect -a lego-demo:technicEverything fetched from ldraw.org is cached under
~/.cache/partcad-ldraw/ (override with PARTCAD_LDRAW_CACHE):
category-list.html the category list (ldraw_repo.py)
categories/<Category>/page-N.html each part-list page (ldraw_repo.py)
parts/<id>.dat a part's header (ldraw_repo.py)
<name>.dat, 48/<name>.dat, ... each file the render reads (ldraw.py)
.missing/<same path> a fetch that produced nothing
A fetch that produced nothing is cached too, and that matters as much as the rest: ldraw.org rate-limits bursts, and meshing one part reads hundreds of files. Without a record of what did not arrive, every part re-asks for every primitive the part before it could not get, so a throttled machine never converges - it repeats the burst that got it throttled, at up to sixteen requests per file.
The two cases are not the same question, so they are not kept for the same length of time. A 404 is an answer - the library has not got this file - and is held for a week. A 429, a timeout or a reset is the absence of an answer, and is held for five minutes so the next run is a fresh attempt rather than this one again. Telling them apart is also what decides what the render says when a part will not come: "not found in the library" means the id is wrong, and "could not be fetched" means try again.
Delete the cache directory to start over; deleting just .missing/ retries
everything that failed without re-fetching what did not.
Requires PartCAD 0.8.174 or newer (partcad: ">=0.8.174" in partcad.yaml):
the first release whose assemblies take turnZ in their map:, which some
shortcuts' ports need (see Shortcuts). Because parts are fetched
on demand, first use of a part or category needs network access; afterwards the
cache is used.
The code here is Apache-2.0 (LICENSE). LDraw parts are not bundled; they
are fetched from ldraw.org and remain under their own per-part licenses
(CC BY 4.0 / CCAL), recorded in each part's .dat header. See NOTICE.