How bearings grows a road network from a smooth field of directions — the idea, the algorithm, and the live controls now in the app. Every demo on this page runs the real algorithm.
The whole idea fits in one sentence: define a smooth field that says which way streets want to run at every point, then trace evenly-spaced curves through it. Everything else — seeding, separation grids, cleanup passes — is bookkeeping to make the traced curves behave like a street network.
This pipeline is live in bearings today, reimplemented in the C++/WASM core (from the paper, not the LGPL reference code) and wired straight into the existing block-and-parcel machinery:
RoadGraph, then planarized, welded, and trimmed (§5).shared with classic pipelineStages 1–2 are the new work (src/cpp/tensor.h, basis_field.h, basis_tensor_field.h, streamline_tracer.h); stage 3's entry point is generateFromField() in bindings.cpp, which swaps in for the per-PlaceType road generator and hands everything downstream unchanged. The algorithm is from Chen et al. 2008, Interactive Procedural Street Modeling (reference implementation: ProbableTrain's MapGenerator). §7 is the hands-on tour of the in-app controls.
A street has an orientation but no forward direction — north-heading and south-heading are the same road. An ordinary vector can't express that, so the field stores a tensor: a 2×2 traceless symmetric matrix, kept as just two numbers, [cos 2θ, sin 2θ], scaled by a magnitude r.
The doubled angle is the whole trick. Because θ and θ+180° give the same 2θ point on the circle, opposite directions collapse into one value — and averaging two tensors blends orientations sensibly where averaging raw vectors would cancel. Each tensor has two eigenvectors: the major axis at θ and the minor axis at θ+90°. One family of streets follows major, the cross streets follow minor. Orthogonal grids fall out for free.
The global field is a weighted sum of primitive basis fields — these are the focal points you place and drag on the map in bearings. There are exactly two kinds (basis_field.h):
Grid — a constant orientation θ everywhere (the orange square handle). Radial — orientations circling a centre; its minor family gives the radial spokes (the magenta circle handle). Each basis field has a centre, a size (its reach — the dashed ring around a focal point) and a decay exponent; its weight at distance d is (1 − d/size)^decay, clamped to zero beyond size. Decay 0 is a hard-edged disk of influence; higher decay concentrates influence at the centre. Sampling a point means: sum every basis tensor times its weight. All three knobs — size, decay, θ — are the sliders you get when you select a focal point in the app (§7).
Where the summed tensor's magnitude hits zero the field is degenerate — no defined direction, so streamlines stop. That happens where opposing fields cancel, and (in this demo's formulation) anywhere outside every field's reach. Drag the two centres below into and out of overlap and watch the blend and the dead zones.
r = 2 after adding), so field cancellation never produces a degenerate point there. Bearings keeps the summed magnitude, like the demos on this page — cancellation-degeneracy is real, and it matters in practice: where two fields fight, streamlines genuinely stop, which is one of the ways open cells and near-miss endpoints arise. The graph passes in §5 exist partly to absorb exactly that.A streamline is traced with a numerical integrator — RK4 in bearings (streamline_tracer.h) — stepping dstep along the chosen eigenvector each iteration. Two details make it work:
Direction continuity. Because of the 2θ ambiguity, every sample can come out pointing either way. Each step compares the new eigenvector with the previous direction and negates it if their dot product is negative. Forget this and streamlines jitter back and forth in place.
Bidirectional growth. Each streamline grows from its seed in both directions simultaneously (integrateStreamline). This is why ring roads close cleanly: the two fronts accumulate matching integration error, and when they come back within dcirclejoin of each other the loop is stitched shut.
A front keeps stepping until one of five things stops it: it leaves the domain; the field goes degenerate; it comes within dtest of an existing street of the same family — this is what creates T-junctions; it has turned past 180° (streamlineTurned, the spiral guard); or it exhausts pathIterations. Click below to seed streamlines yourself — the shaded halo is the dtest forbidden zone around streets already placed, and the status line tells you why each front stopped.
Why the pointy kinks? The tracer follows the field faithfully — so if streets turn unnaturally sharply, the field itself is turning that fast. It happens in the blend zone where one basis field hands over to another: the summed tensor's direction can rotate through a large angle over a couple of steps, and the raw algorithm has no "streets don't hairpin" rule. The remedy is a per-step turn clamp, and bearings ships one: the tracer's maxTurn caps the direction change each step (default 0.12 rad), giving every street a minimum turn radius of about dstep / maxTurn. It's one of the live sliders in the HUD — the derived "min radius" line updates as you drag it. Both tracing demos on this page have a "limit curvature" toggle that applies exactly that clamp; click the same blend zones with it on and off. The trade-off: a clamped trace deviates from the field, so majors and minors are no longer exactly orthogonal in high-curvature zones.
RK4Integrator samples its midpoints at point + (h/2, h/2) — a fixed diagonal offset, not a step along k₁. It works because the field is smooth, but it isn't Runge–Kutta. Bearings' tracer (and this page's demos) use the real thing: k₂ sampled at p + k₁·h/2, and so on. Same cost, actually correct.One traced curve is a road; a network needs even spacing. That's the job of two hash grids of sample points — one for the major family, one for the minor family. Every point of every accepted streamline is dropped into its family's grid, and two distances rule everything:
dsep — a new seed must be at least this far from every existing same-family sample. Seeds are rejection-sampled at random until one fits or seedTries runs out; when no seed can be placed, that family is saturated and generation ends. dtest (< dsep) — a growing streamline stops when it gets this close to a same-family street. The gap between the two matters: seeds are born far from roads, but traces are allowed to approach them before terminating, which is exactly what forms T-junctions instead of near-miss parallels.
Generation alternates strictly: one major streamline, one minor, one major… Because the families collide only with themselves, minor streets freely cross major ones — crossings become intersections later, in the graph stage. dsep is your block-size knob — drag it here, or in the app's HUD with live city on, and watch the block size breathe.
Real cities have arterials and side streets. The trick needs zero new machinery: run the generator three times over the same field with shrinking separations — main, major, minor — and pre-seed each run's grids with every sample from the tiers before it. Small streets are born respecting big ones and terminate against them. Bearings runs a single tier today; the demo's "3-tier hierarchy" toggle shows what's on the roadmap — tier membership would be a free isAvenue classifier, eventually replacing the collinear-chain avenue-promotion heuristic.
Raw streamlines are close to a network but not quite one. The key realization: during generation there is no network. A streamline is just an ordered list of points; "connected" is not a concept the tracer has. Junctions are discovered afterwards — and only cross-family crossings exist geometrically, because a minor trace only collision-tests the minor grid and passes straight through major streets. Same-family crossings are impossible: to cross another minor street, a minor trace would first come within dtest of it, and that's a stop condition. It halts short of touching, leaving its endpoint floating in space. So nearly every streamline is born with dangling ends, and the whole finishing act is about turning near-misses into junctions. In bearings this happens in two places: in the tracer, and then in the graph.
In the tracer — joining dangling ends. A separation-stopped streamline halts dtest short of the street it hit, so every T-junction is born with a gap. (Why not just integrate until it touches? Because dtest's real job is stopping two same-family streets that converge tangentially from running as near-duplicates forever — at the moment of the test you can't tell a 90° approach from a 2° one, so it stops both and lets the join pass repair the perpendicular cases.) Bearings' join pass extends each loose end toward the best sample within dlookahead, filtered to a forward cone of joinangle, and deliberately overshoots one step past the target — the extension must genuinely cross the street it joins, so the crossing can be split into a junction downstream. Toggle "join dangling ends" in §4 and watch the stubs snap the network together. That overshoot is also why the streamline preview in the app shows short whiskers past some crossings: they're the join extensions, and the graph passes trim them before blocks form. The preview stays honest on purpose — a misbehaving whisker is a debugging signal.
In the graph. The polylines are simplified (Douglas–Peucker at dstep) and ingested into RoadGraph; then generateFromField() runs a gauntlet of passes, each of which earned its place by a real bug found while dogfooding the app:
repairCrossings + snapVertexToEdge, looped until the embedding is planar — every cross-family crossing becomes a real degree-4 junction. weldNearbyVertices(3 m) — two streamlines grazing within a couple of metres without crossing (common where fields cancel, §2) left enclosing loops open; welding the near-coincident vertices closes them. weldDanglingEnds — a streamline that stops a few metres shy of an existing junction leaves an open cell; its degree-1 end is snapped onto the mesh (only loose ends ever move, so real junctions can't collapse). removeDeadEnds — prunes genuinely stranded stubs, but preserves dead-ends near the masterplan boundary. removeShortJunctionSpurs — the join-pass overshoot leaves a short whisker hanging off a junction; a pendant edge on a cell's boundary makes that cell's face non-simple, so the DCEL extractor silently rejects it and the cell renders as a hole. Trimming spurs whose far end is a junction (never a streamline terminal) closed exactly that bug.
Then blocks, as always. From here the field path rejoins the standard bearings pipeline: DCEL face extraction, collapseTriangularBlocks, and a field-era addition — dropDegenerateSlivers, which removes "needle" blocks that are both a sharp wedge (min interior angle < 12°) and thin (min OBB < 15 m). Both conditions together target only spikes: legitimate thin strips have ~90° corners and pass through untouched. Narrow boundary blocks still auto-convert to green, parcels still subdivide per PlaceType, POIs still relocate. The tensor field replaces how streets are drawn, not what the city is made of.
Bearings' live values, in meters. Defaults are derived from the masterplan diameter (so the same recipe scales from a block to a district); every row marked ⟡ is a slider in the HUD, and a manual edit is sticky — it survives regeneration instead of being re-derived.
| param | controls | default | hud | intuition for experimenting |
|---|---|---|---|---|
| dsep | seed separation — effectively block dimension | diameter / 12 | ⟡ | The knob. Smaller → finer street grid, more blocks. Compare PT edgeLengthU/V. |
| dtest | trace-time separation; sets the T-junction gap | 0.6 × dsep | ⟡ | Keep below dsep. Lower it and streets tolerate running closer before stopping. |
| dstep | integration step | max(3, dsep/20) | ⟡ | Smaller → smoother curves, more vertices. Also scales the join overshoot and spur trim. |
| maxTurn | curvature clamp (rad per step) | 0.12 | ⟡ | Min turn radius ≈ dstep / maxTurn (the HUD shows it live). 0 disables — expect blend-zone kinks. |
| dcircleJoin | loop-closing distance for the two fronts | 4 × dstep | ⟡ | Bigger → rings close more eagerly. Radial fields need this to make ring roads. |
| dlookahead | dangling-end join search radius | 2 × dsep | ⟡ | 0 turns joins off entirely — try it to see how many T-junctions the join pass owns. |
| joinangle | forward cone for joins (radians) | 0.4 | ⟡ | Wider cone → more aggressive joins, occasionally to the wrong street. |
| seed | PRNG seed (deterministic runs) | 7 | ⟡ | Same seed + same field + same params → the identical city, every time. The repro dump relies on this. |
| pathIterations | max integration steps per streamline | 3000 | — | A backstop, not a knob; bound by domain diameter / dstep. |
| seedTries | failed random seeds before a family saturates | 500 | — | How hard it hunts for gaps before declaring the plan full. |
| minPoints | discard streamlines shorter than this | 5 | — | Rejected fragments still poison the seed grid, so dead pockets don't get re-seeded forever. |
Everything above is live in the app behind dev shortcuts. The working loop is: draw a masterplan → enter field mode → sculpt the field → generate. Coordinates are meters on the (x, z) plane; all the §6 defaults derive from the masterplan you drew.
| shortcut | what it does |
|---|---|
| Ctrl+Shift+B | Field mode. Shows the direction-line overlay, the draggable focal points, and the HUD. First press on an empty field seeds a demo grid + radial pair. |
| Ctrl+Shift+N | Streamline overlay. Traces the current field and draws the raw streamlines (major orange / minor magenta) — §3–§4, live on your map. |
| Ctrl+Shift+G | City from field. Runs the full chain — trace → graph passes (§5) → blocks → parcels → buildings — and renders it. |
| Ctrl+Shift+X | Repro dump. Copies the exact scene (masterplan, focal points, tracer params, PlaceTypes) as JSON. Deterministic: same dump → same city. |
| Ctrl+Shift+V | Paste box. Paste a dump, hit Load, and the scene reproduces exactly — how edge-case bugs get reported and replayed. |
Focal points are basis fields (§2) wearing map handles. Drag the centre dot to move one; drag the rim handle to resize — on a grid field the rim also sets θ, so one gesture rotates the whole street grain. Right-click a centre dot to delete it; the + grid / + radial buttons in the HUD drop a new one at the map centre, sized to the plan. Clicking or grabbing a focal point selects it (gold ring) and opens its sliders — size, decay, and for grids angle — each paired with a number box for exact values.
The HUD's live: toggle picks what regenerates while you drag: streamlines re-traces continuously (cheap, great for feeling out the field), city rebuilds blocks and buildings on release (the full picture). The field: toggle hides the direction lines when they get noisy without leaving field mode. The tracer sliders (§6) sit below and follow the same live-target rule — drag dsep with live city on and watch the block size breathe.
Set dlookahead to 0 and regenerate — the network falls apart into disconnected strands, which is the fastest way to feel why the join pass is load-bearing. Push two grid fields with different θ into overlap and find the seam where the blend kinks, then watch maxTurn round it off. Drop a radial inside a large grid and shrink dcircleJoin until the ring roads stop closing. Every experiment is one Ctrl+Shift+X away from being reproducible.
Done: the field + tracer core in C++/WASM, ingestion into RoadGraph with the §5 robustness passes, full downstream integration (blocks → parcels → POIs → massing), interactive field editing with live regeneration, and the deterministic dump/replay loop.
Next: PlaceTypes driving the field automatically — macro PTs contributing a Grid at their centre/orientation, concentric PTs a Radial, so the superposition gives the smooth PT-to-PT transitions that boundary stitching currently approximates. Also on the roadmap: closing the masterplan boundary in the field path (plan-edge margins currently stay green), the §4 tier hierarchy as the natural isAvenue classifier, and pre-seeding the separation grids with context streets so generated streets terminate against the real network for free.
| concept | bearings source | paper section (Chen et al. 2008) |
|---|---|---|
| 2θ tensor, eigenvectors | src/cpp/tensor.h | §3 (tensor fields) |
| grid / radial basis, decay | src/cpp/basis_field.h | §3.1–3.2 (basis fields) |
| weighted summation | src/cpp/basis_tensor_field.h | §3.3 (combination) |
| RK4, separation grids, seeding, stops, joins | src/cpp/streamline_tracer.h | §4 (street graph gen., after Jobard–Lefer) |
| ingestion + graph passes + welds | src/cpp/bindings.cpp (generateFromField) · src/cpp/graph.h | — |
| focal-point editor, HUD, overlays | src/js/field-editor.js · dev-hud.js · tensor-field-viz.js · streamline-viz.js | — |