Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,15 @@ serde = { version = "1.0.228", features = [ "derive" ] }
serde_json = { version = "1.0.150", optional = true }
slotmap = { version = "1.1.1", features = [ "serde" ] }
thiserror = "2.0.18"
uuid = { version = "1.23.4", features = [ "v4", "serde", "fast-rng" ] }
uuid = { version = "1.23.5", features = [ "v4", "serde", "fast-rng" ] }

[dev-dependencies]
approx = "0.5.1"
criterion = { version = "0.8.2", features = [ "html_reports" ] }
pastey = "0.2.3"
proptest = "1.11.0"
serde_json = "1.0.150"
sysinfo = { version = "0.39.5", default-features = false, features = [ "system" ] } # Process memory monitoring for benchmarks
sysinfo = { version = "0.39.6", default-features = false, features = [ "system" ] } # Process memory monitoring for benchmarks
tracing-subscriber = { version = "0.3.23", features = [ "env-filter" ] }

[features]
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ This project is licensed under the [BSD 3-Clause License](https://github.com/acg
[Pachner moves]: https://en.wikipedia.org/wiki/Pachner_move
[PL-manifold]: https://en.wikipedia.org/wiki/Piecewise_linear_manifold
[Pseudomanifold]: https://en.wikipedia.org/wiki/Pseudomanifold
[readme-hero]: docs/assets/readme/delaunay_spherical_readme.png
[readme-hero]: https://raw.githubusercontent.com/acgetchell/delaunay/main/docs/assets/readme/delaunay_spherical_readme.png
[Secondary maps]: docs/workflows.md#builder-api-auxiliary-vertex-and-simplex-data
[Simulation of Simplicity]: docs/numerical_robustness_guide.md#simulation-of-simplicity-sos
[Validation Guide]: docs/validation.md
14 changes: 13 additions & 1 deletion docs/ORIENTATION_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,19 @@ Keep these documents separate:

## Orientation Invariants

The crate maintains two related but distinct orientation properties.
The crate maintains three related but distinct orientation properties.

### Intrinsic PL Orientability

Level 3 intrinsic orientability asks whether ordinary shared-facet parity
constraints admit any coherent assignment, independently of the orderings
currently stored in the TDS. For pure 2D and 3D complexes,
`Triangulation::orientation_witness()` returns an opaque `OrientationWitness`
when such an assignment exists and reports
`TriangulationValidationError::NonOrientable` for a parity obstruction.
PL-manifold Level 3 validation includes this check in those dimensions.
Periodic quotient facet parity and self-identifications are intentionally
separate from this intrinsic ordinary-facet certificate.

### Coherent Combinatorial Orientation

Expand Down
Binary file modified docs/assets/validation/validation_hierarchy.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 16 additions & 3 deletions docs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -507,15 +507,28 @@ certification.
construction completion, or when `TopologyGuarantee::PLManifoldStrict` checks every insertion):
For every vertex `v`, the link `Lk(v)` must be a (D−1)-sphere (interior vertex) or (D−1)-ball (boundary vertex).
Use `dt.validate_vertex_links()` for an explicit owner-level check.
5. **Connectedness**: All simplices form a single connected component in the simplex neighbor graph
5. **Intrinsic orientability** (for the 2D/3D PL-manifold guarantees):
Ordinary shared-facet parity constraints must admit a coherent assignment.
Use `Triangulation::orientation_witness()` to obtain the opaque
simplex-reversal certificate directly. A parity obstruction is reported as
the typed Level 3 `TriangulationValidationError::NonOrientable` diagnostic.
6. **Connectedness**: All simplices form a single connected component in the simplex neighbor graph
- Detected via a graph traversal over neighbor pointers (O(N·D))
6. **No isolated vertices**: Every vertex must be incident to at least one simplex
7. **Euler Characteristic**: χ matches expected topology (when an expectation is defined)
7. **No isolated vertices**: Every vertex must be incident to at least one simplex
8. **Euler Characteristic**: χ matches expected topology (when an expectation is defined)
- Empty: χ = 0
- Single simplex / Ball(D): χ = 1
- Closed sphere S^D: χ = 1 + (-1)^D
- Unknown: χ is computed but not enforced

Intrinsic orientability is distinct from Level 2 stored-ordering coherence:
reversing one stored simplex can violate `Tds::is_valid()` without changing
whether the underlying complex is orientable, while reversing every simplex
preserves coherence. Euler characteristic and manifold-link checks do not imply
orientability. Positive geometric orientation belongs to Level 4 and is not a
substitute for the intrinsic Level 3 certificate. Periodic quotient facet
parity and self-identifications are a separate quotient-orientability contract.

