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 testThat 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.
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.
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 itPacking prints a brick and step count computed independently of the runtime, so a mismatch between the two is caught rather than silently losing parts.
| 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.
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.
pnpm ldraw:palette # public/parts/palette.mpd + palette.jsonscripts/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.
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.
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.
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.
| 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.
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.
pnpm test # 558 tests across 34 files, about six seconds
pnpm test:watch
pnpm test:coverage # writes coverage/coverage-final.jsonMost 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 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.
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
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.