Skip to content

Repository files navigation

LDBuilder

Load an LDraw model, tip the bricks onto the floor, and watch it assemble itself one build step at a time. Explode the finished model, slice it open layer by layer, isolate a submodel, or click any single brick to find out what it is.

Or build it yourself. In build mode the bag lands as a live physics pile you can dig through, shove and throw pieces out of. Each step lights up the slots it needs, and a piece dropped near the right one clicks into place. Progress is saved in the browser, so a set with eight hundred steps can be picked up where you left off.

Or build something nobody designed. Free build is a floor, 194 parts in any colour the library defines, and no instructions. Stack them on the stud grid and export the result as an LDraw file you can open anywhere else.

Next.js 16 and three.js. No accounts, no server-side rendering of anything heavy, no parts library needed to run it.

pnpm install
pnpm dev
pnpm test

That works straight from a checkout, because the bundled models are committed as self-contained files. You only need the setup below to pack new models.

Why models are packed

An LDraw .ldr file is not a model. It is a list of transforms pointing at part files (3001.dat) that live in the official parts library, a 138 MB download of about 36,000 files. LDrawLoader resolves each reference by trying parts/, then p/, then models/ in turn, so serving that library over HTTP means hundreds of requests per model, most of them 404s. A build-time packer inlines a model and every part it uses into one self-contained .mpd instead.

The subtle part is naming. LDrawLoader normalizes every reference before it looks it up, and keys its embedded-file cache on the lowercased result:

reference in the file what the loader looks up
3001.dat 3001.dat, searched as parts/3001.dat
stud.dat stud.dat, searched as p/stud.dat
s\4315s01.dat parts/s/4315s01.dat
48\1-4edge.dat p/48/1-4edge.dat
8\1-4cyli.dat 8/1-4cyli.dat, searched as p/8/...

The 0 FILE name in the packed output has to be the normalized reference string, not the path the file sits at on disk. Get it wrong and the loader silently renders nothing for that brick. scripts/lib/ldraw-pack.mjs holds the rules.

Unresolved references are a hard error for the bundled models. For a file somebody drops in they are a warning: the missing lines are stripped, the rest builds, and the viewer is told which parts are absent.

Working with models

pnpm ldraw:setup     # download + extract the parts library (138 MB, gitignored)
pnpm ldraw:colors    # LDConfig.ldr -> src/ldraw/colors.generated.ts
pnpm ldraw:pack      # pack the curated models into public/models
pnpm ldraw:palette   # pack the free-build parts into public/parts
pnpm ldraw:demos     # regenerate the generated demo model, checking it is buildable
pnpm ldraw:index     # rescrape the searchable OMR set list

pnpm ldraw:pack path/to/some-set.ldr    # add one more to the gallery
pnpm ldraw:pack x.ldr --skip-missing    # build it without the parts it is missing
pnpm ldraw:omr 928 21309                # check an official set exists, and who built it

Packing prints a brick and step count computed independently of the runtime, so a mismatch between the two is caught rather than silently losing parts.

Bundled models

Model Bricks Steps What it covers
Example Pyramid 13 4 The smallest thing with a real build order
Example Car 61 8 Authored steps, 26 different parts
Gatehouse 128 24 Submodels staged off-model: four towers and a span
928 Galaxy Explorer 368 53 A real set, 53 steps as its author wrote them
21309 NASA Apollo Saturn V 1,845 775 30 bags, deep submodels, the scale case

The two examples ship inside the LDraw library. The gatehouse is generated by scripts/make-demos.mjs to cover the case the library samples do not, a model made of submodels. The two official sets come from the OMR.

Official sets

The LDraw Official Model Repository holds around 1,470 official sets, each built by a named author. Every file carries a licence line:

0 !LICENSE Redistributable under CCAL version 2.0

CCAL 2.0 is the deprecated spelling of CC BY 2.0, the same Creative Commons Attribution licence as the parts library, so OMR sets can be committed here provided their author is credited. Files found elsewhere carry no stated licence, so none are redistributed in this repo. scripts/lib/omr.mjs reads the header, refuses anything without it, and lifts the author and theme out for the gallery card. The author shows wherever the set does: on the card, and in the viewer's header once it opens.

Two sets ship with the app. The rest open through /api/omr/[set], which proxies the OMR (it serves no CORS headers) and packs the set on the way through. Search runs against public/omr-index.json, 1,470 sets with name, theme and year, 19 KB fetched on first interaction. The OMR has no API and no directory listing, so pnpm ldraw:index scrapes the 59 pages of its set list and a monthly workflow re-runs it. A set number typed in full opens whether or not the index knows about it.

