low-vertex-flat-tori

View on GitHub

Numerically discovering low complexity flat tori in 3 space, following Rich Schwartz's vertex-minimal construction.

with Fabian Lander

Readme

Numerically discovering flat, embedded 8-vertex tori, following Rich Schwartz’s vertex-minimal construction (rich/).

A flat torus is intrinsically a flat sheet ℝ²/Λ. The challenge is realizing one with only 8 vertices as a straight-edge polyhedron in ℝ³ that is both flat (every vertex has cone angle exactly 2π) and embedded (no two triangles cross). There are 7 combinatorial types of 8-vertex torus triangulation (each V=8, E=24, F=16); only type #7 is degree-6-regular — the other six mix degree 5/7, so nothing in the pipeline assumes degree 6.

The mathematics, in layers

The repository is organized so the code reads like the mathematics. Each layer’s home is a folder under src/core/, documented in that folder’s README.

  1. The torus — the topology (a genus-1 surface). Never an object, just the fact that V − E + F = 0.
  2. A triangulation — the discrete topology: a combinatorial structure realizing the torus. Pure combinatorics, all derived from a triangle list. (topology/, triangulations/)
  3. A fundamental domain — a developing chart: how to cut the torus open along a minimal graph and unroll it into the plane. A presentation choice. (topology/)
  4. A marking — a basis of H₁(T²,ℤ), two oriented loops. The Teichmüller marking. (topology/)
  5. The developing map — unfold a flat realization → read the holonomy of the marking → its modulus τ ∈ ℍ (its point in Teichmüller space). Forget the marking (the SL(2,ℤ) quotient, reduceModulus) → moduli space. (moduli/)

These five are intrinsic — true independent of any embedding. Realizing the flat torus as a polyhedron in ℝ³, and searching for flat embedded ones, is the extrinsic half — the search system, below.

The search system

The extrinsic problem is sharp: find configurations in ℝ³ⱽ that lie on a high-codimension submanifold (flat: every cone angle 2π, codim V−1; optionally a fixed modulus) and inside a tiny open set (embedded). Because the manifold is thin and the open set is small, you cannot one-shot a far/tiny target — you must move along the manifold while staying inside the open set.

The kit models exactly that, as a few modular objects close to the math (each lives in its src/core/ folder, documented in that folder’s README):

  • a configuration space ConfigSpace = (T, φ) — a triangulation with an embedding φ: ℝⁿ → ℝ³ⱽ (coordinate system: full, pinned, symmetry, Doyle–Schwartz). It pulls a condition to a real function on ℝⁿ, pushes a point up to ℝ³ⱽ, and gives the boundary paperTorus(x). (configuration/, coordinates/)
  • conditions of two kinds — closed submanifolds {fn = target} (flat, collinear, modulus) you project onto (constraints/), and open regions (embedded) you stay inside (embedding/);
  • three operations, all on one QR kernel (the constraint Jacobian’s Jᵀ = QR): project (corrector onto the manifold), minimize (Riemannian gradient descent along it, into the region), continuation (tracks the manifold∩region as a target moves) — the tool for the tiny far set — plus certify to record the result (raw τ and reduced τ̂, margin, embeddedness). (solvers/, search/)

The solvers run entirely on the problem’s space ℝⁿ — they take pulled Fns and a gate predicate, never a Triangulation, a coordinate system, or a chart.

Layout

