-### 2) Power/Laguerre tessellation (weighted Voronoi)
+### 2) Planar periodic workflow
+
+```python
+import numpy as np
+import pyvoro2.planar as pv2
+
+pts2 = np.array([
+ [0.2, 0.2],
+ [0.8, 0.25],
+ [0.4, 0.8],
+], dtype=float)
+
+cell2 = pv2.RectangularCell(((0.0, 1.0), (0.0, 1.0)), periodic=(True, True))
+result2 = pv2.compute(
+ pts2,
+ domain=cell2,
+ return_diagnostics=True,
+ normalize='topology',
+)
+
+diag2 = result2.require_tessellation_diagnostics()
+topo2 = result2.require_normalized_topology()
+```
+
+### 3) Power/Laguerre tessellation (weighted Voronoi)
```python
radii = np.full(len(points), 1.2)
@@ -60,7 +90,7 @@ cells = pv.compute(
)
```
-### 3) Periodic crystal cell with neighbor image shifts
+### 4) Periodic crystal cell with neighbor image shifts
```python
cell = pv.PeriodicCell(
@@ -99,6 +129,8 @@ For stricter post-hoc checks, see:
- `pyvoro2.validate_tessellation(..., level='strict')`
- `pyvoro2.validate_normalized_topology(..., level='strict')`
+- `pyvoro2.planar.validate_tessellation(..., level='strict')`
+- `pyvoro2.planar.validate_normalized_topology(..., level='strict')`
Note: pyvoro2 vendors a Voro++ snapshot that includes the upstream numeric robustness fix for
*power/Laguerre* mode (radical pruning). This avoids rare cross-platform edge cases where fully
@@ -111,14 +143,15 @@ Voro++ is fast and feature-rich, but it is a C++ library with a low-level API.
pyvoro2 aims to be a *scientific* interface that stays close to Voro++ while adding
practical pieces that are easy to get wrong:
-- **triclinic periodic cells** (`PeriodicCell`) with robust coordinate mapping
+- **triclinic periodic cells** (`PeriodicCell`) with robust coordinate mapping in 3D
- **partially periodic orthorhombic cells** (`OrthorhombicCell`) for slabs and wires
-- optional **per-face periodic image shifts** (`adjacent_shift`) for building periodic graphs
+- dedicated **planar 2D support** in `pyvoro2.planar` for boxes and rectangular periodic cells
+- optional **periodic image shifts** (`adjacent_shift`) on faces/edges for building periodic graphs
- **diagnostics** and **normalization utilities** for reproducible topology work
- convenience operations beyond full tessellation:
- - `locate(...)` (owner lookup for arbitrary query points)
- - `ghost_cells(...)` (probe cell at a query point without inserting it)
- - inverse fitting utilities for **fitting power weights** from desired pairwise plane locations
+ - `locate(...)` / `pyvoro2.planar.locate(...)` (owner lookup for arbitrary query points)
+ - `ghost_cells(...)` / `pyvoro2.planar.ghost_cells(...)` (probe cell at a query point without inserting it)
+ - power-fitting utilities for **fitting power weights** from desired pairwise separator locations in both 2D and 3D
## Documentation overview
@@ -129,13 +162,14 @@ implementation-oriented details.
| Section | What it contains |
|---|---|
| [Concepts](https://delonecommons.github.io/pyvoro2/guide/concepts/) | What Voronoi and power/Laguerre tessellations are, and what you can expect from them. |
-| [Domains](https://delonecommons.github.io/pyvoro2/guide/domains/) | Which containers exist (`Box`, `OrthorhombicCell`, `PeriodicCell`) and how to choose between them. |
-| [Operations](https://delonecommons.github.io/pyvoro2/guide/operations/) | How to compute tessellations, assign query points, and compute probe (ghost) cells. |
-| [Topology and graphs](https://delonecommons.github.io/pyvoro2/guide/topology/) | How to build a neighbor graph that respects periodic images, and how normalization helps. |
-| [Inverse fitting](https://delonecommons.github.io/pyvoro2/guide/inverse/) | Fit power/Laguerre radii from desired pairwise plane positions (with optional constraints/penalties). |
-| [Visualization](https://delonecommons.github.io/pyvoro2/guide/visualization/) | Optional py3Dmol helpers for debugging and exploratory analysis. |
-| [Examples (notebooks)](https://delonecommons.github.io/pyvoro2/notebooks/01_basic_compute/) | End-to-end examples that combine the pieces above. |
-| [API reference](https://delonecommons.github.io/pyvoro2/reference/api/) | The full reference (docstrings). |
+| [Domains (3D)](https://delonecommons.github.io/pyvoro2/guide/domains/) | Which spatial containers exist (`Box`, `OrthorhombicCell`, `PeriodicCell`) and how to choose between them. |
+| [Planar (2D)](https://delonecommons.github.io/pyvoro2/guide/planar/) | The planar namespace, current 2D domain scope, wrapper-level diagnostics/normalization convenience, and plotting. |
+| [Operations](https://delonecommons.github.io/pyvoro2/guide/operations/) | How to compute tessellations, assign query points, and compute probe (ghost) cells in the 3D and planar namespaces. |
+| [Topology and graphs](https://delonecommons.github.io/pyvoro2/guide/topology/) | How to build periodic neighbor graphs and how normalization helps in both 2D and 3D. |
+| [Power fitting](https://delonecommons.github.io/pyvoro2/guide/powerfit/) | Fit power weights from pairwise bisector constraints, realized-boundary matching, and self-consistent active sets in 2D or 3D. |
+| [Visualization](https://delonecommons.github.io/pyvoro2/guide/visualization/) | Optional `py3Dmol` / `matplotlib` helpers for debugging and exploratory analysis. |
+| [Examples (notebooks)](https://delonecommons.github.io/pyvoro2/guide/notebooks/) | End-to-end examples, including focused power-fitting notebooks for reports, infeasibility witnesses, and active-set path diagnostics. |
+| [API reference](https://delonecommons.github.io/pyvoro2/reference/planar/) | The full reference (docstrings) for both the spatial and planar APIs. |
## Installation
@@ -145,12 +179,25 @@ Most users should install a prebuilt wheel:
pip install pyvoro2
```
+Optional extras:
+
+- `pyvoro2[viz]` for the 3D `py3Dmol` viewer (and 2D plotting too)
+- `pyvoro2[viz2d]` for 2D matplotlib plotting only
+- `pyvoro2[all]` to install the full optional stack used for local notebook,
+ docs, lint, and publishability checks
+
To build from source (requires a C++ compiler and Python development headers):
```bash
pip install -e .
```
+For contributor-style local validation, install the full optional stack:
+
+```bash
+pip install -e ".[all]"
+```
+
## Testing
pyvoro2 uses **pytest**. The default test suite is intended to be fast and deterministic:
@@ -183,14 +230,23 @@ Additional test groups are **opt-in**:
Tip: you can combine markers, e.g. `pytest -m "fuzz and pyvoro" --fuzz-n 100`.
+## Release and publishability checks
+
+For a one-shot local publishability pass (lint, notebook execution, exported notebook sync, README sync, tests, docs, build, metadata checks, and wheel smoke test):
+
+```bash
+python tools/release_check.py
+```
+
## Project status
pyvoro2 is currently in **beta**.
-The core tessellation modes (standard and power/Laguerre) are stable, and a large
-part of the work in this repository focuses on tests and documentation.
-A future 1.0 release is planned once the inverse-fitting workflow is more mature
-and native 2D support is added.
+The core tessellation modes (standard and power/Laguerre) are stable, and the
+0.6.0 release now includes a first-class planar namespace.
+A future 1.0 release is planned once the inverse-fitting workflow is more mature,
+its disconnected-graph / coverage diagnostics are stabilized, and the project has
+reassessed whether planar `PeriodicCell` support is actually needed.
## AI-assisted development
@@ -202,8 +258,9 @@ Details are documented in the [AI usage](https://delonecommons.github.io/pyvoro2
## License
-- pyvoro2 is released under the **MIT License**.
-- Voro++ is vendored and redistributed under its original license (see the project pages).
+- Starting with **0.6.0**, the pyvoro2-authored code is released under the **GNU Lesser General Public License v3.0 or later (LGPLv3+)**.
+- Versions **before 0.6.0** were released under the **MIT License**.
+- Voro++ is vendored and redistributed under its original upstream license.
---
diff --git a/cpp/bindings2d.cpp b/cpp/bindings2d.cpp
new file mode 100644
index 0000000..dac31af
--- /dev/null
+++ b/cpp/bindings2d.cpp
@@ -0,0 +1,543 @@
+#include
-### 2) Power/Laguerre tessellation (weighted Voronoi)
+### 2) Planar periodic workflow
+
+```python
+import numpy as np
+import pyvoro2.planar as pv2
+
+pts2 = np.array([
+ [0.2, 0.2],
+ [0.8, 0.25],
+ [0.4, 0.8],
+], dtype=float)
+
+cell2 = pv2.RectangularCell(((0.0, 1.0), (0.0, 1.0)), periodic=(True, True))
+result2 = pv2.compute(
+ pts2,
+ domain=cell2,
+ return_diagnostics=True,
+ normalize='topology',
+)
+
+diag2 = result2.require_tessellation_diagnostics()
+topo2 = result2.require_normalized_topology()
+```
+
+### 3) Power/Laguerre tessellation (weighted Voronoi)
```python
radii = np.full(len(points), 1.2)
@@ -53,7 +83,7 @@ cells = pv.compute(
)
```
-### 3) Periodic crystal cell with neighbor image shifts
+### 4) Periodic crystal cell with neighbor image shifts
```python
cell = pv.PeriodicCell(
@@ -92,6 +122,8 @@ For stricter post-hoc checks, see:
- `pyvoro2.validate_tessellation(..., level='strict')`
- `pyvoro2.validate_normalized_topology(..., level='strict')`
+- `pyvoro2.planar.validate_tessellation(..., level='strict')`
+- `pyvoro2.planar.validate_normalized_topology(..., level='strict')`
Note: pyvoro2 vendors a Voro++ snapshot that includes the upstream numeric robustness fix for
*power/Laguerre* mode (radical pruning). This avoids rare cross-platform edge cases where fully
@@ -104,14 +136,15 @@ Voro++ is fast and feature-rich, but it is a C++ library with a low-level API.
pyvoro2 aims to be a *scientific* interface that stays close to Voro++ while adding
practical pieces that are easy to get wrong:
-- **triclinic periodic cells** (`PeriodicCell`) with robust coordinate mapping
+- **triclinic periodic cells** (`PeriodicCell`) with robust coordinate mapping in 3D
- **partially periodic orthorhombic cells** (`OrthorhombicCell`) for slabs and wires
-- optional **per-face periodic image shifts** (`adjacent_shift`) for building periodic graphs
+- dedicated **planar 2D support** in `pyvoro2.planar` for boxes and rectangular periodic cells
+- optional **periodic image shifts** (`adjacent_shift`) on faces/edges for building periodic graphs
- **diagnostics** and **normalization utilities** for reproducible topology work
- convenience operations beyond full tessellation:
- - `locate(...)` (owner lookup for arbitrary query points)
- - `ghost_cells(...)` (probe cell at a query point without inserting it)
- - inverse fitting utilities for **fitting power weights** from desired pairwise plane locations
+ - `locate(...)` / `pyvoro2.planar.locate(...)` (owner lookup for arbitrary query points)
+ - `ghost_cells(...)` / `pyvoro2.planar.ghost_cells(...)` (probe cell at a query point without inserting it)
+ - power-fitting utilities for **fitting power weights** from desired pairwise separator locations in both 2D and 3D
## Documentation overview
@@ -122,13 +155,14 @@ implementation-oriented details.
| Section | What it contains |
|---|---|
| [Concepts](guide/concepts.md) | What Voronoi and power/Laguerre tessellations are, and what you can expect from them. |
-| [Domains](guide/domains.md) | Which containers exist (`Box`, `OrthorhombicCell`, `PeriodicCell`) and how to choose between them. |
-| [Operations](guide/operations.md) | How to compute tessellations, assign query points, and compute probe (ghost) cells. |
-| [Topology and graphs](guide/topology.md) | How to build a neighbor graph that respects periodic images, and how normalization helps. |
-| [Inverse fitting](guide/inverse.md) | Fit power/Laguerre radii from desired pairwise plane positions (with optional constraints/penalties). |
-| [Visualization](guide/visualization.md) | Optional py3Dmol helpers for debugging and exploratory analysis. |
-| [Examples (notebooks)](notebooks/01_basic_compute.ipynb) | End-to-end examples that combine the pieces above. |
-| [API reference](reference/api.md) | The full reference (docstrings). |
+| [Domains (3D)](guide/domains.md) | Which spatial containers exist (`Box`, `OrthorhombicCell`, `PeriodicCell`) and how to choose between them. |
+| [Planar (2D)](guide/planar.md) | The planar namespace, current 2D domain scope, wrapper-level diagnostics/normalization convenience, and plotting. |
+| [Operations](guide/operations.md) | How to compute tessellations, assign query points, and compute probe (ghost) cells in the 3D and planar namespaces. |
+| [Topology and graphs](guide/topology.md) | How to build periodic neighbor graphs and how normalization helps in both 2D and 3D. |
+| [Power fitting](guide/powerfit.md) | Fit power weights from pairwise bisector constraints, realized-boundary matching, and self-consistent active sets in 2D or 3D. |
+| [Visualization](guide/visualization.md) | Optional `py3Dmol` / `matplotlib` helpers for debugging and exploratory analysis. |
+| [Examples (notebooks)](guide/notebooks.md) | End-to-end examples, including focused power-fitting notebooks for reports, infeasibility witnesses, and active-set path diagnostics. |
+| [API reference](reference/planar/index.md) | The full reference (docstrings) for both the spatial and planar APIs. |
## Installation
@@ -138,12 +172,25 @@ Most users should install a prebuilt wheel:
pip install pyvoro2
```
+Optional extras:
+
+- `pyvoro2[viz]` for the 3D `py3Dmol` viewer (and 2D plotting too)
+- `pyvoro2[viz2d]` for 2D matplotlib plotting only
+- `pyvoro2[all]` to install the full optional stack used for local notebook,
+ docs, lint, and publishability checks
+
To build from source (requires a C++ compiler and Python development headers):
```bash
pip install -e .
```
+For contributor-style local validation, install the full optional stack:
+
+```bash
+pip install -e ".[all]"
+```
+
## Testing
pyvoro2 uses **pytest**. The default test suite is intended to be fast and deterministic:
@@ -176,14 +223,23 @@ Additional test groups are **opt-in**:
Tip: you can combine markers, e.g. `pytest -m "fuzz and pyvoro" --fuzz-n 100`.
+## Release and publishability checks
+
+For a one-shot local publishability pass (lint, notebook execution, exported notebook sync, README sync, tests, docs, build, metadata checks, and wheel smoke test):
+
+```bash
+python tools/release_check.py
+```
+
## Project status
pyvoro2 is currently in **beta**.
-The core tessellation modes (standard and power/Laguerre) are stable, and a large
-part of the work in this repository focuses on tests and documentation.
-A future 1.0 release is planned once the inverse-fitting workflow is more mature
-and native 2D support is added.
+The core tessellation modes (standard and power/Laguerre) are stable, and the
+0.6.0 release now includes a first-class planar namespace.
+A future 1.0 release is planned once the inverse-fitting workflow is more mature,
+its disconnected-graph / coverage diagnostics are stabilized, and the project has
+reassessed whether planar `PeriodicCell` support is actually needed.
## AI-assisted development
@@ -195,5 +251,6 @@ Details are documented in the [AI usage](project/ai.md) page.
## License
-- pyvoro2 is released under the **MIT License**.
-- Voro++ is vendored and redistributed under its original license (see the project pages).
+- Starting with **0.6.0**, the pyvoro2-authored code is released under the **GNU Lesser General Public License v3.0 or later (LGPLv3+)**.
+- Versions **before 0.6.0** were released under the **MIT License**.
+- Voro++ is vendored and redistributed under its original upstream license.
diff --git a/docs/notebooks/01_basic_compute.md b/docs/notebooks/01_basic_compute.md
new file mode 100644
index 0000000..92413c4
--- /dev/null
+++ b/docs/notebooks/01_basic_compute.md
@@ -0,0 +1,490 @@
+
+
+[Open the original notebook on GitHub](https://github.com/DeloneCommons/pyvoro2/blob/main/notebooks/01_basic_compute.ipynb)
+# Basic tessellations in pyvoro2
+
+This notebook is a compact tour of the most common `pyvoro2.compute(...)` workflows.
+It is written as a narrative: each section introduces the geometric idea first, and then shows
+the minimal code needed to reproduce it.
+
+We cover:
+- Voronoi cells in a non-periodic **bounding box** (`Box`)
+- Voronoi cells in a **triclinic periodic unit cell** (`PeriodicCell`)
+- Power/Laguerre tessellation (`mode='power'`) and the meaning of per-site radii
+- What geometry is returned (`vertices`, `faces`, `adjacency`)
+- Periodic face shifts (`adjacent_shift`) and basic diagnostics
+- Global enumeration utilities (`normalize_topology`) and per-face descriptors
+
+> Tip: If you are new to Voronoi terminology, the short conceptual background is in
+> the docs section [Concepts](../guide/concepts.md).
+```python
+import numpy as np
+from pprint import pprint
+
+import pyvoro2 as pv
+from pyvoro2 import Box, OrthorhombicCell, PeriodicCell, compute
+```
+## Voronoi tessellation in a bounding box (Box)
+
+In a non-periodic domain, the Voronoi cell of a site is the region of space that is closer
+to that site than to any other site. In practice, we also need a finite *domain* to cut
+the unbounded cells — here we use a rectangular `Box`.
+```python
+pts = np.array(
+ [
+ [0.0, 0.0, 0.0],
+ [2.0, 0.0, 0.0],
+ [0.0, 2.0, 0.0],
+ [0.0, 0.0, 2.0],
+ ],
+ dtype=float,
+)
+
+box = Box(bounds=((-5.0, 5.0), (-5.0, 5.0), (-5.0, 5.0)))
+
+cells = compute(
+ pts,
+ domain=box,
+ mode='standard',
+ return_vertices=True,
+ return_faces=True,
+ return_adjacency=False, # keep output small for display
+)
+
+print(f'Total number of cells: {len(cells)}\n')
+pprint(cells[0])
+```
+**Output**
+
+```text
+Total number of cells: 4
+
+{'faces': [{'adjacent_cell': 1, 'vertices': [1, 5, 7, 3]},
+ {'adjacent_cell': -3, 'vertices': [1, 0, 4, 5]},
+ {'adjacent_cell': -5, 'vertices': [1, 3, 2, 0]},
+ {'adjacent_cell': 2, 'vertices': [2, 3, 7, 6]},
+ {'adjacent_cell': -1, 'vertices': [2, 6, 4, 0]},
+ {'adjacent_cell': 3, 'vertices': [4, 6, 7, 5]}],
+ 'id': 0,
+ 'site': [0.0, 0.0, 0.0],
+ 'vertices': [[-5.0, -5.0, -5.0],
+ [1.0, -5.0, -5.0],
+ [-5.0, 1.0, -5.0],
+ [1.0, 1.0, -5.0],
+ [-5.0, -5.0, 1.0],
+ [1.0, -5.0, 1.0],
+ [-5.0, 1.0, 1.0],
+ [1.0, 1.0, 1.0]],
+ 'volume': 216.0}
+```
+## Periodic tessellation in a triclinic unit cell (PeriodicCell)
+
+For crystals and other periodic systems, the natural domain is a unit cell with periodic boundary
+conditions. `PeriodicCell` supports fully triclinic (skew) cells by representing the cell with
+three lattice vectors.
+
+A useful sanity check: in a fully periodic Voronoi tessellation, the sum of all cell volumes
+should equal the unit cell volume (up to numerical tolerance).
+```python
+cell = PeriodicCell(
+ vectors=(
+ (10.0, 0.0, 0.0),
+ (2.0, 9.5, 0.0),
+ (1.0, 0.5, 9.0),
+ )
+)
+
+pts_pbc = np.array(
+ [
+ [1.0, 1.0, 1.0],
+ [5.0, 5.0, 5.0],
+ [8.0, 2.0, 7.0],
+ [3.0, 9.0, 4.0],
+ ],
+ dtype=float,
+)
+
+cells_pbc = compute(
+ pts_pbc,
+ domain=cell,
+ mode='standard',
+ return_vertices=False,
+ return_faces=False,
+ return_adjacency=False,
+)
+
+# In periodic mode, all Voronoi volumes should sum to the unit cell volume.
+cell_volume = abs(np.linalg.det(np.array(cell.vectors, dtype=float)))
+sum_vol = float(sum(c['volume'] for c in cells_pbc))
+cell_volume, sum_vol
+```
+**Output**
+
+```text
+(855.0000000000013, 855.0)
+```
+## Power/Laguerre tessellation (mode="power")
+
+A power (Laguerre) tessellation generalizes Voronoi cells by assigning each site a weight.
+Voro++ (and pyvoro2) expose this as a per-site **radius** $r_i$, which corresponds to a weight
+$w_i = r_i^2$ in the power distance.
+
+Intuitively: increasing a site's radius tends to expand its cell at the expense of neighbors.
+Unlike standard Voronoi cells, **empty cells are possible** in power mode.
+```python
+# Re-define the periodic cell and points (self-contained example)
+cell = PeriodicCell(
+ vectors=(
+ (10.0, 0.0, 0.0),
+ (2.0, 9.5, 0.0),
+ (1.0, 0.5, 9.0),
+ )
+)
+
+pts_pbc = np.array(
+ [
+ [1.0, 1.0, 1.0],
+ [5.0, 5.0, 5.0],
+ [8.0, 2.0, 7.0],
+ [3.0, 9.0, 4.0],
+ ],
+ dtype=float,
+)
+
+radii = np.array([0.0, 0.0, 2.0, 0.0], dtype=float)
+
+cells_std = compute(
+ pts_pbc,
+ domain=cell,
+ mode='standard',
+ return_vertices=False,
+ return_faces=False,
+ return_adjacency=False,
+)
+
+cells_pow = compute(
+ pts_pbc,
+ domain=cell,
+ mode='power',
+ radii=radii,
+ return_vertices=False,
+ return_faces=False,
+ return_adjacency=False,
+)
+
+vols_std = [c['volume'] for c in cells_std]
+vols_pow = [c['volume'] for c in cells_pow]
+
+vols_std, vols_pow
+```
+**Output**
+
+```text
+([204.52350840152917,
+ 243.35630134069405,
+ 231.409081979397,
+ 175.71110827837984],
+ [177.66314170369014,
+ 213.6503389726455,
+ 307.3025551674562,
+ 156.38396415620826])
+```
+## Inspecting geometry: vertices, faces, adjacency
+
+`compute(...)` can return different levels of geometric detail. For downstream analysis, the most
+important pieces are:
+
+- `vertices`: coordinates of the cell vertices
+- `faces`: polygonal faces (each includes the list of vertex indices and the adjacent cell id)
+- `adjacency`: per-vertex adjacency lists (optional)
+
+The cell dictionaries are designed to be plain data (NumPy arrays + Python lists), so you can
+serialize them or process them with your own code.
+```python
+# Re-define the 0D box system (self-contained example)
+pts = np.array(
+ [
+ [0.0, 0.0, 0.0],
+ [2.0, 0.0, 0.0],
+ [0.0, 2.0, 0.0],
+ [0.0, 0.0, 2.0],
+ ],
+ dtype=float,
+)
+
+box = Box(bounds=((-5.0, 5.0), (-5.0, 5.0), (-5.0, 5.0)))
+
+cells_full = compute(
+ pts,
+ domain=box,
+ mode='standard',
+ return_vertices=True,
+ return_faces=True,
+ return_adjacency=True,
+)
+
+pprint(cells_full[0])
+```
+**Output**
+
+```text
+{'adjacency': [[1, 4, 2],
+ [5, 0, 3],
+ [3, 0, 6],
+ [7, 1, 2],
+ [6, 0, 5],
+ [4, 1, 7],
+ [7, 2, 4],
+ [5, 3, 6]],
+ 'faces': [{'adjacent_cell': 1, 'vertices': [1, 5, 7, 3]},
+ {'adjacent_cell': -3, 'vertices': [1, 0, 4, 5]},
+ {'adjacent_cell': -5, 'vertices': [1, 3, 2, 0]},
+ {'adjacent_cell': 2, 'vertices': [2, 3, 7, 6]},
+ {'adjacent_cell': -1, 'vertices': [2, 6, 4, 0]},
+ {'adjacent_cell': 3, 'vertices': [4, 6, 7, 5]}],
+ 'id': 0,
+ 'site': [0.0, 0.0, 0.0],
+ 'vertices': [[-5.0, -5.0, -5.0],
+ [1.0, -5.0, -5.0],
+ [-5.0, 1.0, -5.0],
+ [1.0, 1.0, -5.0],
+ [-5.0, -5.0, 1.0],
+ [1.0, -5.0, 1.0],
+ [-5.0, 1.0, 1.0],
+ [1.0, 1.0, 1.0]],
+ 'volume': 216.0}
+```
+## Empty cells in power mode (include_empty=True)
+
+In a power diagram, some sites can be dominated by others and end up with **zero volume**.
+This is mathematically valid. If you want these cases to appear explicitly in the output,
+use `include_empty=True`.
+```python
+cell_u = PeriodicCell(vectors=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)))
+pts_u = np.array([[0.1, 0.5, 0.5], [0.9, 0.5, 0.5]], dtype=float)
+radii_u = np.array([1.0, 2.0], dtype=float)
+
+cells_pow = compute(
+ pts_u,
+ domain=cell_u,
+ mode='power',
+ radii=radii_u,
+ include_empty=True,
+ return_vertices=True,
+ return_faces=True,
+ return_adjacency=False,
+ return_face_shifts=True,
+ face_shift_search=1,
+)
+
+[(int(c['id']), c.get('empty', False), float(c.get('volume', 0.0))) for c in cells_pow]
+```
+**Output**
+
+```text
+[(0, True, 0.0), (1, False, 0.9999999999999997)]
+```
+## Periodic face shifts and diagnostics
+
+In periodic domains, an adjacency is not just “site *i* touches site *j*”. The shared face is formed
+with a **particular periodic image** of *j*. pyvoro2 can annotate each face with an integer lattice
+shift `adjacent_shift = (na, nb, nc)`.
+
+This section also shows how to request diagnostics when you want to actively validate a tessellation.
+```python
+cell_u = PeriodicCell(vectors=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)))
+pts_u = np.array([[0.1, 0.5, 0.5], [0.9, 0.5, 0.5]], dtype=float)
+
+cells_std_u, diag_std_u = compute(
+ pts_u,
+ domain=cell_u,
+ mode='standard',
+ return_vertices=True,
+ return_faces=True,
+ return_adjacency=False,
+ return_face_shifts=True,
+ face_shift_search=1,
+ tessellation_check='diagnose',
+ return_diagnostics=True,
+)
+
+# Inspect the face between the two sites across the x-boundary.
+c0 = next(c for c in cells_std_u if int(c['id']) == 0)
+idx = next(i for i, f in enumerate(c0['faces']) if int(f['adjacent_cell']) == 1)
+face01 = c0['faces'][idx]
+
+(diag_std_u.ok, diag_std_u.volume_ratio, diag_std_u.n_faces_orphan), face01
+```
+**Output**
+
+```text
+((True, 1.0, 0),
+ {'adjacent_cell': 1,
+ 'vertices': [1, 6, 4, 5],
+ 'adjacent_shift': (-1, 0, 0),
+ 'orphan': False,
+ 'reciprocal_mismatch': False,
+ 'reciprocal_missing': False})
+```
+## Normalization: global vertices / edges / faces
+
+When you compute cells, each cell has its own local vertex indexing. For graph and topology work,
+it is often helpful to build a **global** pool of vertices/edges/faces with stable IDs that are
+consistent across cells.
+
+`normalize_topology(...)` can mutate cell dicts (unless `copy_cells=True`) and adds global-id arrays
+such as `vertex_global_id` and `face_global_id`.
+```python
+from pyvoro2 import normalize_topology
+
+cell_n = PeriodicCell(vectors=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)))
+pts_n = np.array([[0.1, 0.5, 0.5], [0.9, 0.5, 0.5]], dtype=float)
+
+cells_n = compute(
+ pts_n,
+ domain=cell_n,
+ mode='standard',
+ return_vertices=True,
+ return_faces=True,
+ return_adjacency=False,
+ return_face_shifts=True,
+ face_shift_search=1,
+)
+
+# Pick the periodic wrap face (0 -> 1 across x-wrap)
+c0 = next(c for c in cells_n if int(c['id']) == 0)
+idx = next(
+ i
+ for i, f in enumerate(c0['faces'])
+ if int(f['adjacent_cell']) == 1 and tuple(int(x) for x in f['adjacent_shift']) == (-1, 0, 0)
+)
+
+# Mutate in place so the original cell dictionaries gain global id fields.
+nt = normalize_topology(cells_n, domain=cell_n, copy_cells=False)
+
+n_global = (len(nt.global_vertices), len(nt.global_edges), len(nt.global_faces))
+
+# Example: show the face's global id and its global vertex ids
+fid0 = int(c0['face_global_id'][idx])
+print(f'Global counts for vertices, edges, and faces: {n_global}')
+print('\nGlobal face data:')
+pprint(nt.global_faces[fid0])
+print('\nUpdated cell:')
+pprint(c0)
+```
+**Output**
+
+```text
+Global counts for vertices, edges, and faces: (16, 24, 6)
+
+Global face data:
+{'cell_shifts': ((0, 0, 0), (-1, 0, 0)),
+ 'cells': (0, 1),
+ 'vertex_shifts': [(0, 0, 0), (0, 1, 0), (0, 1, -1), (0, 0, -1)],
+ 'vertices': [1, 5, 4, 6]}
+
+Updated cell:
+{'edge_global_id': [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
+ 'edges': [(0, 3),
+ (0, 4),
+ (0, 7),
+ (1, 2),
+ (1, 5),
+ (1, 6),
+ (2, 3),
+ (2, 7),
+ (3, 5),
+ (4, 5),
+ (4, 6),
+ (6, 7)],
+ 'face_global_id': [0, 1, 2, 3, 0, 1],
+ 'faces': [{'adjacent_cell': 0,
+ 'adjacent_shift': (0, -1, 0),
+ 'vertices': [1, 2, 7, 6]},
+ {'adjacent_cell': 0,
+ 'adjacent_shift': (0, 0, 1),
+ 'vertices': [1, 5, 3, 2]},
+ {'adjacent_cell': 1,
+ 'adjacent_shift': (-1, 0, 0),
+ 'vertices': [1, 6, 4, 5]},
+ {'adjacent_cell': 1,
+ 'adjacent_shift': (0, 0, 0),
+ 'vertices': [2, 3, 0, 7]},
+ {'adjacent_cell': 0,
+ 'adjacent_shift': (0, 1, 0),
+ 'vertices': [3, 5, 4, 0]},
+ {'adjacent_cell': 0,
+ 'adjacent_shift': (0, 0, -1),
+ 'vertices': [4, 6, 7, 0]}],
+ 'id': 0,
+ 'site': [0.1, 0.5, 0.5],
+ 'vertex_global_id': [0, 1, 2, 3, 4, 5, 6, 7],
+ 'vertex_shift': [(0, 1, 0),
+ (0, 0, 1),
+ (0, 0, 1),
+ (0, 1, 1),
+ (0, 1, 0),
+ (0, 1, 1),
+ (0, 0, 0),
+ (0, 0, 0)],
+ 'vertices': [[0.5, 1.0, 0.0],
+ [-1.3877787807814457e-16, 0.0, 1.0],
+ [0.5, 0.0, 1.0],
+ [0.5, 1.0, 0.9999999999999998],
+ [-1.3877787807814457e-16, 1.0, 0.0],
+ [-1.3877787807814457e-16, 1.0, 1.0],
+ [-1.3877787807814457e-16, 0.0, 0.0],
+ [0.5, 0.0, 1.1102230246251565e-16]],
+ 'volume': 0.5000000000000001}
+```
+## Face properties: contact descriptors
+
+`annotate_face_properties(...)` computes per-face descriptors (centroid, normal, and intersection
+with the site-to-site line) that are often useful for contact analysis.
+```python
+from pyvoro2 import annotate_face_properties
+
+cell_f = PeriodicCell(vectors=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)))
+pts_f = np.array([[0.1, 0.5, 0.5], [0.9, 0.5, 0.5]], dtype=float)
+
+cells_f, diag_f = compute(
+ pts_f,
+ domain=cell_f,
+ mode='standard',
+ return_vertices=True,
+ return_faces=True,
+ return_adjacency=False,
+ return_face_shifts=True,
+ face_shift_search=1,
+ tessellation_check='diagnose',
+ return_diagnostics=True,
+)
+
+c0 = next(c for c in cells_f if int(c['id']) == 0)
+idx = next(
+ i
+ for i, f in enumerate(c0['faces'])
+ if int(f['adjacent_cell']) == 1 and tuple(int(x) for x in f['adjacent_shift']) == (-1, 0, 0)
+)
+
+annotate_face_properties(cells_f, domain=cell_f, diagnostics=diag_f)
+f = c0['faces'][idx]
+{
+ 'centroid': f.get('centroid'),
+ 'normal': f.get('normal'),
+ 'intersection': f.get('intersection'),
+ 'intersection_inside': f.get('intersection_inside'),
+ 'intersection_centroid_dist': f.get('intersection_centroid_dist'),
+ 'intersection_edge_min_dist': f.get('intersection_edge_min_dist'),
+}
+```
+**Output**
+
+```text
+{'centroid': [-1.3877787807814457e-16, 0.5, 0.5],
+ 'normal': [-1.0, -0.0, -0.0],
+ 'intersection': [-1.3877787807814457e-16, 0.5, 0.5],
+ 'intersection_inside': True,
+ 'intersection_centroid_dist': 0.0,
+ 'intersection_edge_min_dist': 0.5}
+```
diff --git a/docs/notebooks/02_periodic_graph.md b/docs/notebooks/02_periodic_graph.md
new file mode 100644
index 0000000..1d598f1
--- /dev/null
+++ b/docs/notebooks/02_periodic_graph.md
@@ -0,0 +1,162 @@
+
+
+[Open the original notebook on GitHub](https://github.com/DeloneCommons/pyvoro2/blob/main/notebooks/02_periodic_graph.ipynb)
+# Periodic tessellation and neighbor graphs
+
+In non-periodic geometry, a Voronoi tessellation naturally defines a **neighbor graph**:
+two sites are neighbors if their cells share a face.
+
+In a **periodic** domain, there is an additional subtlety:
+
+- every site has infinitely many periodic images,
+- a face between sites *i* and *j* is formed with a **specific image** of *j*.
+
+If you want a graph that is correct for crystals, you typically need that image information.
+pyvoro2 can annotate each face with an integer lattice shift:
+
+- `adjacent_cell`: neighbor id
+- `adjacent_shift = (na, nb, nc)`: which periodic image produced the face
+
+This notebook shows a minimal workflow:
+1. compute a periodic tessellation in a triclinic cell,
+2. extract graph edges `(i, j, shift)`,
+3. canonicalize edges into an undirected contact list.
+```python
+import numpy as np
+import pyvoro2 as pv
+
+rng = np.random.default_rng(0)
+
+# Random points in Cartesian coordinates (not necessarily wrapped)
+points = rng.random((30, 3))
+
+cell = pv.PeriodicCell(
+ vectors=((10.0, 0.0, 0.0), (2.0, 9.0, 0.0), (1.0, 0.5, 8.0)),
+ origin=(0.0, 0.0, 0.0),
+)
+
+cells = pv.compute(
+ points,
+ domain=cell,
+ return_faces=True,
+ return_vertices=True,
+ return_face_shifts=True, # <-- adds `adjacent_shift` to each face
+ face_shift_search=2,
+)
+len(cells)
+```
+**Output**
+
+```text
+30
+```
+## Inspecting face shifts
+
+For a well-formed periodic tessellation, face shifts should be **reciprocal**:
+if cell *i* has a face to neighbor *j* with shift *s*, then cell *j* should have the
+corresponding face back to *i* with shift `-s`.
+
+Let's inspect one example face.
+```python
+# Pick a cell and show its first non-boundary face
+c0 = next(c for c in cells if int(c['id']) == 0)
+f0 = next(f for f in c0['faces'] if int(f.get('adjacent_cell', -1)) >= 0)
+
+(i, j, shift) = (int(c0['id']), int(f0['adjacent_cell']), tuple(int(x) for x in f0['adjacent_shift']))
+(i, j, shift)
+```
+**Output**
+
+```text
+(0, 8, (0, 0, -1))
+```
+## Extracting a periodic neighbor graph
+
+A simple representation for periodic adjacency is a list of **directed** edges:
+
+- `(i, j, shift)`
+
+meaning: *cell i* touches the image of *cell j* translated by `shift`.
+
+Depending on your application, you may want to:
+- keep the graph directed (useful for some algorithms), or
+- canonicalize contacts into an **undirected** set by storing only one orientation.
+
+Below we build both.
+```python
+# 1) Directed edges from faces
+directed = []
+for c in cells:
+ i = int(c['id'])
+ for f in c.get('faces', []):
+ j = int(f.get('adjacent_cell', -1))
+ if j < 0:
+ continue
+ s = tuple(int(x) for x in f.get('adjacent_shift', (0, 0, 0)))
+ directed.append((i, j, s))
+
+print('n_directed:', len(directed))
+print('sample:', directed[:5])
+```
+**Output**
+
+```text
+n_directed: 436
+sample: [(0, 8, (0, 0, -1)), (0, 20, (0, 0, 0)), (0, 6, (0, 0, 0)), (0, 19, (0, 0, 0)), (0, 10, (0, 0, 0))]
+```
+```python
+# 2) Canonicalize into an undirected contact set
+#
+# We choose a convention:
+# - store edges with i < j
+# - if we flip direction, also flip the shift (reciprocity)
+undirected = set()
+for (i, j, s) in directed:
+ if i < j:
+ undirected.add((i, j, s))
+ elif j < i:
+ undirected.add((j, i, (-s[0], -s[1], -s[2])))
+
+print('n_undirected:', len(undirected))
+print('sample:', list(sorted(undirected))[:5])
+```
+**Output**
+
+```text
+n_undirected: 218
+sample: [(0, 3, (0, 0, 0)), (0, 6, (0, 0, 0)), (0, 8, (0, 0, -1)), (0, 10, (0, 0, 0)), (0, 14, (0, 0, 0))]
+```
+## Building an adjacency list
+
+Many downstream workflows prefer an adjacency list:
+
+- `adj[i] = [(j, shift), ...]`
+
+Here we build it from the directed edges.
+```python
+from collections import defaultdict
+
+adj = defaultdict(list)
+for (i, j, s) in directed:
+ adj[i].append((j, s))
+
+# Show the neighbors of site 0
+adj[0][:10]
+```
+**Output**
+
+```text
+[(8, (0, 0, -1)),
+ (20, (0, 0, 0)),
+ (6, (0, 0, 0)),
+ (19, (0, 0, 0)),
+ (10, (0, 0, 0)),
+ (14, (0, 0, 0)),
+ (3, (0, 0, 0))]
+```
+## Notes
+
+- For `OrthorhombicCell` with only partial periodicity, shifts on non-periodic axes are always zero.
+- If you plan to compute a graph repeatedly (e.g., for many frames), consider:
+ - keeping your inputs in a consistent wrapped form, and
+ - using `tessellation_check='warn'` or `'diagnose'` during development.
diff --git a/docs/notebooks/03_locate_and_ghost.md b/docs/notebooks/03_locate_and_ghost.md
new file mode 100644
index 0000000..4c44ce4
--- /dev/null
+++ b/docs/notebooks/03_locate_and_ghost.md
@@ -0,0 +1,97 @@
+
+
+[Open the original notebook on GitHub](https://github.com/DeloneCommons/pyvoro2/blob/main/notebooks/03_locate_and_ghost.ipynb)
+# Point queries: locate(...) and ghost_cells(...)
+
+A full tessellation (`compute`) gives you all cells at once. In many workflows you only need
+**local queries**:
+
+- **Owner lookup**: *which site owns this point?* → `locate(...)`
+- **Probe/ghost cell**: *what cell would a query point have if it were inserted?* → `ghost_cells(...)`
+
+Both operations are **stateless** in pyvoro2: each call builds a temporary Voro++ container,
+runs the query, and returns plain Python/NumPy outputs.
+
+This notebook demonstrates both operations in a non-periodic `Box`.
+```python
+import numpy as np
+from pprint import pprint
+
+import pyvoro2 as pv
+
+rng = np.random.default_rng(0)
+
+# Generator sites
+points = rng.uniform(-1.0, 1.0, size=(25, 3))
+
+box = pv.Box(((-2, 2), (-2, 2), (-2, 2)))
+```
+## 1) Owner lookup with locate(...)
+
+`locate(points, queries, domain=...)` returns, for each query point, whether it was located and
+which generator site owns it.
+
+- For a non-periodic `Box`, queries outside the box are typically reported as `found=False`.
+```python
+queries = np.array(
+ [
+ [0.0, 0.0, 0.0], # inside
+ [1.5, 1.5, 1.5], # inside (near boundary)
+ [5.0, 0.0, 0.0], # outside
+ ],
+ dtype=float,
+)
+
+res = pv.locate(
+ points,
+ queries,
+ domain=box,
+ return_owner_position=True,
+)
+
+pprint(res)
+```
+**Output**
+
+```text
+{'found': array([ True, True, False]),
+ 'owner_id': array([14, 9, -1]),
+ 'owner_pos': array([[ 0.18860006, -0.32417755, -0.216762 ],
+ [ 0.96167068, 0.37108397, 0.30091855],
+ [ nan, nan, nan]])}
+```
+## 2) Probe cells with ghost_cells(...)
+
+`ghost_cells(points, queries, domain=...)` computes the Voronoi cell **around each query point**
+without inserting it permanently into the point set.
+
+This is useful for:
+- sampling free volume at probe points,
+- inspecting local environments,
+- building “what-if” analyses without recomputing the entire tessellation.
+
+For a non-periodic `Box`, a query outside the box may yield an empty result when `include_empty=True`.
+```python
+ghost = pv.ghost_cells(
+ points,
+ queries,
+ domain=box,
+ include_empty=True,
+ return_vertices=True,
+ return_faces=True,
+)
+
+# Show a compact summary
+[(g['query_index'], bool(g.get('empty', False)), float(g.get('volume', 0.0))) for g in ghost]
+```
+**Output**
+
+```text
+[(0, False, 0.21887577215282997),
+ (1, False, 3.3710997729938335),
+ (2, True, 0.0)]
+```
+## Notes
+
+- In a periodic domain, `locate` and `ghost_cells` wrap queries into a primary domain.
+- In power mode (`mode='power'`), a ghost cell also needs a radius/weight for the query site (`ghost_radius`).
diff --git a/docs/notebooks/04_inverse_fit.ipynb b/docs/notebooks/04_inverse_fit.ipynb
deleted file mode 100644
index f723801..0000000
--- a/docs/notebooks/04_inverse_fit.ipynb
+++ /dev/null
@@ -1,311 +0,0 @@
-{
- "cells": [
- {
- "cell_type": "markdown",
- "id": "c76412f0a2844e34964ce3a2e7cbc84a",
- "metadata": {},
- "source": [
- "# Inverse fitting of power weights (Laguerre radii)\n",
- "\n",
- "Sometimes you do *not* want a purely distance-based partition. Instead, you may know\n",
- "(or hypothesize) where the **interface** between two sites should lie along the line\n",
- "connecting them.\n",
- "\n",
- "In a power/Laguerre tessellation, each site has a weight $w_i$ and the boundary between\n",
- "sites $i$ and $j$ is defined by equal **power distance**:\n",
- "\n",
- "$$\n",
- "\\lVert x - p_i\\rVert^2 - w_i \\;=\\; \\lVert x - p_j\\rVert^2 - w_j.\n",
- "$$\n",
- "\n",
- "Along the line segment $p_i \\to p_j$, the separating plane intersects at a fraction $t$\n",
- "(measured from $i$ toward $j$):\n",
- "\n",
- "$$\n",
- "t \\;=\\; \\tfrac12 + \\frac{w_i - w_j}{2\\,d^2}, \\qquad d=\\lVert p_j - p_i\\rVert.\n",
- "$$\n",
- "\n",
- "So a desired $t_{ij}$ constrains the **weight difference** $w_i - w_j$.\n",
- "\n",
- "pyvoro2 provides solvers that fit weights (and corresponding Voro++ radii $r_i=\\sqrt{w_i+C}$)\n",
- "from a list of constraints $(i, j, t_{ij})$.\n",
- "\n",
- "Important practical note:\n",
- "a constraint can be “algebraically satisfied” but the pair might still not become a **face** in the\n",
- "final tessellation (e.g. because a third site blocks it). The result object can optionally report\n",
- "such inactive constraints (`check_contacts=True`).\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 1,
- "id": "daf0256eb4c24037a1a010122fff1985",
- "metadata": {},
- "outputs": [],
- "source": [
- "import numpy as np\n",
- "from pprint import pprint\n",
- "\n",
- "import pyvoro2 as pv\n"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "b0a781f26d0f443a83013b7a79fdf764",
- "metadata": {},
- "source": [
- "## 1) Two-site example (easy to interpret)\n",
- "\n",
- "With only two sites, the separating plane is the only interface in the domain.\n",
- "We ask for $t=0.25$, i.e. the interface is closer to site 0 than to site 1.\n",
- "\n",
- "Here we also set `r_min=1.0` to choose a radii gauge where the smallest returned radius is 1.0.\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 2,
- "id": "ba10df2cbd3c4d85a85bab86883acb36",
- "metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "weights: [0. 2.]\n",
- "radii: [1. 1.73205081]\n",
- "t_target: [0.25]\n",
- "t_pred: [0.25]\n"
- ]
- }
- ],
- "source": [
- "points = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float)\n",
- "box = pv.Box(((-5, 5), (-5, 5), (-5, 5)))\n",
- "\n",
- "constraints = [(0, 1, 0.25)]\n",
- "\n",
- "fit = pv.fit_power_weights_from_plane_fractions(\n",
- " points,\n",
- " constraints,\n",
- " domain=box,\n",
- " t_bounds_mode='none',\n",
- " r_min=1.0,\n",
- ")\n",
- "\n",
- "print('weights:', fit.weights)\n",
- "print('radii:', fit.radii)\n",
- "print('t_target:', fit.t_target)\n",
- "print('t_pred:', fit.t_pred)\n"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "da700da1f3a04af78affd4a18033d2fc",
- "metadata": {},
- "source": [
- "Now use the fitted radii in an actual power tessellation and inspect the volumes.\n",
- "(For two points in a box, both cells are always present and share one face.)\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 3,
- "id": "7b09c37330a8446ea96e96338aa53ce6",
- "metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "volumes: [550.0, 449.9999999999999]\n"
- ]
- }
- ],
- "source": [
- "cells = pv.compute(\n",
- " points,\n",
- " domain=box,\n",
- " mode='power',\n",
- " radii=fit.radii,\n",
- " return_faces=True,\n",
- " return_vertices=True,\n",
- ")\n",
- "\n",
- "print('volumes:', [float(c['volume']) for c in cells])\n"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "96a55f1eb4964d4eb6be5c265ee96dc0",
- "metadata": {},
- "source": [
- "## 2) Allowing $t<0$ or $t>1$ and adding penalties\n",
- "\n",
- "In a power diagram, it is possible for the separating plane to lie **outside** the segment\n",
- "between the two sites ($t<0$ or $t>1$). This corresponds to one site strongly dominating\n",
- "the other.\n",
- "\n",
- "pyvoro2 lets you:\n",
- "\n",
- "- allow any $t$ values, and\n",
- "- optionally penalize or forbid predicted $t$ outside a chosen interval.\n",
- "\n",
- "Two common regimes are:\n",
- "\n",
- "- **soft bounds**: quadratic penalty when $t$ leaves $[0,1]$ (`t_bounds_mode='soft_quadratic'`)\n",
- "- **hard bounds**: infeasible outside $[0,1]$ (`t_bounds_mode='hard'`)\n",
- "\n",
- "You can also add an “avoid the endpoints” exponential penalty to discourage interfaces\n",
- "too close to $t=0$ or $t=1$ (`t_near_penalty='exp'`).\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 4,
- "id": "9fa78c05b21d4da7a2de5f9db0862cab",
- "metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "t_target: [1.4]\n",
- "t_pred: [0.90387053]\n",
- "warnings: ()\n"
- ]
- }
- ],
- "source": [
- "# An intentionally extreme constraint\n",
- "constraints2 = [(0, 1, 1.4)] # plane \"behind\" site 1 (t > 1)\n",
- "\n",
- "fit_soft = pv.fit_power_weights_from_plane_fractions(\n",
- " points,\n",
- " constraints2,\n",
- " domain=box,\n",
- " t_bounds_mode='soft_quadratic',\n",
- " alpha_out=5.0,\n",
- " t_near_penalty='exp',\n",
- " beta_near=1.0,\n",
- " t_margin=0.05,\n",
- " r_min=1.0,\n",
- ")\n",
- "\n",
- "print('t_target:', fit_soft.t_target)\n",
- "print('t_pred:', fit_soft.t_pred)\n",
- "print('warnings:', fit_soft.warnings)\n"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "eeca6a9ea2a844b5bb58dd625ee733b3",
- "metadata": {},
- "source": [
- "## 3) Multi-site example and contact checking\n",
- "\n",
- "With multiple sites, a requested pair `(i, j)` might not become adjacent in the final tessellation.\n",
- "Set `check_contacts=True` to have pyvoro2 compute a tessellation using the fitted radii and report\n",
- "which constraints correspond to actual faces.\n",
- "\n",
- "This is valuable when you plan to iterate:\n",
- "fit → compute tessellation → update constraints/weights.\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 5,
- "id": "2135561dce5c4fd9a3fd8b325c7837f4",
- "metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "rms_residual: 0.0\n",
- "inactive_constraints: (1,)\n"
- ]
- }
- ],
- "source": [
- "rng = np.random.default_rng(1)\n",
- "points3 = rng.uniform(-1.0, 1.0, size=(12, 3))\n",
- "box3 = pv.Box(((-2, 2), (-2, 2), (-2, 2)))\n",
- "\n",
- "# Pick a few arbitrary constraints. In a real workflow you would usually choose\n",
- "# pairs that are expected to be near-neighbors.\n",
- "constraints3 = [\n",
- " (0, 1, 0.45),\n",
- " (0, 2, 0.55),\n",
- " (3, 4, 0.50),\n",
- " (5, 6, 0.60),\n",
- "]\n",
- "\n",
- "fit3 = pv.fit_power_weights_from_plane_fractions(\n",
- " points3,\n",
- " constraints3,\n",
- " domain=box3,\n",
- " check_contacts=True,\n",
- " r_min=0.0,\n",
- ")\n",
- "\n",
- "print('rms_residual:', fit3.rms_residual)\n",
- "print('inactive_constraints:', fit3.inactive_constraints)\n"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "9eabb84c5cf64f7693dd74161576d543",
- "metadata": {},
- "source": [
- "You can now use `fit3.radii` in `mode='power'` computations.\n",
- "\n",
- "If you see many inactive constraints, that does *not* necessarily mean the optimizer failed;\n",
- "it usually means the requested pair is not a Delaunay neighbor under the fitted weights.\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 6,
- "id": "10e407c5eaa843e0a0dc04c6c367627c",
- "metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "n_cells: 12\n",
- "n_empty: 0\n"
- ]
- }
- ],
- "source": [
- "cells3 = pv.compute(points3, domain=box3, mode='power', radii=fit3.radii, include_empty=True)\n",
- "\n",
- "print('n_cells:', len(cells3))\n",
- "print('n_empty:', sum(bool(c.get('empty', False)) for c in cells3))\n"
- ]
- }
- ],
- "metadata": {
- "kernelspec": {
- "display_name": "Python 3 (ipykernel)",
- "language": "python",
- "name": "python3"
- },
- "language_info": {
- "codemirror_mode": {
- "name": "ipython",
- "version": 3
- },
- "file_extension": ".py",
- "mimetype": "text/x-python",
- "name": "python",
- "nbconvert_exporter": "python",
- "pygments_lexer": "ipython3",
- "version": "3.10.19"
- }
- },
- "nbformat": 4,
- "nbformat_minor": 5
-}
\ No newline at end of file
diff --git a/docs/notebooks/04_powerfit.md b/docs/notebooks/04_powerfit.md
new file mode 100644
index 0000000..0daf2f4
--- /dev/null
+++ b/docs/notebooks/04_powerfit.md
@@ -0,0 +1,142 @@
+
+
+[Open the original notebook on GitHub](https://github.com/DeloneCommons/pyvoro2/blob/main/notebooks/04_powerfit.ipynb)
+# Power fitting from pairwise bisector constraints
+
+This notebook shows the new math-oriented inverse API in `pyvoro2`:
+
+1. resolve pairwise bisector constraints,
+2. fit power weights under a configurable model,
+3. match realized pairs in the resulting power tessellation,
+4. run the self-consistent active-set solver.
+```python
+import numpy as np
+
+import pyvoro2 as pv
+```
+## 1) Resolve and fit a simple two-site constraint
+
+A raw constraint tuple is `(i, j, value[, shift])`, where `value` is
+interpreted in either fraction-space or absolute position-space.
+```python
+points = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float)
+box = pv.Box(((-5, 5), (-5, 5), (-5, 5)))
+
+constraints = pv.resolve_pair_bisector_constraints(
+ points,
+ [(0, 1, 0.25)],
+ measurement='fraction',
+ domain=box,
+)
+
+fit = pv.fit_power_weights(points, constraints)
+
+print('weights:', fit.weights)
+print('radii:', fit.radii)
+print('predicted fraction:', fit.predicted_fraction)
+print('predicted position:', fit.predicted_position)
+print('status:', fit.status)
+print('weight shift:', fit.weight_shift)
+```
+## 2) Add hard feasibility and a near-boundary penalty
+
+The fitting model separates mismatch, hard feasibility, and soft penalties.
+```python
+model = pv.FitModel(
+ mismatch=pv.SquaredLoss(),
+ feasible=pv.Interval(0.0, 1.0),
+ penalties=(
+ pv.ExponentialBoundaryPenalty(
+ lower=0.0,
+ upper=1.0,
+ margin=0.05,
+ strength=1.0,
+ tau=0.01,
+ ),
+ ),
+)
+
+fit_penalized = pv.fit_power_weights(
+ points,
+ [(0, 1, 1e-3)],
+ measurement='fraction',
+ domain=box,
+ model=model,
+ solver='admm',
+)
+
+print('predicted fraction with penalty:', fit_penalized.predicted_fraction[0])
+```
+## 3) Match realized pairs after fitting
+
+Requested pairwise separators do not automatically become realized faces
+in the full power tessellation.
+```python
+realized = pv.match_realized_pairs(
+ points,
+ domain=box,
+ radii=fit.radii,
+ constraints=constraints,
+ return_boundary_measure=True,
+ return_tessellation_diagnostics=True,
+)
+
+print('realized:', realized.realized)
+print('same shift:', realized.realized_same_shift)
+print('boundary measure:', realized.boundary_measure)
+print('tessellation ok:', realized.tessellation_diagnostics.ok)
+```
+## 4) Self-consistent active-set refinement
+
+For larger candidate sets, the active-set solver repeatedly fits, tessellates,
+and keeps the constraints whose requested pairs are actually realized.
+```python
+points3 = np.array(
+ [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]],
+ dtype=float,
+)
+box3 = pv.Box(((-5, 5), (-5, 5), (-5, 5)))
+
+result = pv.solve_self_consistent_power_weights(
+ points3,
+ [(0, 1, 0.5), (1, 2, 0.5), (0, 2, 0.5)],
+ measurement='fraction',
+ domain=box3,
+ options=pv.ActiveSetOptions(add_after=1, drop_after=2, relax=0.5),
+ return_history=True,
+ return_boundary_measure=True,
+)
+
+print('termination:', result.termination)
+print('active mask:', result.active_mask)
+print('constraint status:', result.diagnostics.status)
+print('marginal constraints:', result.marginal_constraints)
+
+print('path summary:', result.path_summary)
+```
+## Disconnected path example
+
+The next example starts from an empty active set so the first fitted subproblem is completely disconnected, while the final active set reconnects into the expected nearest-neighbor chain. This illustrates the difference between final-state diagnostics and optimization-path diagnostics.
+```python
+points4 = np.array(
+ [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]],
+ dtype=float,
+)
+box4 = pv.Box(((-5, 5), (-5, 5), (-5, 5)))
+
+result_path = pv.solve_self_consistent_power_weights(
+ points4,
+ [(0, 1, 0.5), (1, 2, 0.5), (0, 2, 0.5)],
+ measurement='fraction',
+ domain=box4,
+ active0=np.array([False, False, False]),
+ options=pv.ActiveSetOptions(add_after=1, drop_after=1, max_iter=6),
+ return_history=True,
+ connectivity_check='diagnose',
+ unaccounted_pair_check='diagnose',
+)
+
+print('final active graph components:', result_path.connectivity.active_graph.n_components)
+print('path summary:', result_path.path_summary)
+print('first history row:', result_path.history[0])
+```
diff --git a/docs/notebooks/05_visualization.md b/docs/notebooks/05_visualization.md
new file mode 100644
index 0000000..51b2ad2
--- /dev/null
+++ b/docs/notebooks/05_visualization.md
@@ -0,0 +1,4153 @@
+
+
+[Open the original notebook on GitHub](https://github.com/DeloneCommons/pyvoro2/blob/main/notebooks/05_visualization.ipynb)
+# Visualization with py3Dmol (optional)
+
+pyvoro2 includes a small **optional** helper module, `pyvoro2.viz3d`, for interactive visualization
+in notebooks. It is meant for exploration and debugging:
+
+- draw the domain wireframe (box or unit cell),
+- draw cell wireframes from the `vertices`/`faces` output,
+- optionally draw labeled sites and (global) vertices.
+
+Install the extra dependency with:
+
+```bash
+pip install "pyvoro2[viz]"
+```
+
+or directly:
+
+```bash
+pip install py3Dmol
+```
+
+> These helpers are intentionally lightweight. For publication-quality rendering you will usually
+> want a dedicated 3D pipeline.
+```python
+import numpy as np
+import pyvoro2 as pv
+
+from pyvoro2.viz3d import VizStyle, view_tessellation
+
+rng = np.random.default_rng(0)
+```
+## 1) Non-periodic box
+
+In a non-periodic `Box`, all returned vertices are inside the domain boundary.
+```python
+points = rng.uniform(-1.0, 1.0, size=(15, 3))
+box = pv.Box(((-2, 2), (-2, 2), (-2, 2)))
+
+cells = pv.compute(
+ points,
+ domain=box,
+ return_vertices=True,
+ return_faces=True,
+)
+
+view_tessellation(
+ cells,
+ domain=box,
+ show_vertices=False, # start simple
+)
+```
+3Dmol.js failed to load for some reason. Please check your browser console for error messages.
3Dmol.js failed to load for some reason. Please check your browser console for error messages.
3Dmol.js failed to load for some reason. Please check your browser console for error messages.
3Dmol.js failed to load for some reason. Please check your browser console for error messages.
3Dmol.js failed to load for some reason. Please check your browser console for error messages.