The free-build palette

pnpm ldraw:palette   # public/parts/palette.mpd + palette.json

scripts/lib/palette-select.mjs picks parts by rule rather than from a written list of part numbers, reading each part's own description. Most of the library's 20,000 real parts are printed variants, Duplo moulds or licensed minifigures, and it gains more every release. The rules yield 194 parts across nine groups: 1.9 MB packed, 280 KB over the wire, loaded only when the sandbox is opened.

Every part is packed with colour 16, LDraw's "inherit from whoever used me", so one copy of the geometry serves every colour. The runtime picks one per instance by redirecting the two materials that stand for an inherited surface and an inherited edge.

Bringing your own

Drag an .ldr or .mpd anywhere onto the page. A self-contained .mpd opens straight from the browser with no server involved. A raw .ldr is posted to /api/pack, which resolves its parts the same way /api/omr does. Unofficial parts are skipped with a warning naming what is missing, and only a model where nothing resolves is refused.

Where parts come from at request time

36,600 files and 612 MB does not fit in a serverless bundle, so src/server/parts-resolver.ts picks a source at startup:

used when speed
the local library pnpm ldraw:setup has been run instant
the network anywhere else, including every deployment 10-30 s cold, then cached

The network resolver tries a jsDelivr-hosted mirror first and falls back to library.ldraw.org for anything the mirror lacks. The mirror is a CDN and does not rate limit, while the library starts returning 429 at four concurrent requests, and a single set is roughly 400 lookups. The mirror is an older snapshot, so the two sources do not produce byte-identical output, but neither drops a brick.

Packed sets are returned gzipped (about 6.4:1, so a 3.8 MB set is 612 KB on the wire) with a one-year s-maxage. LDRAW_PARTS_SOURCE=network exercises the deployment path on a machine that has the library installed.

How it works

Flattening. A brick flies from the floor to its place in the model, so its transform has to be absolute rather than relative to a submodel that is itself moving. src/ldraw/flatten.ts cuts the loader's group tree into a flat list and keeps the submodel structure as data. A brick is any group whose 0 !LDRAW_ORG type is Part or Shortcut, and matching stops the descent, since a Shortcut contains real Part files.

Build steps. Real 0 STEP metas are taken as written. A file with none gets an order inferred from how the model stacks up, kept per submodel and labelled inferred.

Subassemblies. An LDraw file records every brick at its position in the finished model, so a submodel replayed literally assembles itself in mid-air. A submodel occurrence of five to forty bricks, built over more than one step and not standing on the ground, is displaced clear of the model while it is built and slides in on the step its last brick goes on. The displacement is a pure translation, the cheapest of the five ways out of the model's silhouette. Building by hand is left alone.

Bags. The build splits into contiguous runs of steps of about 110 bricks, cut on submodel seams where there are any. Future bags never enter the scene graph, and each bag lands on the side of the model it builds.

The drop is simulated, then baked. Bricks fall with rapier, so they collide and settle at whatever angle they land at. The simulation runs once when a bag opens, records every transform on every step, and plays the recording back. That gives the same pile from the same seed, resting poses known before the animation starts, which the camera framing and the scrubber both need, and playback that is an array lookup. A 110-brick bag settles in about 60ms; the recording is roughly 500 KB. If the physics module fails to load, a scripted fall stands in.

One trap: rapier's thresholds assume a world measured in metres and LDraw units are 0.4mm, so the solver quietly clamps velocity and bricks fall at a constant speed after a dozen frames. Setting world.lengthUnit to the drop height restores a real parabola.

Rapier's compat build inlines its WebAssembly as base64, a 2.7 MB chunk, so it is dynamically imported and absent from the gallery and the builder's initial HTML.

Free build. The library carries no stud-to-tube connectivity data. It does guarantee the grid: 20 units between studs, 8 for the height of a plate, three plates to a brick. Snapping to that grid and resting each part on whatever is under it gets stud-accurate building out of geometry that is already there.

A part's origin is at the middle of its footprint, so a 2x4 brick straddles grid lines and a 1x1 sits on the middle of a stud. Rounding without accounting for that puts every odd part half a stud out. Height comes from resting rather than rounding, because a slope is not a whole number of plates tall.

Rotations are held as quarter turns rather than a quaternion: exact however many times a part is turned, small to save, and clean integers on export.