`Triangulation::validate()` (Levels 1–3) additionally runs `Tds::validate()` first.
The `DelaunayTriangulation` wrapper forwards the explicit ridge-link and
vertex-link validators to its owned `Triangulation`, so Delaunay workflows can
Expand Down
47 changes: 43 additions & 4 deletions notebooks/01_validation.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,7 @@
" \"Do fast ridge-link checks pass when required?\",\n",
" \"Do complete vertex-link checks pass when required?\",\n",
" \"Does Euler characteristic match the declared topology?\",\n",
" \"Is the intrinsic PL manifold orientable?\",\n",
" ),\n",
" color=\"#dcfce7\",\n",
" ),\n",
Expand Down Expand Up @@ -1076,7 +1077,45 @@
" glyph_cross(axis, (0.50, 0.50))\n",
"\n",
"\n",
"def draw_level_3_glyph(axis: Any, family_index: int) -> None:\n",
"def draw_euler_characteristic_glyph(axis: Any) -> None:\n",
" \"\"\"Draw a planar simplicial complex whose Euler characteristic is not two.\"\"\"\n",
" left = ((0.14, 0.39), (0.42, 0.39), (0.42, 0.73))\n",
" right = ((0.42, 0.73), (0.70, 0.73), (0.86, 0.41))\n",
" glyph_triangle(axis, left, color=\"#7dd3fc\", alpha=0.24)\n",
" glyph_triangle(axis, right, color=\"#86efac\", alpha=0.24)\n",
" axis.text(0.50, 0.275, \"V - E + F = 1 ≠ 2\", fontsize=9, weight=\"bold\", ha=\"center\", color=\"#991b1b\")\n",
"\n",
"\n",
"def draw_mobius_glyph(axis: Any) -> None:\n",
" \"\"\"Draw a projected Möbius strip witnessing intrinsic non-orientability.\"\"\"\n",
" samples = 24\n",
" centerline: list[Point2] = []\n",
" upper: list[Point2] = []\n",
" lower: list[Point2] = []\n",
" for sample in range(samples + 1):\n",
" angle = 2.0 * math.pi * sample / samples\n",
" projected_edges: list[Point2] = []\n",
" for offset in (-0.28, 0.28):\n",
" radius = 1.0 + offset * math.cos(angle / 2.0)\n",
" x = radius * math.cos(angle)\n",
" y = 0.58 * radius * math.sin(angle) + 0.34 * offset * math.sin(angle / 2.0)\n",
" projected_edges.append((0.50 + 0.29 * x, 0.51 + 0.29 * y))\n",
" lower.append(projected_edges[0])\n",
" upper.append(projected_edges[1])\n",
" centerline.append(((projected_edges[0][0] + projected_edges[1][0]) / 2.0, (projected_edges[0][1] + projected_edges[1][1]) / 2.0))\n",
" for sample in range(samples):\n",
" face = (lower[sample], lower[sample + 1], upper[sample + 1], upper[sample])\n",
" shade = \"#7dd3fc\" if sample < samples // 2 else \"#c4b5fd\"\n",
" axis.add_patch(Polygon(face, closed=True, facecolor=shade, edgecolor=\"#64748b\", linewidth=0.45, alpha=0.52))\n",
" axis.plot([point[0] for point in centerline], [point[1] for point in centerline], color=\"#334155\", linewidth=1.0, linestyle=\"--\")\n",
" top = centerline[6]\n",
" bottom = centerline[18]\n",
" axis.annotate(\"\", xy=(top[0], top[1] + 0.13), xytext=top, arrowprops={\"arrowstyle\": \"->\", \"color\": \"#dc2626\", \"linewidth\": 1.8})\n",
" axis.annotate(\"\", xy=(bottom[0], bottom[1] - 0.06), xytext=bottom, arrowprops={\"arrowstyle\": \"->\", \"color\": \"#dc2626\", \"linewidth\": 1.8})\n",
" axis.text(0.50, 0.195, \"transported normal returns reversed\", fontsize=8.2, ha=\"center\", color=\"#991b1b\")\n",
"\n",
"\n",
"def draw_level_3_glyph(axis: Any, family_index: int) -> None: # noqa: C901\n",
" \"\"\"Draw distinct intrinsic-topology failure witnesses.\"\"\"\n",
" if family_index == 0:\n",
" for center in ((0.25, 0.50), (0.75, 0.50)):\n",
Expand Down Expand Up @@ -1116,10 +1155,10 @@
" axis.plot([point[0] for point in (*component, component[0])], [point[1] for point in (*component, component[0])], color=\"#64748b\", linewidth=1.5)\n",
" axis.scatter([center[0]], [center[1]], s=36, color=\"#dc2626\")\n",
" axis.text(0.50, 0.82, \"disconnected link\", fontsize=8.5, ha=\"center\", color=\"#991b1b\")\n",
" elif family_index == 6:\n",
" draw_euler_characteristic_glyph(axis)\n",
" else:\n",
" axis.text(0.50, 0.64, \"V - E + F\", fontsize=13, weight=\"bold\", ha=\"center\")\n",
" axis.text(0.50, 0.43, \"= 1\", fontsize=12, ha=\"center\", color=\"#dc2626\")\n",
" axis.text(0.50, 0.27, \"expected 2\", fontsize=8.5, ha=\"center\", color=\"#475569\")\n",
" draw_mobius_glyph(axis)\n",
"\n",
"\n",
"def draw_level_4_glyph(axis: Any, family_index: int) -> None:\n",
Expand Down
Binary file modified papers/validation.pdf
Binary file not shown.
1 change: 1 addition & 0 deletions src/core/algorithms/incremental_insertion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2136,6 +2136,7 @@ impl InsertionError {
..
}
| TriangulationValidationError::RidgeNotFound { .. }
| TriangulationValidationError::NonOrientable { .. }
| TriangulationValidationError::EulerCharacteristicMismatch { .. }
| TriangulationValidationError::Disconnected { .. } => false,
}
Expand Down
20 changes: 20 additions & 0 deletions src/core/tds/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,8 @@ pub enum TriangulationValidationErrorKind {
RidgeLinkNotManifold,
/// A vertex link failed PL-manifold validation.
VertexLinkNotManifold,
/// The intrinsic simplex-orientation constraints are contradictory.
NonOrientable,
/// Euler characteristic did not match the expected classification.
EulerCharacteristicMismatch,
/// A vertex was not incident to any simplex.
Expand Down Expand Up @@ -1077,6 +1079,7 @@ impl From<&TriangulationValidationError> for TriangulationValidationErrorKind {
TriangulationValidationError::VertexLinkNotManifold { .. } => {
Self::VertexLinkNotManifold
}
TriangulationValidationError::NonOrientable { .. } => Self::NonOrientable,
TriangulationValidationError::EulerCharacteristicMismatch { .. } => {
Self::EulerCharacteristicMismatch
}
Expand Down Expand Up @@ -1500,6 +1503,23 @@ mod tests {
}
}

#[test]
fn triangulation_validation_error_kind_preserves_non_orientable() {
let source = TriangulationValidationError::NonOrientable {
simplex1_key: SimplexKey::from(KeyData::from_ffi(7)),
simplex1_uuid: Uuid::from_u128(7),
simplex1_facet_index: 1,
simplex2_key: SimplexKey::from(KeyData::from_ffi(8)),
simplex2_uuid: Uuid::from_u128(8),
simplex2_facet_index: 2,
};

assert_eq!(
TriangulationValidationErrorKind::from(&source),
TriangulationValidationErrorKind::NonOrientable
);
}

#[test]
fn delaunay_validation_error_kind_from_error_preserves_all_variants() {
let cases = [
Expand Down
4 changes: 2 additions & 2 deletions src/core/tds/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,7 @@ impl<U, V, const D: usize> Tds<U, V, D> {
}

/// Resolves the mirror facet for orientation validation without forcing periodic parity checks.
pub(super) fn orientation_mirror_facet_index(
pub(crate) fn orientation_mirror_facet_index(
simplex: &Simplex<V, D>,
facet_idx: usize,
neighbor_simplex: &Simplex<V, D>,
Expand Down Expand Up @@ -821,7 +821,7 @@ impl<U, V, const D: usize> Tds<U, V, D> {
/// Returns `(currently_coherent, observed_odd_permutation, expected_odd_permutation)`.
/// The expected odd parity follows the coherent boundary-orientation convention:
/// odd is expected exactly when `(facet_idx + mirror_idx)` is even.
pub(super) fn facet_permutation_parity(
pub(crate) fn facet_permutation_parity(
simplex: &Simplex<V, D>,
facet_idx: usize,
neighbor_simplex: &Simplex<V, D>,
Expand Down
Loading
Loading