src/ is three rings by purity — core/ (pure, headless, tsx-runnable), display/ (torus → three.js), app/ (scene/path-trace harness). Imports cross folders via the aliases @core/*, @display/*, @app/*.

src/core/            THE PURE, HEADLESS CORE — no three.js, no DOM; every folder runs under tsx.

  topology/            the intrinsic torus, COMBINATORIAL — works on ANY triangulation:
    triangulation.ts     deriveCombinatorics + makeTriangulation(data, marking) + types (Euler-checked)
    trees.ts             shared spanning-tree primitives (primal/dual trees, tree–cotree, LCA path)
    marking.ts           canonicalMarking: H₁ generators + cut + develop order — EXPENSIVE, run OFFLINE
    fundamentalDomain.ts the developing chart — exact minimal cut + centered-spiral unroll order
    harmonicLayout.ts    flat-torus harmonic (Tutte) embedding — the SCRATCH layout that picks the marking
    (depends only on geometry/; the geometric measurement of a metric torus lives in moduli/, below)

  moduli/              the modulus: develop a metric torus → τ, and the space it lives in:
    develop.ts           the developing map via per-triangle frames (no circle–circle): developNet + frames
    modulus.ts           τ ∈ ℍ directly from the frames + tauJacobian (the exact analytic ∂τ/∂p)
    reduce.ts            the space ℍ/SL(2,ℤ): applyMobius, reduceModulus, SQUARE/HEXAGONAL (torus-blind)
    (depends on topology + geometry; consumed by constraints/modulus and search/certify)

  triangulations/     the specific triangulations we study, as DATA — scales to many:
    {seven,eight,nine,ten}Vertex.ts  the triangle lists per vertex count (TriangulationData;
                         ids v7-1, v8-1..7, v9-1..112, v10-1..2109 — 2229 tori from Lutz's census)
    <census>.markings.generated.ts   per-census precomputed markings, keyed by id — GENERATED
                         by `npm run compute-markings` (1:1 with the data files)
    index.ts             the registry: ALL_TORI, RICH, byId('v8-7') — joins each data ⊕ markings via
                         makeTriangulation (eager; building LOADS the marking, never computes it)
    (depends on topology)

  THE SEARCH STACK — a modular, problem-agnostic kit for constrained search (see per-folder READMEs).
  Dependency-ordered, each layer using only the ones below:
    geometry → functions → {configuration, coordinates, constraints, embedding} → solvers → sampling → search

    geometry/          torus-blind ℝ²/ℝ³ kernels: point/segment/triangle distances, the tri–tri chord
    functions/         the generic toolkit ONLY: the `Fn` contract (a map ℝⁿ→ℝᵏ with inDim/outDim +
                       value/jacobian; `ScalarFn` = outDim 1) + the algebra (fdFn/scalarFn/affine; the
                       one chain-rule `compose` = both pullback g∘φ and post-map locus∘τ; `stack` to
                       combine conditions, `leastSquares` to soften one into a flow energy). ONE map type —
                       a constraint is an `Fn` + a `target`, an energy a `ScalarFn`, a coordinate map an `Fn`.
    configuration/     the configuration-space MACHINERY: `ConfigSpace = (T, φ)` with pull/push/coords/
                       metric (space.ts); `paperTorus` (the {triang, positions} boundary bundle); csv.
    coordinates/       the coordinate-system INSTANCES (each a map (`Fn`) φ, both push + coords):
                       full · pin (pinCoords/pinVertices) · symmetry · normalized (the gauge-fixed
                       section of C → C/Sim, 3V−7 free coords; the mirror of moduli/reduce).
    constraints/       the CLOSED conditions {fn=target} you project onto (+ types.ts: Constraint = {fn, target?}):
                       flat (fn emits V−1 rows), collinear, modulus — the point/line/circle × Teichmüller/moduli
                       grid (pinTeichmuller/pinModuli × point/verticalLine/circle, each locus a Constraint on ℍ;
                       named: fixedModulus, modulusWall).
    embedding/         the OPEN condition Ω you stay inside (its own home): embedded (isEmbedded gate +
                       clearance) · separation (minSeparation + the fatten cell-gap substrate) ·
                       energies/ (overlap + fatten) · cells (cellTables). Defines the `Region` contract
                       ({contains, margin?}) the solvers stay inside.
    solvers/           the engine, on ℝⁿ over one QR kernel (qr.ts): project (corrector), minimize
                       (Riemannian descent, region-gated), continuation (tracks a family); types.ts holds Family.
    sampling/          producing seeds: rng · perturb · seeds (random + deterministic gridSeeds) ·
                       reference (RICH_REFERENCE).
    search/            composition & measurement: certify (raw τ AND reduced τ̂), collect, the recipes,
                       pull (pull conditions into a coordinate system).

src/display/         TORUS → THREE.JS (the impure presentation library):
  mesh/                the triangulation's k-cells realized in ℝ³ (one `Part`) + section/obj
  viewer/              the one subject `makeTorusView` (factory, streams positions) + decorations + look

src/app/             THE SCENE/PRESENT HARNESS:
  render/              the path-traced `Studio` (WebGL ↔ three-gpu-pathtracer) + stage/controls

demos/ renders/      browser entry points (interactive demos; path-traced figures), each a <name>/main.ts
scripts/             headless CLI runners (the search drivers); scripts/legacy/ is a read-only archive
data/                CSV result sets we keep (one torus per row, 24 floats); samples/ is the gitignored dump

The dependency rule: all of src/core/ never imports three.js or touches the DOM — so every algorithm runs headless under tsx; display//app/ may, and depend on core/, never the reverse (a @display/@app import inside src/core/ is a glaring purity violation). Within core/: topology (combinatorial) depends only on geometry/ (its harmonic scratch layout uses geometry/vec2); moduli/ (the modulus measurement + space) depends on topology + geometry. Arrows are one-way folder→folder: geometry → topology → triangulations and geometry/topologymoduli; the search stack geometry → functions → {configuration, coordinates, constraints, embedding} → solvers → sampling → search builds on top (with constraints/search also consuming moduli); display/app sit on top of all of it. geometry/ is the single bottom everything rests on. Machinery and its instances are flat siblings — topologytriangulations, functionsconstraints, configurationcoordinates — never nested (the arrow is dependency, not containment).

Searches

A search is three small pieces wired together — there is no framework, just composition:

  1. a seed source() => Float64Array | null, the next starting configuration (src/core/sampling/seeds.ts: perturbedSeeds, poolSeeds, uniformSeeds random; gridSeeds deterministic, returning null when exhausted — all built on sampling/perturb + an RNG);
  2. an attempt recipe(seed) => Certificate | null: run project/minimize on the seed and certify, returning the certificate to accept it or null to reject;
  3. the collect drivercollect(drawSeed, attempt, {maxTries, maxAccepts, onAccept}): the rejection-sampling loop (stops on a null seed), with all IO in the onAccept/onTry callbacks.

The three built-in searches (thin runners in scripts/, logic in src/core/search/):

npm run discover       -- [opts]          # any flat embedded torus
npm run wall           -- --c 0           # flat embedded tori on a modulus wall |Re τ̂|=c (0 rect, ½ rhombic)
npm run semi-solutions -- [opts]          # Doyle–Schwartz semi-solution scan (flat immersions; embeddedness recorded)

discover and wall share one recipe (flattenFlowEmbed), differing only in held:

seed → project(held) → minimize(held, energy, region = embedded) → certify
  held = [flat]                  → discover
  held = [flat, modulusWall(c)]  → wall    (descends to embedded along the wall)

To write a new search, write its attempt (pick the coordinate system, the held conditions, whether to minimize, and the accept predicate) and hand it to collect with a seed source — e.g. semiSolution runs in a pinCoords coordinate system with held = [flat, collinear, collinear] and no descent (a flat-immersion search). A ~15-line src/core/search/<name>.ts plus a thin scripts/<name>.mjs is a whole new search.

Validate the develop → τ pipeline (covolume = area, rotational defect ≈ 0, cone deficit ≈ 0):

npx tsx scripts/legacy/develop-check.mjs [path/to.csv]

Running

Install once: npm install.

npm run dev <name>        # serve a demo/render (vite). Omit <name> to list them.
npm run build <name>      # self-contained build → dist/<name>/
npm test                  # vitest
npx tsc --noEmit          # the typecheck — there is no separate linter

npm run dev <name> (a demo under demos/ or a render under renders/) writes a gitignored .dev/<name>.html and serves it on a stable per-name port (a hash of the name, 5200–5599), so entries never collide and run in parallel. build rewrites the tracked index.html, so git status shows it modified after a build.

Adding a triangulation

  1. Add a { id, triangles } entry (TriangulationData) to a data file (src/core/triangulations/eightVertex.ts, or a new per-vertex-count file like nineVertex.ts).
  2. Run npm run compute-markings to (re)generate the per-census *.markings.generated.ts files (the expensive harmonic-layout + min-cut pass, run once, committed; 1:1 with the data files).
  3. Add the build(data, markings) pair to ALL_TORI in src/core/triangulations/index.ts.

The builder derives all combinatorics and validates V − E + F = 0 (+ manifold edges, single-cycle links, coherent orientation); no vertex/edge/face count is hard-wired. The marking is a deterministic function of the triangle list, so the generated table is a pure on-disk cache — recomputed only when you re-run the generator, never at load.

Data format

CSV result files (data/): one torus per line, 24 comma-separated full-precision floats in x0,y0,z0, …, x7,y7,z7 order — exactly a PaperTorus’s positions. A row records only points; interpret it by pairing with a chosen triangulation. .bin files are the same 24-float packing in Float32.

Normalization convention

src/core/coordinates/normalized.ts puts a torus into a canonical pose under the similarity group of ℝ³ (translation ⊕ rotation ⊕ uniform scale = 7 DOF): vertex 0 at the origin, vertex 1 at (1,0,0), vertex 2 in the xy-plane with y₂ ≥ 0. That removes the 7 similarity DOF, leaving 3V − 7 free numbers. Only proper rotations are used, so chirality is preserved, not quotiented. It is a real coordinate system (the section of C → C/Sim): normalizePose is the projection onto the slice (coords), the linear scatter back is push. Use it to search in a gauge-fixed chart (full-rank constraints, deduplicated up to similarity), or for storage/dedup; off it, the gauge is handled implicitly by the solvers’ minimum-norm step.

← All software