Exporting. A .ldr is a list of type-1 lines, each one a colour, a 3x3 rotation, a translation and a part. The app turns every part upright on load, so writing one back out turns it down again. That turn is a half turn about X, which is its own inverse.

Performance. Two thresholds, both measured.

  • Above 800 bricks, vertex normals stay flat. Smoothing dominates the parse: 14.9s of a 15.3s load on a 4,209-brick set, against 1.4s with it off.
  • Above 1,500 bricks, edge lines and shadows go. Every part carries its own line and conditional-line object, which makes them most of the draw calls. Dropping them took the same set from 10,921 calls to 3,797, and 36 fps to 60.

Build mode. The pile is whatever you have done to it, so src/scene/liveWorld.ts runs the solver every frame. Only one bag is ever loose, so the body count tops out around 170. A settled pile sleeps and the placed model is static colliders with no bodies at all. Measured at 120 fps on a 368-brick set with 68 bricks on the floor.

A held brick is kinematic rather than dynamic: it shoves the pile and never gets shoved. Letting go hands the tracked hand velocity to a dynamic body, so a flick throws. Slots match by part and colour rather than identity, since a bag holds eight identical 1x2 plates; placing swaps the two records' objects and bodies, so nothing on screen changes and every record still owns exactly one brick.

A carried brick rides a horizontal plane at the height of the slots being filled, not a camera-facing one, and the wheel raises and lowers it. Two presses on the same brick send it home, which is what keeps the mode playable on a trackpad.

Saved builds. localStorage, one entry per model: the step, the filled slots, and where the loose bricks are lying. The pile costs the most to store and is kept anyway, since re-pouring would throw away the sorting you have already done. Every read is defensive and every write may fail. A save is checked against the model's brick and step counts, so repacking a model invalidates it rather than corrupting it.

State. Anything worth sharing or keeping across a refresh lives in the URL via nuqs: ?flow=, ?step=, ?mode=, ?explode=, ?slice=, ?sub=, ?sel=. Build progress is too big for a URL and lives in localStorage. A free build is the same, minus the model to check it against; it keeps part names instead, and a part that has since left the palette is dropped on the way back in. step counts steps already finished, so 0 is an untouched pile. Per-frame playback state stays in the scene controller and never round-trips through React, which is why the code drives three.js directly instead of react-three-fiber.

Controls

W A S D / arrows Move the camera across the floor
Q / E Move down and up
Shift Move faster
Drag / scroll Orbit, zoom
Click a brick Inspect it
Space Play or pause the build
[ ] or , . Step backwards or forwards
Escape Clear the selection
Frame Toggle between framing the table and framing the model

In build mode:

Drag a brick Pick it up and carry it
Scroll while carrying Raise or lower it
Flick and release Throw it
Press a brick twice Send it to its slot
F Highlight the pieces this step still needs

In free build:

1-9 Reach for a hotbar slot
Click Put down what is in hand, or pick up what is under the pointer
R / Shift R Turn a quarter circle
T Tip on its side
Arrows Nudge a stud at a time
PgUp / PgDn Raise or lower by a plate
Esc Put it back
Del Throw it away

The arrows belong to the camera until something is being carried. Movement pans the camera and its orbit target together, and speed scales with how far the camera is from what it is looking at. Once you have moved the camera yourself, opening a new bag stops pulling the view back to it; Frame hands that back. The full list lives behind the ? in the View panel.

Colour

Blue means selection and progress. Yellow means something is off but usable, like an inferred build order or a missing part. Red means a failure. Yellow never marks a selection.

Light and dark, via next-themes with attribute="class". Every text and fill pairing is checked against WCAG AA in the worst case as well as the ordinary one. Panels are 92% opaque, so the figure in brackets is the same text with a white brick showing through behind it. The numbers are for the dark theme.

Token On a panel Job
ink #eceef2 15.4:1 (12.3:1) Primary text
muted #b3bac6 9.2:1 (7.3:1) Secondary text
faint #8d95a2 5.9:1 (4.7:1) Labels and readouts, the smallest text here
accent-lit #6fb2f5 8.0:1 (6.4:1) Links, icons, progress
warn #f5c518 11.0:1 (8.8:1) Inferred steps, missing parts
danger #f2626a 5.8:1 (4.6:1) Failures

