Metadata-Version: 2.4
Name: mtgeodesicdome
Version: 1.2.0
Summary: Geodesic domes (icosahedral spherical lattices) with fast neighbour search, flat grids, map projections and an interactive rotatable map viewer -- the lattice layer for spherical self-organising maps.
Author-email: Masahiro Takatsuka <masa@takatsuka.org>
Maintainer-email: Masahiro Takatsuka <masa@takatsuka.org>
License-Expression: AGPL-3.0-or-later
Project-URL: Homepage, https://github.com/takatsuka/mtGeodesicDome
Project-URL: Source, https://github.com/takatsuka/mtGeodesicDome
Project-URL: Issues, https://github.com/takatsuka/mtGeodesicDome/issues
Project-URL: Documentation, https://github.com/takatsuka/mtGeodesicDome/blob/main/docs/index.md
Project-URL: Changelog, https://github.com/takatsuka/mtGeodesicDome/blob/main/CHANGELOG.md
Project-URL: Paper, https://doi.org/10.1016/j.neunet.2006.05.021
Keywords: geodesic dome,geodesic sphere,icosahedron,spherical grid,icosphere,self-organizing map,spherical SOM,neighbour search,map projection,equal earth,mesh
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Science/Research
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Scientific/Engineering :: Mathematics
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Scientific/Engineering :: Visualization
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Requires-Dist: numpy>=1.23
Provides-Extra: interactive
Requires-Dist: matplotlib>=3.6; extra == "interactive"
Provides-Extra: examples
Requires-Dist: matplotlib>=3.6; extra == "examples"
Requires-Dist: pillow; extra == "examples"
Provides-Extra: legacy
Requires-Dist: plotly; extra == "legacy"
Requires-Dist: dash; extra == "legacy"
Provides-Extra: dev
Requires-Dist: matplotlib>=3.6; extra == "dev"
Requires-Dist: pytest>=7; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Requires-Dist: ruff<0.17,>=0.16; extra == "dev"
Requires-Dist: build; extra == "dev"
Requires-Dist: twine; extra == "dev"
Provides-Extra: all
Requires-Dist: matplotlib>=3.6; extra == "all"
Requires-Dist: pillow; extra == "all"
Requires-Dist: plotly; extra == "all"
Requires-Dist: dash; extra == "all"
Requires-Dist: pytest>=7; extra == "all"
Requires-Dist: pytest-cov; extra == "all"
Requires-Dist: ruff<0.17,>=0.16; extra == "all"
Requires-Dist: build; extra == "all"
Requires-Dist: twine; extra == "all"
Dynamic: license-file

# mtGeodesicDome — geodesic domes and grids for Python

