Model LEGO® DUPLO® train track and find every layout that loops nicely — given the pieces you actually own.
The mathematics that grew out of it: how many distinct switch settings can a
single train ever visit on a layout of N lazy-point switches? Exactly
f(N) = min(2^N, N + 4), for every N.
The 2^N ceiling binds up to two switches (the teardrop and the dogbone
visit all of them); from three switches on the answer is N + 4, attained
by an explicit teardrop–chain–Gray layout. The whole law is machine-checked
as a single Lean 4 theorem, GeneralN.state_law
— no Mathlib, no native_decide, audited to exactly the three standard
axioms. theory/ is the guide.
duplotrain solve --curve 12 --straight 4 --use-all -o out
That picture is the complete answer for a starter box (12 curves + 4 straights): up
to rotation, reflection and starting point, exactly four closed layouts use the whole
box, and the solver proves it by exhaustion — in exact arithmetic, so "closed" means
closed, not "closed to within some epsilon". (Drop --use-all and the sub-loops
that leave straights in the box are reported too.)
A DUPLO curve turns 30° with a 256 mm centreline radius, and one curve advances exactly
one straight length (128 mm) while kicking sideways by 256 − 128·√3 mm. That √3
means loop-closure positions are irrational: test them with floats and a tolerance and
you will eventually bless a layout that is millimetres open, or reject one that is
perfect. All geometry here lives in the field ℚ(√2, √3) (four exact rational
coefficients per coordinate), headings live on a 24-step lattice of 15°, and a loop is
closed if and only if the end pose equals the start pose.
Real DUPLO joints do have designed-in play (~1 mm each), and some classic builds rely
on it — the famous R,R,L,L lane-change is 4.59 mm short of its nominal length. Pass
--slop 6 and the solver will admit such forced fits too, always reporting exactly
how many millimetres the joints must absorb. It never mislabels them as exact.
Geometry for the modern light-grey system (2018+, sets 10874 / 10875 / 10882), sourced from measured surveys (Cailliau, duplo-schienen.de, onemetre.net) and the LDraw part library:
| id | part | geometry |
|---|---|---|
straight |
6377 | 128 mm (8 studs) connection pitch |
curve |
6378 | 30°, R = 256 mm; 12 make a 576 mm circle |
switch |
51943c01 | left + right 30°/R256 branches off one stem (LDraw-exact); no straight route |
crossing |
6376 | two 128 mm straight runs crossing at their midpoints, 60° |
level_crossing |
6391 | one straight under a 160 × 160 mm road plate (16 mm end overhang — two of them refuse to mate) |
ramp |
6392 | 320 mm run rising 57.6 mm (3 bricks) |
span |
6393 | 192 mm arch rising a further 19.2 mm to the 76.8 mm crest |
buffer |
35967 | 64 mm track-end bumper; its far face is sealed and can never mate |
slope |
35966 | 256 mm "slight slope" from 10875 (rise ~5.6 mm, unverified) |
offramp |
4785 | 96 mm off-ramp to the floor from 10425; its floor side is sealed (provisional) |
Connectors are genderless (a jigsaw tab and socket at every end), so any end mates with any end and one physical curve serves as both the left and the right turn — the model gets that for free by letting pieces be entered from either port.
These numbers were settled by parsing the LDraw part files and BlueBrick's measured connection library, cross-checked against part weights, photographs and duplo-schienen.de's combination rules (BrickLink's stud dimensions for track parts are demonstrably wrong and were rejected). The one soft spot left is the bridge's vertical split — derived from brick-integer constraints and part heights rather than a published figure. Override any piece — or add entirely new ones — with a JSON catalogue, no code changes:
{ "pieces": [ { "id": "ramp",
"name": "Bridge ramp (my callipers)", "category": "bridge", "width": 64,
"paths": [ { "segments": [ { "type": "ramp", "run": 320, "rise": 60 } ] } ],
"port_names": ["low", "high"] } ] }duplotrain solve --catalog my-measurements.json --curve 12 --ramp 2 --span 2 ...
Lengths may be plain numbers, exact fractions ("384/5"), field elements
({"alg": [a, b, c, d]} = a + b√2 + c√3 + d√6), or arc chords
({"chord": {"radius": 256, "degrees": 30}}).
Python ≥ 3.12. From a checkout:
pip install -e .[dev] # click + rich + matplotlib + pytest
CLI:
duplotrain pieces # the catalogue
duplotrain sets # known boxed sets
duplotrain solve --set 10874 --set 10882 -o out # "we own these boxes"
duplotrain solve --curve 12 --straight 4 --switch 2 -o out
duplotrain solve --inventory mybox.json --slop 5 -o out
duplotrain check out/loop_01.json # closure report
duplotrain render out/loop_01.json -o picture.png
duplotrain gui # interactive designer
duplotrain demo # the classic oval
--set knows the 2018 wave (10874 Steam Train, 10875 Cargo Train, 10872 Bridge &
Tracks, 10882 Track pack) and the 2024 sets (10425 Tunnel, 10426 Bridge expansion)
with per-set piece counts from the published inventories — repeat a flag to own a
set twice. Sets also contribute their action stones (below).
The coloured inserts that clip onto a straight are modelled as accessories: red stop, yellow horn, blue refuel, white lights, and — the interesting one — green direction-change. A layout doesn't have to be a plain closed loop to run forever:
With reversing_loops enabled (--reversing, on automatically when your --sets
include the green stone, or the checkbox in the GUI) the solver also proposes
teardrops: the walk closes into the switch's other branch instead of back on
itself. The train always exits through the stem, bounces off the direction stone on
the tail, comes back in and trails through the points — endless running from one
switch and twelve curves, no full circle of spare track required. Exactly three
distinct teardrop shapes exist for switch + 12 curves; the solver proves it.
In the GUI, stones are armed from their own palette and clipped onto straights with a click; buffers cap open ends (their bumper face draws as a bar and is not clickable); layouts count as closed when every real connector is mated — a buffered siding is finished, not dangling.
Browser build: the editor can run fully client-side using the identical Python
engine under Pyodide. webapp/build.py produces the static bundle; nothing leaves
the browser.
duplotrain gui opens a local track editor in your browser (standard library server,
nothing to install). Because DUPLO only ever connects on the exact lattice there is no
freeform dragging: arm a piece variant in the palette, click a red open-end arrow and
it snaps on. Junction stubs are clickable ends like any other, piece counts come from
your editable inventory, and Close the loop hands the layout to the completion
solver — candidates are listed with their gap (exact or forced), preview as ghosts on
hover, and apply with a click. Export/import round-trips the same exact-geometry JSON
the CLI uses.
The same completion search is available as a library call — "I built this much by hand, close it for me":
result = solve({"curve": 18, "straight": 8}, pieces,
SolverConfig(min_pieces=1, slop=2.0),
base=my_layout) # optionally grow_from=/close_onto= endsIt keeps every placed piece where it is, grows from one open end, and reports every way to reach the other — respecting collisions with the existing track and re-entering its open switch branches when they help.
solve prints a ranked table (exactness, box usage, compactness, squareness, variety,
dangling-branch penalty — weights overridable in duplotrain.scoring) and writes a
PNG + JSON per kept layout. Layout JSON stores frames as exact coefficients, so a
reloaded layout still passes the exact closure test.
Library:
from duplotrain import default_catalog, solve, SolverConfig, render_layout
pieces = default_catalog()
result = solve({"curve": 12, "straight": 4, "switch": 1}, pieces,
SolverConfig(max_results=50))
best = result.solutions[0] # .layout, .exact, .gap, .open_stubs
render_layout(best.layout, "loop.png")Geometry closing is necessary, not sufficient: switches have state. duplotrain.drive
simulates a train with real points semantics — a facing move (in at the stem)
follows the tongue; a trailing move (in through a branch) pushes through and
forces the tongue to the branch it came from, like the real unsprung DUPLO points.
Direction stones bounce the train once per pass; stop stones park it; buffers and open
ends end the run. Every run is provably periodic (finite state), so
classify() decides, by exhaustive simulation over every starting position, direction
and initial tongue setting, where a layout sits on the ladder:
| level | meaning |
|---|---|
| locally looping | some placement runs forever |
| looping | every placement runs forever |
| completely looping | …and every run covers the whole track |
| perfectly looping | …sweeping every tile in both directions, infinitely often |
(duplotrain classify layout.json prints the verdict and a counterexample start.)
Findings the simulator proves about real DUPLO:
- Any reachable open end or buffer admits a doomed start (bounce off the tip, or park against the bumper), so everything from looping up requires a fully mated layout. Teardrops are therefore locally looping only — wonderful, but keep the toddler from placing the loco at the very tip.
- A plain loop is completely but never perfectly looping: runs are one-way. Clip one green direction stone anywhere on it and it becomes perfect — every run ping-pongs, sweeping everything both ways.
- Teardrops come in two flavours: stem-tailed (the classic — every pass trails
the points, the tongue alternates, the train alternates lobes) and branch-tailed
(a one-way trap that absorbs the train into its circuit).
is_stem_tailed()tells them apart; only the first composes further. - Join two stem-tailed teardrops through their tails and you get the dogbone — perfectly looping with no stones at all: the lobes themselves reverse the train.
Two layouts count as the same when their track centrelines are congruent curves in
space (rotations, translations, reflections; z included, so bridges distinguish) —
which straight carries the stone, or whether a level crossing stands in for a plain
straight, doesn't change the curve. congruence_key() canonicalises the sampled
centreline over the 24 lattice rotations × reflection, so non-isomorphic hunting is a
set of keys:
from duplotrain import default_catalog, find_perfect_loops, SolverConfig
pieces = default_catalog()
perfect = find_perfect_loops({"curve": 12, "straight": 4}, pieces,
SolverConfig(use_all_pieces=True, max_results=100))
# -> 4 non-isomorphic perfectly looping tracks (oval, two parallelograms,
# rounded square -- each with one direction stone clipped on)With today's pieces, perfection has exactly three known sources: a closed loop plus a
direction stone; a reversing terminator (direction stone at a buffer face — every
approach bounces off the wall) capping the ends of otherwise-open track (shuttles,
capped teardrops); or reversing topology (dogbones — build them with
make_dogbone(pick_stem_tailed(solutions, pieces), pieces)). Everything else tops out
lower on the ladder, and classify will tell you why, with the exact doomed start.
Exhaustive network search. enumerate_networks() goes beyond single driving
loops: it enumerates every closed network — all connectors mated or sealed,
passing-loop and multi-cap topologies included — by always extending the canonically
smallest open end (attach, or join two ends that mate), with end-aware collision
handling and congruence dedup. find_perfect_networks() then tries the sensible
direction-stone placements (buffer faces are forced; one mid-loop stone otherwise)
and classifies each, so for bounded sizes the question "what are ALL the perfectly
looping networks from this box?" is answered by proof, not by taste. Costs are
honest: exhaustion is practical to roughly a dozen pieces (a 14-piece hunt is a
few minutes); beyond that, compose constructively and verify with classify.
The switch dynamics yields a little theorem the machine confirms by exhaustion: a dead-end cap reflects a train back through the branch it came from, so a trailing pass re-aims the tongue at that same branch and a facing return retraces it — caps keep the tongue sticky. Only a lobe (branch-to-branch loop) alternates the tongue. Hence a 3-armed star of capped arms ping-pongs between two arms forever (looping, never completely), and no perfect one-switch network exists without curves — checked over all 27 closed candidates. Perfection needs rotation somewhere: a lobe, or a stone in a ring.
Depth-first search that walks track outward from an anchored origin, over the geometrically distinct traversals of each piece type (a straight contributes one move, a curve two — its left and right readings), trying homeward moves first so small gaps close promptly. It prunes, conservatively: headings that the remaining pieces cannot swing back to the anchor's, positions they cannot reach home from, placements that overlap existing track (respecting elevation, so a sufficiently high bridge legitimately crosses over), and joints where two overhanging road plates would claim the same floor. Switches drop open stubs which the walk may later re-enter exactly — figure-eights and re-joining branches emerge from that rule alone. Found loops are deduplicated by a canonical signature invariant under rotation, reversal and reflection — the mirror image is generated explicitly per piece from its geometry, since walking a chiral loop backwards is not its mirror image.
Elevation is modelled (ramps carry z; closure requires returning to ground), and the
collision clearance defaults to 120 mm — a DUPLO loco is ~100 mm tall, and the stock
bridge crests at 76.8 mm, which is why the real one only passes toy cars underneath.
Arithmetic engines. Exactness doesn't require Fractions: every real piece turns in 30° steps and measures in twentieths of a millimetre, so positions live in the scaled cyclotomic ring (1/20)·ℤ[e^{iπ/6}], where rotation is an integer 4×4 map. The solver compiles the problem for this integer lattice engine automatically (~6× faster: 1.8k → 10k nodes/s natively; it's what makes the browser build usable) and falls back to the general ℚ(√2,√3) field for anything off-grid — a user piece on the 45° lattice, say. Conformance tests run every solver mode on both engines and require identical solutions. If far bigger searches ever matter (v2 multi-cycle at scale), the same seam is where a Rust core would slot in.
Current limits worth knowing:
- The solver finds driving loops — one closed train circuit. A passing loop (both switch branch-pairs connected) is not a single circuit and needs the planned multi-cycle search; today the second track shows up as two dangling stubs. (The completion solver will happily close a hand-built passing loop's siding, though.)
- With the measured crossing geometry, no figure-eight closes within 6 mm from 12–16 curves (± straights): the solver instead uses the crossing straight-through with its other route dangling. Blame the lattice, not the box.
- Collision checking is sampled discs along centrelines: exact-touch parallel tracks are legal, sub-2 mm grazes may slip through.
python -m pytest # everything installed browsers allow
python -m pytest -m "not slow" # skip the ~2 min full-enumeration proofs
python -m pytest -m "not slow and not browser" # what application CI runs first
Tests marked browser need playwright plus a downloaded browser. Missing defaults
skip locally; explicit browser paths and all browser startup failures in CI fail. See Editor recovery and application checks below for the
full local command sequence.
The suite covers the number field, the pose lattice, piece derivation, the documented
geometric identities (the L,R,R,L snake equals four straights exactly; R,R,L,L
misses by exactly 448 − 256√3 ≈ 4.59 mm), serialisation round-trips, collision
regressions, the CLI, the GUI's HTTP API, completion mode, and solver ground truths
(12 curves make exactly one circle; the starter box makes exactly four shapes; 12
curves + 6 straights make exactly 18; a deliberately stretched piece closes only as a
forced fit reporting exactly its 2 mm gap).
Rendered output of the exhaustive searches, in docs/:
perfect-gallery.png, perfect-gallery-2.png, perfect-gallery-switches.png and
perfect-gallery-wild.png (every perfect layout the classification admits),
topology-gallery.png and crossing-gallery.png (crossing topologies),
figure-eight.png (the minimal exact eight through crossing 6376) and
flyover-eight.png (the bridge flyover eight, closed exactly from one owned set).
Not affiliated with the LEGO Group. LEGO and DUPLO are trademarks of the LEGO Group.
The editor automatically saves exact layout geometry, owned track and stone counts, and sandbox mode in this browser's local storage. A fresh engine restores that session; an already-running local server keeps its newer session. Storage is device/browser-local, not a cloud backup. Export JSON for a portable copy of the layout. Storage or recovery errors are shown without overwriting an unreadable checkpoint. Clear remains undoable within the current session; undo history is not persisted across engine restarts.
On phones, the canvas sits above the scrolling controls. Drag to pan, pinch or use +/− to zoom, and use the visible Remove tool to delete a stone or piece. Completion cards require Preview before Apply. Inventory changes invalidate old suggestions.
Completion searches report whether the inventory was exhausted or a node, result or piece limit stopped the search. The editor starts at 26 added pieces; Search deeper increases that limit, up to 128. An unsuccessful capped search is not a proof that no layout exists. Arc shortcuts and staged searches are explicitly non-exhaustive.
Run application checks locally:
python -m pip install -e '.[dev]'
ruff check src webapp
python -m pytest -m 'not slow and not browser'
node --test tests/web/*.test.cjs
# Browser integration, including the real Pyodide worker:
python -m pip install playwright
python -m playwright install chromium webkit
python webapp/build.py
DUPLOTRAIN_STATIC_DIST="$PWD/webapp/dist" python -m pytest tests/browser -m browser
DUPLOTRAIN_BROWSER=webkit DUPLOTRAIN_STATIC_DIST="$PWD/webapp/dist" python -m pytest tests/browser -m browserApplication CI runs these checks on pushes and pull requests, including a clean base installation without matplotlib. The independent Lean workflow checks proof changes. Package license metadata reads the repository's LICENSE file (GNU AGPL v3), rather than declaring an inconsistent MIT license.
Autosave checkpoints carry unique revisions. Web Locks serialize cross-tab writes; a tab that detects another writer pauses autosave and asks you to export its work before reloading. Redrawing or closing a stale tab never rewrites a newer checkpoint. Previous saves migrate read-only into a new storage key, isolated from tabs still running the old editor. When safe locking or storage is unavailable, the editor warns you to export instead. Saves remain browser-local, not a backup service.
duplotrain check layout.json checks the geometry of every recorded joint, not
just whether all connectors have link records. It exits 1 for empty/open layouts,
non-exact joints, and incompatible headings, elevations, or connector plates.
--slop 5 explicitly accepts up to 5 mm of total planar joint gap in a fully
linked layout, with a forced-fit warning; it never excuses elevation or heading
errors and is not a physical-fit guarantee. Collisions away from joints are not
checked by this command. The editor recomputes joint warnings after import and
reload, so exported forced fits cannot silently become “exact” layouts.
Browser CI requires Playwright and its binaries (DUPLOTRAIN_REQUIRE_BROWSER=1).
Only absent, unconfigured defaults may skip locally; crashes and bad custom paths
are errors. Dependabot checks Python and GitHub Actions dependencies weekly.