The blue splits in two because a blue light enough to read as text over graphite is too light to carry white text on top of it. accent #1c6bd6 is the fill: white on it is 5.1:1, and it sits 3.2:1 against a resting button. Hover goes darker, because lighter drops white text below 4.5:1. On the light theme warn becomes a dark amber #8a5a00, since #f5c518 on white is 1.7:1.

The canvas reads the same stylesheet rather than keeping a second copy of the palette. globals.css exposes --scene-* custom properties and SceneController.applyTheme() reads background, grid, shadow strength and environment intensity off the root element with getComputedStyle.

Tests

pnpm test              # 558 tests across 34 files, about six seconds
pnpm test:watch
pnpm test:coverage     # writes coverage/coverage-final.json

Most run on node and cover pure logic: the packer's naming rules, the resolver's fallback chain and caching, step synthesis, subassembly staging, bag partitioning, search ranking, the OMR scrapers, the build state machine, the saved-game store and the slot ghosts. three.js and rapier work headless as long as nothing asks for a WebGLRenderer, so the physics tests run the real solver and the scene controller runs in jsdom against a renderer stub that draws nothing. A suite opts into a DOM with a @vitest-environment docblock; the rest stay on node and stay fast.

One suite checks the committed models rather than the code: every .mpd in public/models must be self-contained and must still match the brick, step and part counts in the manifest. That catches a bad re-pack, which is otherwise invisible until someone opens the model.

pnpm audit runs the suite with coverage first, because fallow reads coverage-final.json to score CRAP. Without it every function counts as untested.

CI

CI runs lint, types, tests and the build on every pull request and on pushes to main. The checks use !cancelled() rather than stopping at the first failure, so one run tells you everything that is broken. fallow gates there too, on what a change introduces rather than what it inherits; pnpm audit runs the same check locally against the last commit.

Layout

scripts/           setup, packing and demo generation (plain ESM, shared with /api/pack)
src/test/          fixtures and stubs shared by the unit tests
public/models/     committed self-contained .mpd files + manifest
public/ldraw/      LDConfig.ldr, the colour table
demo-models/       source for the generated demos
public/parts/      the free-build palette: one .mpd plus its catalogue
src/ldraw/         loading, flattening, steps, subassemblies, bags, layout
src/scene/         renderer, assembly state machine, live physics, build rules
src/components/    viewer stage and HUD panels

Legal

Licensing is split, and LICENSE is the full version. The short of it: the code is MIT, and the packed .mpd files under public/ are not, because they carry other people's CC BY work and are not this project's to relicense. Dependency notices are in NOTICES.md, regenerated by pnpm notices so they cannot drift from what is installed.

LEGO® is a trademark of the LEGO Group of companies which does not sponsor, authorize or endorse this site. This is an unofficial, non-commercial project with no affiliation to the LEGO Group.

LDraw™ is a trademark owned and licensed by the Estate of James Jessiman.

The LD prefix in the name is deliberate, and so is the absence of LDraw. Quality, Brand, and the LDraw Name asks that LDraw not appear in a program's title or URL, since LDraw.org licenses the mark from the estate rather than owning it, and suggests the prefix instead. That is where LDView and LDCad get their names, and this one.

Part geometry and colour definitions come from the LDraw Parts Library, used under CC BY 2.0 and CC BY 4.0 depending on the part. See ldraw.org legal info.

Both licence versions are named because both ship. CAreadme.txt in the library sets out which is which: parts submitted or edited on or after 2023-03-05 carry CC BY 4.0, older ones carry CC BY 2.0 or both, and CCAL version 2.0, the line the OMR models use, is a deprecated spelling of CC BY 2.0. Most of the geometry on screen is CC BY 4.0, so crediting only 2.0 would name the wrong licence for it.

Modified. The same document asks that a derivative note whether it changed the work, and draws the line in a place this project sits on the far side of: a model that references library parts is not a derivative work, while one that includes their source "in any form" is. Packing inlines part files, so every .mpd in public/ is a derivative. It is a lossless one, and says so in its own header:

0 // Repacked for the web. Every part file this model references is inlined
0 // below and its name normalised to the key LDrawLoader looks it up by. No
0 // geometry is altered and no header is dropped [...]

No block loses its 0 Author: or 0 !LICENSE line on the way through, so the attribution each part carries arrives with it. The footer repeats it on every route, and the viewer names whoever built the model.

A file exported from free build is the opposite case: it references parts and embeds none, so it is not a derivative work, and it goes out with no 0 Author: and no 0 !LICENSE line. Those belong to whoever built it, not to this app.

About

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages