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), the LDraw part files and BlueBrick's measured connection library, cross-checked against part weights and photographs (BrickLink's stud dimensions for track parts are wrong and are not used):
| id | part | geometry |
|---|---|---|
straight |
6377 | 128 mm (8 studs) connection pitch |
curve |
6378 | 30°, R = 256 mm; 12 make a circle of 576 mm outer diameter |
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. The bridge is the exception: an arch's foot overlaps a ramp's top instead, so a ramp's top carries only an arch's foot and an arch's foot rests only on a ramp's top. An arch's crest takes the other arch, or a further ramp that climbs higher still. Ordinary track can stand on DUPLO bricks as well (a straight has three brick tubes under its middle, a curve two), so track raised on stacks may meet a crest or a ramp's foot; a completion never sets track under the floor its base stands on (how the searches keep to both).
The one soft spot 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"], "port_kinds": ["track", "ramp_top"] } ] }duplotrain solve --catalog my-measurements.json --curve 12 --ramp 2 --span 2 ...
port_kinds names how each port joins, one per port in port order: track (the
default), ramp_top or arch_foot, which mate only with each other; an override of
the ramp or the arch keeps the bridge joint only by naming them.
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}}, a multiple of 30 degrees). A
catalogue is read as untrusted input, its fields checked rather than coerced: a
file takes at most 2 MB and 64 levels of nesting, a number written as text at
most 64 characters and a decimal exponent of at most 64, every numerator and
denominator at most 512 bits, and the coefficients of an alg or chord length
at most 1,000,000 in size.
A piece has at most 16 paths of at most 64 segments, each path at most 10,000 mm
long and starting within 10,000 mm of the piece's origin along each axis; every
segment has a positive run or radius, an arc turns less than a full circle, no
two connectors of a piece share a point, and a piece is 8 to 1,000 mm wide, with
an end overhang of at most 1,000 mm. Its id is one line of 1 to 40 characters,
not blank, and its texts hold no control characters but tabs and newlines, and
no unpaired surrogates.
Python ≥ 3.12. From a checkout:
pip install -e '.[dev]' # click + rich + matplotlib + pytest + ruff
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
solve -o DIR saves the top loops as loop_01.json, loop_01.png and so on,
replacing the loop_NN files an earlier run left in DIR. The search finds the
shortest loops first, lengthening them a piece at a time from --min-pieces up to
--max-pieces (by default the whole box), and ranks the first --max-results it
finds. With reversing loops on (below), and a switch and a straight for the stone in
the box, it then looks for as many teardrops, as far as the loops went, with the
states the loops left: a teardrop is longer than the shortest loops, which would
otherwise take every place.
--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).
duplotrain check audits the geometry of every recorded joint, not just whether the
connectors have link records: it exits 1 for empty or open layouts, non-exact joints
and incompatible headings, elevations, connector plates or bridge joints, and lists
the five closest pairs of open ends that could join, ends that meet but cannot, and
open ends no other open end could join. Like every command, it exits 2 for a file it
cannot read.
--slop 5 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 checks no collisions away from the
joints. The editor recomputes joint warnings after import and reload, so an exported
forced fit cannot become an “exact” layout.
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. On a stem-tailed teardrop 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, twelve curves and a straight for the stone, no full
circle of spare track required. Switch + 12 curves make exactly three distinct
teardrop shapes, one of them stem-tailed; the solver proves it. duplotrain solve
lists only the teardrops that bring the train back — stem-tailed, with a straight on
the tail — and saves each with the stone clipped onto that straight; the library and
the GUI also offer the others, such as the branch-tailed kind, which
never returns the train to its tail.
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. The bundle is published at https://senegrom.github.io/duplotrain/
from each push or manual run on main that passes every check
(docs/editor.md); that build passes
--pages, which also embeds the content security policy as a <meta> tag because
GitHub Pages cannot send response headers.
The browser tab and Home Screen use the app's own toy-train icon. On iPhone or iPad, use Safari's Share → Add to Home Screen. The same icons are included in the local editor; see app icons for the editable source and exports.
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. On phones the canvas sits above the scrolling controls: drag to pan,
pinch or use +/− to zoom, and use the Remove tool to delete a stone or piece.
Completion cards require Preview before Apply, and inventory changes invalidate old
suggestions. A search is a resumable job that reports whether it exhausted the
inventory or stopped at its limits; one stopped at a limit never proves that no
layout exists. docs/search-jobs.md describes Find more and
Search harder, and docs/bridge-completion.md the stages.
Beyond closing loops, the editor keeps a bounded undo/redo history of track, owned pieces and sandbox mode, checks a layout for open connectors, sampled overlaps and stock shortages, saves and opens portable projects (the session plus view and search settings) with append-only local copies, and traces a test train from a chosen start through the drive model. docs/editor.md states those contracts; docs/performance.md and docs/search-correctness.md describe how the searches stay fast and why their pruning is sound.
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, the floor it stands on and the bridge's joints, and re-entering its open switch branches when they help.
solve prints a ranked table — loops standing on the floor first (a column counts
the pieces on bricks), then by score: exactness, box usage, compactness, squareness,
variety and a 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, unless the layout is
perfectly looping, a counterexample start;
it refuses a layout without drivable track, and one whose joints do not fit exactly,
which duplotrain check lists.)
Classification streams switch settings and checks the total run count before
simulation. Above the default 100,000 runs it raises ClassificationLimitError
without issuing a partial verdict, and a single run longer than 100,000 steps
raises DriveLimitError. Increase classify(layout, max_runs=...) or
duplotrain classify layout.json --max-runs ... for larger layouts; library callers
can explicitly request unbounded enumeration with max_runs=None.
Findings the simulator proves about real DUPLO:
- Any reachable open end, or buffer without a direction stone at its face, admits a doomed start (bounce off the tip, or park against the bumper), so everything from looping up requires every end mated or guarded by such a reversing terminator. 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 mid-piece on a straight and it becomes perfect — every run ping-pongs, sweeping everything both ways. (A stone at a piece's end face only turns trains running into that face, which leaves the loop one-way there.)
- 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() names that class with a
sampled key, controlled by spacing and decimals and comparable only with keys
from the same library version; for the built-in segment types, piece boundaries
and decimal rounding ties cannot change it
(why).
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)Known constructions with today's pieces include: 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)). Use classify to
check any construction and obtain a counterexample start when a property fails.
Network search. enumerate_networks() goes beyond single driving loops:
it grows connected closed networks — all connectors mated or sealed, passing-loop
and multi-cap topologies included — by extending the canonically smallest open end.
It shares the loop solver's sampled collision model, including bridge underpasses.
find_perfect_networks() checks stone placement and classification before
deduplicating an accepted centreline: a level crossing must not hide an otherwise
identical straight that can carry a required stone.
Check whether the search finished. solve() and enumerate_networks() expose
stats.complete, stats.stop_reason, and stats.max_pieces_searched. Both
find_perfect_*() helpers return a PerfectResult: it supports list operations
(indexing, iteration, len, comparison with []), and also exposes .stats. A
successful classification proves the returned candidate's behaviour; it does not
mean the search found every candidate.
from duplotrain import (
NetworkConfig, default_catalog, find_perfect_networks, IncompleteSearchError,
)
result = find_perfect_networks(
{"buffer": 2, "straight": 2, "level_crossing": 1},
default_catalog(), {"stone_direction": 2},
NetworkConfig(max_pieces=5, max_results=100),
)
print(len(result), result.stats.complete, result.stats.stop_reason)
# An optional level crossing does not hide the all-straight perfect shuttle.
try:
result.require_complete()
except IncompleteSearchError as exc:
print("Partial search:", exc.result.stats.stop_reason)
# Alternatively pass require_complete=True to either find_perfect_*() call.complete=True means exhaustion over the inventory for the searched family, with
stop_reason="exhausted". Node, result, or piece caps instead report
node_limit, result_limit, or piece_limit and leave complete=False; an
empty partial result is not proof that no qualifying layout exists. aborted is
true only for the node limit. A piece-limited search is conservatively reported as
incomplete even when all branches within that piece bound were visited.
The network helper's stone policy guards every buffer-facing connector with a
direction stone, then tries zero or one additional mid-piece stone on each eligible
straight. It does not enumerate every combination of multiple optional stones.
Exhaustion refers to that policy, the catalogue, and the sampled collision/congruence
model — closure itself remains exact. For larger inventories, compose layouts
constructively and verify their dynamics 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 every closed candidate. 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 runs in passes of growing length, each finding every loop or completion of up to that many pieces, so the shortest come first and a broad box cannot drown the search in long walks. 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), joints where two overhanging road plates would claim the same floor, and joints the bridge's parts cannot make, or in a completion track under the floor (bridge joints and the floor). A box whose curves cannot turn a full circle closes no exact loop, and a plain loop search says so at once (why). 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.
Both search modes also work backward: exact reverse-reachability tables and, for
longer tails, exact linear bounds per arrival heading reject any branch whose
remaining traversals cannot reach the closing target (a fresh loop's origin face,
a completion's selected end; for a network, another open end or a junction the
walk places), and a separate turn bound keeps a crossing from lending a curve's
turn. The pruning removes only branches without closures: exhaustive and
result-limited searches return the same solutions as without it, and the
independent collision audit still checks every returned candidate.
SolverConfig.completion_lookahead (NetworkConfig.lookahead for networks; 0
disables) sets the exact horizon; stats.pruned_completion and the other
completion_* counters report the effect (stats.pruned_reachability for networks).
docs/search-correctness.md gives the argument,
docs/performance.md the horizon
and budgets, and its benchmarks the measurements.
Elevation is modelled (ramps carry z; closure requires returning to the anchor's
height). Blanket collision clearance is 120 mm; underpass-enabled pieces
add a separate, provisional clearance rule near the bridge crest. Both search
engines apply the same rule (see duplotrain.collision and the catalogue notes).
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 (several times faster than the field; 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 — or too far away for the lattice's packed keys. Conformance tests run every solver mode on both engines and require identical solutions.
Current limits worth knowing:
solve()finds driving loops — one closed train circuit. A passing loop (both switch branch-pairs connected) is not a single circuit:solve()shows its second track as two dangling stubs, whileenumerate_networks()finds such networks. (The completion solver will happily close a hand-built passing loop's siding, too.)- 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 browser" # the non-browser suite CI runs
The full local sequence mirrors application CI, which runs on pull requests and
pushes to main (plus a clean base installation without matplotlib; the Lean
workflow checks proof changes independently):
python -m pip install -e '.[dev]'
ruff check src webapp tests benchmarks
python -m pytest -m 'not browser'
node --test tests/web/*.test.cjs
# Browser integration, including the real Pyodide worker:
python -m pip install -e '.[browser]'
python -m playwright install chromium webkit
python webapp/build.py --pages
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 browserTests marked browser need playwright plus a downloaded browser. Locally they skip
when playwright or the chosen browser is missing, the three tests of the built app
skip without DUPLOTRAIN_STATIC_DIST, the built-app boot test also skips without
openssl on the PATH, and the two WebKit offline tests run only on the opted-in
CI runner (docs/editor.md); CI sets
DUPLOTRAIN_REQUIRE_BROWSER=1, and explicit browser paths and every browser startup
failure are errors. The two-finger pinch test runs on Chromium only.
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 (perfect layouts from those searches),
topology-gallery.png and crossing-gallery.png (crossing topologies),
figure-eight.png (an eight through crossing 6376: 24 curves and 2 straights closing
as a 9.2 mm forced fit) 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.