[![PyPI](https://img.shields.io/pypi/v/mtgeodesicdome.svg)](https://pypi.org/project/mtgeodesicdome/)
[![Python](https://img.shields.io/pypi/pyversions/mtgeodesicdome.svg)](https://pypi.org/project/mtgeodesicdome/)
[![tests](https://github.com/takatsuka/mtGeodesicDome/actions/workflows/tests.yml/badge.svg)](https://github.com/takatsuka/mtGeodesicDome/actions/workflows/tests.yml)
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue.svg)](https://github.com/takatsuka/mtGeodesicDome/blob/main/LICENSE)

`mt.geodesicdome` builds **geodesic spheres** (icosahedron-based domes of any frequency) and
**flat hexagonal/rectilinear grids**, with fast neighbour search on both. It was written as the
lattice layer for Self-Organising Maps (SOMs), but it works just as well for meshing,
sampling points evenly on a sphere, or cellular automata on a globe.

| What | Name |
|---|---|
| `pip install …` | `mtgeodesicdome` |
| Python import | `mt.geodesicdome` (`mt` is a namespace package shared by future `mt.*` libraries) |
| Repository | [`takatsuka/mtGeodesicDome`](https://github.com/takatsuka/mtGeodesicDome) |

![geodesic domes of frequency 1, 2, 4 and 8](https://raw.githubusercontent.com/takatsuka/mtGeodesicDome/main/examples/output/02_domes_3d.png)

**What you get**

| Feature | Where |
|---|---|
| Geodesic dome of any frequency *f*: 10f²+2 points, 20f² triangles, all on the unit sphere | `mt.geodesicdome.grid.geodesicdome.GeodesicDome` |
| Neighbour search and *k*-ring neighbourhoods that continue across the seams of the net | `get_neighbours`, `get_neighbours_in_distance` |
| NumPy export of points and triangles (outward-facing winding) | `get_all_xyz`, `get_all_triangles` |
| A data payload per vertex, e.g. SOM weight vectors | `vertex.set_data(...)` / `vertex.data` |
| Four map projections to flatten the sphere | `mt.geodesicdome.projection` |
| **Interactive map: drag with the mouse to rotate the sphere inside the projection** | `mt.geodesicdome.interactive.ProjectionViewer` |
| Flat hexagonal or rectilinear grids, with borders or as a torus | `mt.geodesicdome.grid.plane.Plane` |

---

## 1. Installation

Requires Python ≥ 3.10 and NumPy. The interactive viewer and the examples also need matplotlib.

```bash
pip install mtgeodesicdome                  # the library (numpy only)
pip install "mtgeodesicdome[interactive]"   # + matplotlib, for mt.geodesicdome.interactive
```

Check that it works:

```bash
python -c "from mt.geodesicdome.grid.geodesicdome import GeodesicDome; print(GeodesicDome(4))"
# GeodesicDome(frequency=4)
```

The examples, the tests and `setup_env.sh` are in the
[GitHub repository](https://github.com/takatsuka/mtGeodesicDome), not in the pip package.

### Working on this repository: `setup_env.sh` (recommended)

One command creates a virtual environment with everything the project uses: numpy, matplotlib, pillow,
plotly and dash for the older viewers, and pytest. It also installs this package in editable mode, so any
`.py` file in any folder of the project can `import mt.geodesicdome`.

```bash
cd mtGeodesicDome
./setup_env.sh                          # once
python examples/01_quickstart.py        # works straight away, in the same terminal
python examples/09_interactive_projection.py
```

* **It leaves you in a ready shell.** When it finishes, it opens a shell in which the environment is active
  (the prompt starts with `(mtGeodesicDome)`), so `python` is the project's Python. Type `exit` to return to
  your previous shell. `--no-shell` skips this.
* **New terminals are ready too.** It adds a small block to `~/.zshrc` (and `~/.bashrc` if you have one) that
  activates the environment whenever you are inside the project and deactivates it when you leave.
  `--no-shell-hook` skips this; `./setup_env.sh --remove-shell-hook` removes it.
* **The environment lives in `~/.venvs/mtGeodesicDome`, outside the repository.** This repository sits in Google Drive,
  which syncs every file of a virtual environment and can make them online-only, so imports hang or fail.
* **Anywhere else**, run `source ~/.venvs/mtGeodesicDome/bin/activate`, or call the environment's Python
  directly: `~/.venvs/mtGeodesicDome/bin/python path/to/script.py`.
* **It is safe to re-run at any time.** A healthy environment is reused, and missing packages are added. A broken
  one, for example after `brew upgrade python`, is rebuilt automatically.
* **It picks the newest Python ≥ 3.10 it can find.** Use `--python /opt/homebrew/bin/python3.13` to choose one.
* **Other options:** `--recreate` builds from scratch, `--check` only verifies, `--minimal` installs numpy only,
  and `--no-legacy` skips plotly and dash. See `./setup_env.sh --help`.
* **IDE:** in PyCharm or VS Code, choose `~/.venvs/mtGeodesicDome/bin/python` as the project interpreter.

### Other ways to install

```bash
pip install "git+https://github.com/takatsuka/mtGeodesicDome.git"   # latest development version
pip install -e ".[interactive]"                            # from a local checkout, editable
```

Optional extras: `interactive` (matplotlib), `examples` (+ pillow), `legacy` (plotly and dash, for the old
viewer scripts), `dev` (pytest, ruff, build, twine), and `all`.

---

## 2. Quick start

```python
import numpy as np
from mt.geodesicdome.grid.geodesicdome import GeodesicDome

dome = GeodesicDome(frequency=8)          # icosahedron with each edge cut into 8

xyz = dome.get_all_xyz()                              # (N, 3) unit vectors
tri = dome.get_all_triangles().reshape(-1, 3)         # (20 f², 3) indices into xyz
print(xyz.shape, tri.shape)                           # (729, 3) (1280, 3)

# neighbours of a vertex, addressed by its (x, y) position on the index grid
v = dome.get_vertex_at(12, 14)
dome.unmark_vertices()                                # always reset before a search
ring1 = dome.get_neighbours(v, False)                 # 6 neighbours (5 at the 12 corners)

dome.unmark_vertices()
rings = dome.get_neighbours_in_distance(v, 3)         # [[ring 1], [ring 2], [ring 3]]
print([len(r) for r in rings])                        # [6, 12, 18]

v.set_data(np.zeros(3))                               # attach anything to a vertex
```

You can also refine step by step. `split` multiplies the frequency, so
`GeodesicDome(2).split(3)` gives frequency 6.

---

## 3. Core concepts

### 3.1 Frequency

| frequency *f* | unique points 10f²+2 | triangles 20f² | stored vertices | mean edge (unit sphere) |
|---:|---:|---:|---:|---:|
| 1 | 12 | 20 | 22 | 1.052 |
| 2 | 42 | 80 | 63 | 0.582 |
| 4 | 162 | 320 | 205 | 0.298 |
| 8 | 642 | 1 280 | 729 | 0.150 |
| 12 | 1 442 | 2 880 | 1 573 | 0.100 |

Every point has 6 neighbours, except the 12 original icosahedron corners, which have 5.
Rings near a corner are therefore slightly smaller (for example 5, 10, 15 around a corner itself).

### 3.2 The unfolded net and seam vertices

Internally, the icosahedron is unfolded onto an integer grid. Each vertex has a grid position
`(vertex.x, vertex.y)`, and its neighbours on the sphere are always the six grid offsets
`(±1, 0)`, `(0, ±1)`, `(+1, +1)` and `(−1, −1)`. That is what makes neighbour look-ups fast.

This indexed geodesic data structure was introduced for the spherical self-organising map in:

> Y. Wu and M. Takatsuka, "Spherical self-organizing map using efficient indexed geodesic data structure,"
> *Neural Networks*, vol. 19, no. 6–7, pp. 900–910, 2006. [doi:10.1016/j.neunet.2006.05.021](https://doi.org/10.1016/j.neunet.2006.05.021)

```bibtex
@article{wu2006spherical,
  author  = {Wu, Yingxin and Takatsuka, Masahiro},
  title   = {Spherical self-organizing map using efficient indexed geodesic data structure},
  journal = {Neural Networks},
  volume  = {19},
  number  = {6--7},
  pages   = {900--910},
  year    = {2006},
  month   = {07},
  doi     = {10.1016/j.neunet.2006.05.021}
}
```

![the unfolded net](https://raw.githubusercontent.com/takatsuka/mtGeodesicDome/main/examples/output/03_unfolded_net.png)

To fold the net back into a sphere, points on its border are **stored more than once**, and each copy
lists the others in `vertex.same_vertices`. As a result:

* `get_all_vertices()` / `get_all_xyz()` return **N ≥ 10f²+2** entries (for example 729 instead of 642 at f = 8).
* Neighbour searches already handle the copies, so rings continue across seams and never
  contain the same point twice.
* To get a clean mesh with each point exactly once, de-duplicate by position.
  `examples/dome_utils.py` has a ready-made `unique_mesh(dome)` that returns
  `(points, faces, index_map)`.

### 3.3 The `visited` flag

The neighbour searches mark vertices with `vertex.visited = True` so they are not returned twice.
**Call `dome.unmark_vertices()` before every new query**. Otherwise vertices from the previous
query will be missing from the result.

### 3.4 Coordinates

* `vertex.coord` is the unit vector (x, y, z). The dome is built with its poles on the **±y** axis.
* `vertex.latlon_coord` is `(colatitude from +y, longitude)` in radians.
* `vertex.id` is the row of the vertex in `get_all_xyz()`. It is assigned when faces are built
  (`get_faces()` / `get_all_triangles()`), so call one of those first.
* The map projections use **+z** as their north pole (`latitude = arcsin(z)`).

---

## 4. API reference

### `GeodesicDome(frequency=1)` — `mt.geodesicdome.grid.geodesicdome`

| Member | Description |
|---|---|
| `frequency`, `x_max`, `y_max` | current frequency and the extent of the index grid |
| `split(n)` | subdivide every edge into `n` more segments (frequency ×= n) |
| `get_all_vertices()` | list of `GeodesicVertex`, including seam copies |
| `get_vertex_at(x, y)` | vertex at a grid position, or `None` |
| `get_faces()` | flat list of vertices, 3 per triangle (also assigns `vertex.id`) |
| `get_all_triangles()` | flat `ndarray` of vertex ids; `.reshape(-1, 3)` gives the triangles |
| `get_all_xyz()` | `(N, 3)` array of coordinates, row = `vertex.id` |
| `get_number_of_vertices_per_face()` | `3` |
| `get_neighbours(v, False)` | immediate neighbours of `v` |
| `get_neighbours_in_distance(v, d)` | list of `d` rings: `[[ring 1], [ring 2], ...]` |
| `unmark_vertices()` | reset every `visited` flag (do this before each search) |

### `GeodesicVertex`

`x`, `y` (grid position) · `coord` (xyz) · `latlon_coord` · `id` · `same_vertices` (seam copies or `None`) ·
`data` / `set_data(obj)` · `visited` · `projected_coord` (set by a projection).

### Projections — `mt.geodesicdome.projection`

| Class | Module |
|---|---|
| `KavrayskiyVII` | `kavrayskiy` |
| `WagnerVI`, `WagnerIII` | `wagner` |
| `EqualEarth` | `equal_earth` |

```python
from mt.geodesicdome.projection.equal_earth import EqualEarth
tri2d = EqualEarth().build(dome)            # (M, 3) triangle ids; sets v.projected_coord
xy = [v.projected_coord for v in dome.get_all_vertices()]
EqualEarth().xyz_to_2d(np.array([0, 0, 1])) # project a single point
```

`build` leaves out the few triangles that would wrap across the ±180° meridian, so M < 20f².
For a complete map, with no missing triangles and any orientation of the sphere, use
`mt.geodesicdome.interactive` (below).

Vectorised helpers on every projection: `latlong_to_2d(lat, lon)` (arrays, radians),
`xyz_to_2d_many(xyz)` for an `(N, 3)` array, and `outline()`, the map boundary as a polygon.

![map projections](https://raw.githubusercontent.com/takatsuka/mtGeodesicDome/main/examples/output/05_map_projections.png)

### Interactive projection — `mt.geodesicdome.interactive`

`ProjectionViewer` shows the dome in a map projection that you can rotate. **Click and drag on the map, and the
sphere turns underneath it**: dragging left or right spins it about the map's polar axis, and dragging up or down
tilts it. The projection is recomputed on every mouse move.

```python
from mt.geodesicdome.grid.geodesicdome import GeodesicDome
from mt.geodesicdome.interactive import ProjectionViewer

viewer = ProjectionViewer(GeodesicDome(8), 'Equal Earth')
viewer.show()
```

![rotating the sphere inside an Equal Earth projection](https://raw.githubusercontent.com/takatsuka/mtGeodesicDome/main/examples/output/09_rotation.gif)

| Mouse / key | Action |
|---|---|
| drag (left button) | rotate the sphere (a fast preview is drawn while dragging; full quality returns on release) |
| arrow keys | rotate by 5° (with shift: 1°) |
| `,` / `.` | roll the view about the map centre |
| `r` / *Reset view* button | back to the starting view |
| `p` / radio buttons | switch projection: Equal Earth, Kavrayskiy VII, Wagner VI, Wagner III |
| `g` / `e` | show or hide the graticule / the triangle edges |

The black graticule shows the latitude and longitude lines *of the original sphere*, so you can see how it has
turned. The status line gives the original point now at the map centre.

**Options and methods**

```python
viewer = ProjectionViewer(
    dome, 'Wagner VI',
    colors='position',     # 'position' (default), 'icosahedron', 'data' (vertex.data), a colour name,
                           # or an array per stored vertex / per unique point / per face
    cmap='viridis',        # used when colors holds scalars (a colour bar is added)
    edges=None,            # triangle edges; default: on for frequency <= 12
    graticule=True,
    view=(-33.9, 151.2),   # (lat, lon) in degrees of the original point to put at the centre
)
viewer.rotate(d_lon=30, d_lat=-10, roll=0)   # degrees, same axes as dragging
viewer.set_view(lat, lon)                    # centre the map on an original point
viewer.set_rotation(R)                       # any 3x3 rotation matrix (original -> view)
viewer.set_projection('Kavrayskiy VII')
viewer.set_colors(values, vmin=0, vmax=1)
viewer.on_rotate(lambda v: print(v.centre))  # called after every change of the view
viewer.save('map.png')
```

Colouring by `vertex.data` means a trained spherical SOM (see `examples/06_spherical_som.py`) can be explored
directly: store each node's weight vector as an RGB colour with `set_data` and pass `colors='data'`.

**Running it.** It needs a matplotlib GUI backend. `python examples/09_interactive_projection.py` from a desktop
terminal or IDE opens a window. In Jupyter, run `%matplotlib widget` first (`pip install ipympl`).

**How it works.** `SphereMap` (numpy only) rotates the de-duplicated mesh and projects it. Unlike
`Projection.build`, it keeps every triangle:
* a triangle that straddles the ±180° meridian is drawn twice, shifted by ±360° of longitude, and clipped to the
  map outline;
* the triangle that contains a pole becomes a polygon running along the pole line;
* exact alignments, such as a pole lying precisely on an edge in the un-rotated view, are broken by a fixed
  10⁻⁶ rad rotation that is invisible on screen.

The tests (`tests/test_interactive.py`) render random and deliberately degenerate orientations in all four
projections and check that no pixel inside the map is left uncovered. `SphereMap.polygons(R)` is also usable on
its own, for example to draw a rotated map in your own figure.

### `Plane(x, y, lattice, topology)` — `mt.geodesicdome.grid.plane`

* `lattice`: `Lattice.Hexagonal` (6 neighbours, odd rows shifted by ½) or `Lattice.Rectilinear` (4 neighbours).
* `topology`: `Topology.Plane` (hard borders) or `Topology.Donut` (wraps around like a torus). Use an even
  height with a hexagonal donut. Faces are not generated across the wrap.
* It has the same `Manifold` interface as the dome: `get_vertex_at`, `get_neighbours`,
  `get_neighbours_in_distance`, `get_faces` (3 or 4 vertices per face), `get_all_xyz`, `get_all_triangles`, `unmark_vertices`.

Because both classes share the `Manifold` interface, code such as a SOM can switch between a flat
map, a torus and a sphere without changes.

![plane grids](https://raw.githubusercontent.com/takatsuka/mtGeodesicDome/main/examples/output/08_plane_grids.png)

---

## 5. Examples

Run them from the repository root, e.g. `python examples/04_neighbours.py`. They work straight
from a checkout without installing, and their images and files go to `examples/output/`.

| Script | Shows |
|---|---|
| `01_quickstart.py` | building domes, NumPy export, vertex attributes, a size table for each frequency |
| `02_plot_dome_3d.py` | shaded 3D renders of frequency 1, 2, 4 and 8 |
| `03_unfolded_net.py` | the index grid, seam copies and the 12 five-neighbour corners |
| `04_neighbours.py` | *k*-ring neighbourhoods around an interior, a seam and a corner vertex |
| `05_map_projections.py` | the four projections, coloured by parent icosahedron face |
| `06_spherical_som.py` | **showcase:** a Self-Organising Map trained on colours, living on the sphere |
| `07_export_mesh.py [freq] [radius]` | writing a de-duplicated mesh to `.obj`, `.off` and `.npz` |
| `08_plane_grids.py` | hexagonal and rectilinear `Plane`, with borders and as a torus |
| `09_interactive_projection.py` | **interactive:** drag to rotate the sphere in a map projection (`--freq`, `--projection`, `--colors`, `--view`, `--gif`) |
| `dome_utils.py` | helpers used above: `unique_mesh`, `neighbour_rings`, `output_path` |

![neighbour rings](https://raw.githubusercontent.com/takatsuka/mtGeodesicDome/main/examples/output/04_neighbours.png)

![spherical SOM](https://raw.githubusercontent.com/takatsuka/mtGeodesicDome/main/examples/output/06_spherical_som.png)

The older interactive viewers in `examples/legacy/` use plotly/dash (`pip install -e ".[legacy]"`).

---

## 6. Changelog

See [CHANGELOG.md](https://github.com/takatsuka/mtGeodesicDome/blob/main/CHANGELOG.md).

---

## 7. Citing

If you use mtGeodesicDome in research, please cite the paper that introduced the data structure (BibTeX in
[section 3.2](#32-the-unfolded-net-and-seam-vertices)):

> Y. Wu and M. Takatsuka, "Spherical self-organizing map using efficient indexed geodesic data structure,"
> *Neural Networks*, vol. 19, no. 6–7, pp. 900–910, 2006. [doi:10.1016/j.neunet.2006.05.021](https://doi.org/10.1016/j.neunet.2006.05.021)

The repository's `CITATION.cff` also lets GitHub's "Cite this repository" button produce a reference to
the software itself.

---

## 8. Licence

Copyright © 2022–2026 Masahiro Takatsuka.

mtGeodesicDome is free software under the **GNU Affero General Public License v3.0 or later**
([LICENSE](https://github.com/takatsuka/mtGeodesicDome/blob/main/LICENSE)), with an additional attribution term
([NOTICE](https://github.com/takatsuka/mtGeodesicDome/blob/main/NOTICE)). In short:

* **You may** use, study, modify and share it, including commercially.
* **If you distribute it,** or a modified version, or software that includes it, **or let people use a
  modified version over a network,** you must release the complete source code of that work under the
  same licence.
* **You must keep the attribution** to the author and to the 2006 paper, both in the source code and in
  the legal notices your software displays.
* There is no warranty.

**Commercial licence.** To use mtGeodesicDome in proprietary software without these obligations, contact
<masa@takatsuka.org> about a commercial licence.

**Contributing.** Contributions are welcome under the terms in
[CONTRIBUTING.md](https://github.com/takatsuka/mtGeodesicDome/blob/main/CONTRIBUTING.md).
