Skip to content

About

Model LEGO DUPLO train track and find closed layouts that loop nicely

Topics

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

1,174 Commits

Folders and files

Repository files navigation

duplotrain

Model LEGO® DUPLO® train track and find every layout that loops nicely — given the pieces you actually own.

The state law

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

Every exact loop from one starter box

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.)

Why exact arithmetic

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.

The pieces

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.

Install & use

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.

Action stones and reversing loops

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:

Reversing teardrop

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.

The designer GUI

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= ends

It 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")

Oval with a switch worked in

Driving, and the looping ladder

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.

Dogbone

Isomorphism and finding all perfect tracks

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.

How the solver works

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, while enumerate_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.

Tests

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 browser

Tests 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).

Result galleries

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.

About

Model LEGO DUPLO train track and find closed layouts that loop nicely

Topics

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages